Selaa lähdekoodia

重构感知信息实时展示

zengy 1 viikko sitten
vanhempi
commit
c6eec8b35f
13 muutettua tiedostoa jossa 958 lisäystä ja 53 poistoa
  1. 0 17
      firefighting-pressure-service/src/main/java/com/zksy/pressure/domain/Enum/FirefightingDeviceCodeEnum.java
  2. 0 18
      firefighting-pressure-service/src/main/java/com/zksy/pressure/domain/Enum/FirefightingWarningCodeEnum.java
  3. 72 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/perception/PerceptionController.java
  4. 3 18
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/domain/EquipmentType.java
  5. 23 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/perception/domain/vo/AlarmTypeDistVO.java
  6. 55 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/perception/domain/vo/AlarmVO.java
  7. 29 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/perception/domain/vo/AreaRankVO.java
  8. 96 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/perception/domain/vo/EquipmentProfileVO.java
  9. 21 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/perception/domain/vo/EquipmentRealtimeVO.java
  10. 31 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/perception/mapper/PerceptionMapper.java
  11. 33 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/perception/service/PerceptionService.java
  12. 558 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/perception/service/impl/PerceptionServiceImpl.java
  13. 37 0
      pipe-network-service/zksy-system/src/main/resources/mapper/perception/PerceptionMapper.xml

+ 0 - 17
firefighting-pressure-service/src/main/java/com/zksy/pressure/domain/Enum/FirefightingDeviceCodeEnum.java

@@ -1,17 +0,0 @@
-package com.zksy.pressure.domain.Enum;
-
-import lombok.AllArgsConstructor;
-import lombok.Getter;
-
-/**
- * 设备编码枚举(新增)
- */
-@Getter
-@AllArgsConstructor
-public enum FirefightingDeviceCodeEnum {
-    //firefighting
-    FIREFIGHTING_DEVICE("1302100007", "消防设备"); // 对应数据库中的设备编码
-
-    private final String code;  // 设备编码
-    private final String name;  // 设备名称
-}

+ 0 - 18
firefighting-pressure-service/src/main/java/com/zksy/pressure/domain/Enum/FirefightingWarningCodeEnum.java

@@ -1,18 +0,0 @@
-package com.zksy.pressure.domain.Enum;
-
-import lombok.AllArgsConstructor;
-import lombok.Getter;
-
-/**
- * 预警编码枚举(替代原WarningTypeEnum)
- */
-@Getter
-@AllArgsConstructor
-public enum FirefightingWarningCodeEnum {
-
-    PRESSURE("WARN-PRESSURE","消防压力过高预警",50.0);
-
-    private final String code;       // 预警编码(对应数据库)
-    private final String name;       // 预警名称
-    private final double defaultVal; // 默认阈值(数据库未配置时使用)
-}

+ 72 - 0
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/perception/PerceptionController.java

@@ -0,0 +1,72 @@
+package com.zksy.web.controller.perception;
+
+import com.zksy.base.perception.domain.vo.AlarmTypeDistVO;
+import com.zksy.base.perception.domain.vo.AlarmVO;
+import com.zksy.base.perception.domain.vo.AreaRankVO;
+import com.zksy.base.perception.domain.vo.EquipmentProfileVO;
+import com.zksy.base.perception.domain.vo.EquipmentRealtimeVO;
+import com.zksy.base.perception.service.PerceptionService;
+import com.zksy.common.core.domain.AjaxResult;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+
+@Api(tags = "感知信息实时展示")
+@RestController
+@RequestMapping("/perception")
+public class PerceptionController {
+
+    @Autowired
+    private PerceptionService perceptionService;
+
+    @ApiOperation("感知设备实时列表(含最后通讯时间)")
+    @GetMapping("/equipment")
+    public AjaxResult pageEquipment(@RequestParam(defaultValue = "1") long pageNum,
+                                    @RequestParam(defaultValue = "10") long pageSize,
+                                    @RequestParam(required = false) String equipmentCode,
+                                    @RequestParam(required = false) String equipmentName,
+                                    @RequestParam(required = false) Integer equipmentTypeId,
+                                    @RequestParam(required = false) Integer rootTypeId,
+                                    @RequestParam(required = false) Integer onlineStatus,
+                                    @RequestParam(required = false) Integer alarmStatus,
+                                    @RequestParam(required = false) Integer district,
+                                    @RequestParam(required = false) Integer currentStatus) {
+        Page<EquipmentRealtimeVO> page = perceptionService.pageEquipment(pageNum, pageSize,
+                equipmentCode, equipmentName, equipmentTypeId, rootTypeId,
+                onlineStatus, alarmStatus, district, currentStatus);
+        return AjaxResult.success(page);
+    }
+
+    @ApiOperation("设备档案(基础信息 + 最近采集记录 + 指标单位)")
+    @GetMapping("/equipment/{equipmentId}/profile")
+    public AjaxResult getProfile(@PathVariable String equipmentId) {
+        return AjaxResult.success(perceptionService.getProfile(equipmentId));
+    }
+
+    @ApiOperation("未解除报警列表")
+    @GetMapping("/alarms")
+    public AjaxResult pageActiveAlarms(@RequestParam(defaultValue = "1") long pageNum,
+                                      @RequestParam(defaultValue = "20") long pageSize) {
+        Page<AlarmVO> page = perceptionService.pageActiveAlarms(pageNum, pageSize);
+        return AjaxResult.success(page);
+    }
+
+    @ApiOperation("片区健康排行")
+    @GetMapping("/area-rank")
+    public AjaxResult areaRank() {
+        return AjaxResult.success(perceptionService.areaRank());
+    }
+
+    @ApiOperation("报警类型分布")
+    @GetMapping("/alarm-type-dist")
+    public AjaxResult alarmTypeDist() {
+        return AjaxResult.success(perceptionService.alarmTypeDist());
+    }
+}

+ 3 - 18
pipe-network-service/zksy-system/src/main/java/com/zksy/base/domain/EquipmentType.java

@@ -21,41 +21,26 @@ import java.time.LocalDateTime;
 @TableName(value = "equipment_type", schema = "app_user")
 public class EquipmentType {
 
-    /**
-     * ID
-     */
     @TableId(value = "id", type = IdType.AUTO)
     @ApiModelProperty("设备类型ID")
     private Integer id;
 
-    /**
-     * 类别名称
-     */
     @ApiModelProperty("设备类型名称")
     private String typeName;
 
-    /**
-     * 父类别ID
-     */
     @ApiModelProperty("父设备类型ID")
     private Integer parentId;
 
-    /**
-     * 备注
-     */
     @ApiModelProperty("备注")
     private String remark;
 
-    /**
-     * 创建时间
-     */
+    @ApiModelProperty("原始采集数据标名")
+    private String origCollTable;
+
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
     @ApiModelProperty("创建时间")
     private LocalDateTime createTime;
 
-    /**
-     * 更新时间
-     */
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
     @ApiModelProperty("更新时间")
     private LocalDateTime updateTime;

+ 23 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/perception/domain/vo/AlarmTypeDistVO.java

@@ -0,0 +1,23 @@
+package com.zksy.base.perception.domain.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+/** 报警类型分布。 */
+@Data
+@ApiModel("报警类型分布")
+public class AlarmTypeDistVO {
+
+    @ApiModelProperty("预警编码")
+    private String warningCode;
+
+    @ApiModelProperty("指标名称")
+    private String warningName;
+
+    @ApiModelProperty("单位")
+    private String warningUnit;
+
+    @ApiModelProperty("未解除报警数量")
+    private Long count;
+}

+ 55 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/perception/domain/vo/AlarmVO.java

@@ -0,0 +1,55 @@
+package com.zksy.base.perception.domain.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/** 未解除报警条目(含设备名/位置、指标名/单位)。 */
+@Data
+@ApiModel("实时报警条目")
+public class AlarmVO {
+
+    @ApiModelProperty("报警ID")
+    private String id;
+
+    @ApiModelProperty("报警单号")
+    private String alarmNo;
+
+    @ApiModelProperty("设备编码")
+    private String deviceCode;
+
+    @ApiModelProperty("设备名称")
+    private String deviceName;
+
+    @ApiModelProperty("设备位置")
+    private String deviceLocation;
+
+    @ApiModelProperty("预警编码")
+    private String warningCode;
+
+    @ApiModelProperty("指标名称")
+    private String warningName;
+
+    @ApiModelProperty("单位")
+    private String warningUnit;
+
+    @ApiModelProperty("报警等级")
+    private String alarmLevel;
+
+    @ApiModelProperty("实际值")
+    private BigDecimal actualValue;
+
+    @ApiModelProperty("报警内容")
+    private String alarmContent;
+
+    @ApiModelProperty("报警状态:0-未处理 1-已处理")
+    private Integer alarmStatus;
+
+    @ApiModelProperty("报警时间")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private LocalDateTime createTime;
+}

+ 29 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/perception/domain/vo/AreaRankVO.java

@@ -0,0 +1,29 @@
+package com.zksy.base.perception.domain.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+/** 片区健康排行。 */
+@Data
+@ApiModel("片区健康排行")
+public class AreaRankVO {
+
+    @ApiModelProperty("片区编码:0-城南 1-城北 2-太常")
+    private Integer district;
+
+    @ApiModelProperty("片区名称")
+    private String districtName;
+
+    @ApiModelProperty("设备总数")
+    private Long total;
+
+    @ApiModelProperty("在线数")
+    private Long online;
+
+    @ApiModelProperty("离线数")
+    private Long offline;
+
+    @ApiModelProperty("报警数")
+    private Long alarm;
+}

+ 96 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/perception/domain/vo/EquipmentProfileVO.java

@@ -0,0 +1,96 @@
+package com.zksy.base.perception.domain.vo;
+
+import com.zksy.base.domain.vo.EquipmentBaseVO;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+
+/** 设备档案:基础信息 + 原始采集表信息 + 最近采集记录 + 关联指标单位。 */
+@Data
+@ApiModel("设备档案与采集详情")
+public class EquipmentProfileVO {
+
+    @ApiModelProperty("设备基础信息")
+    private EquipmentBaseVO base;
+
+    @ApiModelProperty("原始采集表名(来自 equipment_type.orig_coll_table)")
+    private String origCollTable;
+
+    @ApiModelProperty("原始采集表设备标识列名")
+    private String idColumn;
+
+    @ApiModelProperty("最后通讯时间(原始采集表最新一条 create_time)")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private LocalDateTime lastCommTime;
+
+    @ApiModelProperty("最近采集记录(原始表最近 50 条,字段随表变化)")
+    private List<Map<String, Object>> records;
+
+    @ApiModelProperty("采集记录展示视图(按原始表定义的列 / 单位 / 展示形式)")
+    private RecordView recordView;
+
+    @ApiModelProperty("该设备类型关联的监测指标(名称 + 单位,参考 warning_type)")
+    private List<WarningIndicator> indicators;
+
+    @Data
+    @ApiModel("监测指标及单位")
+    public static class WarningIndicator {
+        @ApiModelProperty("预警编码")
+        private String warningCode;
+        @ApiModelProperty("指标名称")
+        private String warningName;
+        @ApiModelProperty("单位")
+        private String warningUnit;
+    }
+
+    @Data
+    @ApiModel("采集记录展示视图")
+    public static class RecordView {
+        @ApiModelProperty("布局:wide=宽表(一行一时刻多指标);long=竖表(一行一指标)")
+        private String layout;
+        @ApiModelProperty("列定义(含采集时间列)")
+        private List<RecordColumn> columns;
+        @ApiModelProperty("行数据")
+        private List<RecordRow> rows;
+    }
+
+    @Data
+    @ApiModel("采集记录列定义")
+    public static class RecordColumn {
+        @ApiModelProperty("字段 key")
+        private String key;
+        @ApiModelProperty("列标题")
+        private String label;
+        @ApiModelProperty("单位")
+        private String unit;
+        @ApiModelProperty("是否报警状态列(0 正常 / 1 报警)")
+        private boolean alarm;
+    }
+
+    @Data
+    @ApiModel("采集记录行")
+    public static class RecordRow {
+        @ApiModelProperty("采集时间")
+        private String createTime;
+        @ApiModelProperty("指标单元格(与 columns 顺序一致,不含时间列)")
+        private List<RecordCell> cells;
+    }
+
+    @Data
+    @ApiModel("采集记录单元格")
+    public static class RecordCell {
+        @ApiModelProperty("字段 key")
+        private String key;
+        @ApiModelProperty("展示串(已含单位,用于竖表 / 降级)")
+        private String display;
+        @ApiModelProperty("纯值(不含单位,用于宽表渲染)")
+        private String value;
+        @ApiModelProperty("是否报警(标红)")
+        private boolean alarm;
+    }
+}

+ 21 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/perception/domain/vo/EquipmentRealtimeVO.java

@@ -0,0 +1,21 @@
+package com.zksy.base.perception.domain.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.zksy.base.domain.vo.EquipmentBaseVO;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.time.LocalDateTime;
+
+/** 感知设备实时卡片:在设备基础信息之上补充最后通讯时间。 */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ApiModel("感知设备实时信息")
+public class EquipmentRealtimeVO extends EquipmentBaseVO {
+
+    @ApiModelProperty("最后通讯时间(原始采集表最新一条 create_time)")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private LocalDateTime lastCommTime;
+}

+ 31 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/perception/mapper/PerceptionMapper.java

@@ -0,0 +1,31 @@
+package com.zksy.base.perception.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+
+/** 感知实时展示专用 Mapper:动态原始采集表查询 + 聚合统计。 */
+@Mapper
+public interface PerceptionMapper {
+
+    /** 按设备编码查询某原始采集表最近 limit 条记录(表名/标识列由白名单约束,外部已校验)。 */
+    List<Map<String, Object>> selectLatestRecords(@Param("table") String table,
+                                                 @Param("idColumn") String idColumn,
+                                                 @Param("deviceCode") String deviceCode,
+                                                 @Param("limit") int limit);
+
+    /** 取某原始采集表该设备的最新通讯时间(create_time 最大值)。 */
+    LocalDateTime selectLastCommTime(@Param("table") String table,
+                                    @Param("idColumn") String idColumn,
+                                    @Param("deviceCode") String deviceCode);
+
+    /** 按片区(district)聚合设备在线/离线/报警统计。 */
+    List<Map<String, Object>> selectAreaStats();
+
+    /** 未解除报警按 warning_code 分组计数。 */
+    List<Map<String, Object>> selectAlarmTypeDist();
+}

+ 33 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/perception/service/PerceptionService.java

@@ -0,0 +1,33 @@
+package com.zksy.base.perception.service;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.zksy.base.perception.domain.vo.AlarmTypeDistVO;
+import com.zksy.base.perception.domain.vo.AlarmVO;
+import com.zksy.base.perception.domain.vo.AreaRankVO;
+import com.zksy.base.perception.domain.vo.EquipmentProfileVO;
+import com.zksy.base.perception.domain.vo.EquipmentRealtimeVO;
+
+import java.util.List;
+
+/** 感知信息实时展示服务。 */
+public interface PerceptionService {
+
+    /** 感知设备实时分页列表(补充最后通讯时间)。 */
+    Page<EquipmentRealtimeVO> pageEquipment(long pageNum, long pageSize,
+                                           String equipmentCode, String equipmentName,
+                                           Integer equipmentTypeId, Integer rootTypeId,
+                                           Integer onlineStatus, Integer alarmStatus,
+                                           Integer district, Integer currentStatus);
+
+    /** 设备档案:基础信息 + 原始采集表最近 50 条 + 关联指标单位。 */
+    EquipmentProfileVO getProfile(String equipmentId);
+
+    /** 未解除报警(audit_status=1 & alarm_status=0)分页列表。 */
+    Page<AlarmVO> pageActiveAlarms(long pageNum, long pageSize);
+
+    /** 片区健康排行。 */
+    List<AreaRankVO> areaRank();
+
+    /** 报警类型分布。 */
+    List<AlarmTypeDistVO> alarmTypeDist();
+}

+ 558 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/perception/service/impl/PerceptionServiceImpl.java

@@ -0,0 +1,558 @@
+package com.zksy.base.perception.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.domain.EquipmentStatus;
+import com.zksy.base.domain.EquipmentType;
+import com.zksy.base.domain.vo.EquipmentBaseVO;
+import com.zksy.base.mapper.EquipmentBaseMapper;
+import com.zksy.base.mapper.EquipmentStatusMapper;
+import com.zksy.base.mapper.EquipmentTypeMapper;
+import com.zksy.base.perception.domain.vo.AlarmTypeDistVO;
+import com.zksy.base.perception.domain.vo.AlarmVO;
+import com.zksy.base.perception.domain.vo.AreaRankVO;
+import com.zksy.base.perception.domain.vo.EquipmentProfileVO;
+import com.zksy.base.perception.domain.vo.EquipmentRealtimeVO;
+import com.zksy.base.perception.mapper.PerceptionMapper;
+import com.zksy.base.perception.service.PerceptionService;
+import com.zksy.base.service.EquipmentBaseService;
+import com.zksy.base.alarm.domain.AlarmData;
+import com.zksy.base.alarm.mapper.AlarmDataMapper;
+import com.zksy.base.warning.domain.EquipmentWarningType;
+import com.zksy.base.warning.domain.WarningType;
+import com.zksy.base.warning.mapper.EquipmentWarningTypeMapper;
+import com.zksy.base.warning.mapper.WarningTypeMapper;
+import org.springframework.beans.BeanUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.text.DecimalFormat;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.TemporalAccessor;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+/**
+ * 感知信息实时展示服务实现。
+ * 设备最后通讯时间 / 采集历史均按 equipment_type.orig_coll_table 动态定位原始采集表。
+ */
+@Service
+public class PerceptionServiceImpl implements PerceptionService {
+
+    /** 原始采集表 -> 设备标识列(白名单,防止动态表名 SQL 注入)。 */
+    private static final Map<String, String> TABLE_ID_COLUMN = new LinkedHashMap<>();
+    static {
+        TABLE_ID_COLUMN.put("e_real_time_data", "device_id");
+        TABLE_ID_COLUMN.put("firefighting_pressure", "telemetering_station");
+        TABLE_ID_COLUMN.put("gas_monitor_data", "mac_address");
+        TABLE_ID_COLUMN.put("jg_device_data", "imei_card_number");
+        TABLE_ID_COLUMN.put("radar_data", "device_code");
+        TABLE_ID_COLUMN.put("telemetry_data", "device_code");
+    }
+
+    /** 片区编码 -> 名称。 */
+    private static final Map<Integer, String> DISTRICT_NAMES = new HashMap<>();
+    static {
+        DISTRICT_NAMES.put(0, "城南");
+        DISTRICT_NAMES.put(1, "城北");
+        DISTRICT_NAMES.put(2, "太常");
+    }
+
+    @Autowired
+    private EquipmentBaseService equipmentBaseService;
+    @Autowired
+    private EquipmentBaseMapper equipmentBaseMapper;
+    @Autowired
+    private EquipmentTypeMapper equipmentTypeMapper;
+    @Autowired
+    private AlarmDataMapper alarmDataMapper;
+    @Autowired
+    private WarningTypeMapper warningTypeMapper;
+    @Autowired
+    private EquipmentWarningTypeMapper equipmentWarningTypeMapper;
+    @Autowired
+    private PerceptionMapper perceptionMapper;
+    @Autowired
+    private EquipmentStatusMapper equipmentStatusMapper;
+
+    @Override
+    public Page<EquipmentRealtimeVO> pageEquipment(long pageNum, long pageSize, String equipmentCode,
+                                                  String equipmentName, Integer equipmentTypeId, Integer rootTypeId,
+                                                  Integer onlineStatus, Integer alarmStatus,
+                                                  Integer district, Integer currentStatus) {
+        Page<EquipmentBaseVO> basePage = equipmentBaseService.findByPage(pageNum, pageSize, equipmentCode,
+                equipmentName, equipmentTypeId, rootTypeId, null, onlineStatus, alarmStatus,
+                null, district, currentStatus);
+        Page<EquipmentRealtimeVO> result = new Page<>(basePage.getCurrent(), basePage.getSize(), basePage.getTotal());
+        List<EquipmentRealtimeVO> records = new ArrayList<>();
+        for (EquipmentBaseVO vo : basePage.getRecords()) {
+            EquipmentRealtimeVO rv = new EquipmentRealtimeVO();
+            BeanUtils.copyProperties(vo, rv);
+            rv.setLastCommTime(queryLastCommTime(vo.getEquipmentId()));
+            records.add(rv);
+        }
+        result.setRecords(records);
+        return result;
+    }
+
+    private LocalDateTime queryLastCommTime(String equipmentId) {
+        EquipmentBase base = equipmentBaseMapper.selectById(equipmentId);
+        if (base == null) {
+            return null;
+        }
+        String table = resolveTable(base.getEquipmentTypeId());
+        if (table == null) {
+            return null;
+        }
+        return perceptionMapper.selectLastCommTime(table, TABLE_ID_COLUMN.get(table), base.getEquipmentCode());
+    }
+
+    /** 由 equipment_type_id -> equipment_type.orig_coll_table 解析出原始采集表;不在白名单返回 null。 */
+    private String resolveTable(Integer equipmentTypeId) {
+        if (equipmentTypeId == null) {
+            return null;
+        }
+        EquipmentType type = equipmentTypeMapper.selectById(equipmentTypeId);
+        if (type == null || type.getOrigCollTable() == null) {
+            return null;
+        }
+        String table = type.getOrigCollTable().trim();
+        return TABLE_ID_COLUMN.containsKey(table) ? table : null;
+    }
+
+    @Override
+    public EquipmentProfileVO getProfile(String equipmentId) {
+        EquipmentBase base = equipmentBaseMapper.selectById(equipmentId);
+        EquipmentProfileVO profile = new EquipmentProfileVO();
+        if (base == null) {
+            return profile;
+        }
+        EquipmentBaseVO vo = new EquipmentBaseVO();
+        BeanUtils.copyProperties(base, vo);
+        if (base.getEquipmentTypeId() != null) {
+            vo.setEquipmentTypeId(String.valueOf(base.getEquipmentTypeId()));
+        }
+        EquipmentStatus status = equipmentStatusMapper.selectOne(
+                new LambdaQueryWrapper<EquipmentStatus>().eq(EquipmentStatus::getEquipmentId, equipmentId));
+        if (status != null) {
+            vo.setCurrentStatus(status.getCurrentStatus());
+            vo.setAlarmStatus(status.getAlarmStatus());
+            vo.setOnlineStatus(status.getOnlineStatus());
+        }
+        profile.setBase(vo);
+        profile.setLastCommTime(queryLastCommTime(equipmentId));
+
+        EquipmentType type = equipmentTypeMapper.selectById(base.getEquipmentTypeId());
+        if (type != null) {
+            profile.setOrigCollTable(type.getOrigCollTable());
+            vo.setEquipmentTypeName(type.getTypeName());
+        }
+        String table = resolveTable(base.getEquipmentTypeId());
+        List<Map<String, Object>> rawRecords;
+        if (table != null) {
+            profile.setIdColumn(TABLE_ID_COLUMN.get(table));
+            rawRecords = perceptionMapper.selectLatestRecords(table, TABLE_ID_COLUMN.get(table),
+                    base.getEquipmentCode(), 50);
+        } else {
+            rawRecords = Collections.emptyList();
+        }
+        // 保留原始记录(前端兼容),同时生成按表定义的展示视图
+        profile.setRecords(rawRecords);
+        profile.setRecordView(buildRecordView(table, rawRecords));
+        profile.setIndicators(queryIndicators(base.getEquipmentTypeId()));
+        return profile;
+    }
+
+    /** 设备类型关联的监测指标(名称 + 单位),参考 warning_type。 */
+    private List<EquipmentProfileVO.WarningIndicator> queryIndicators(Integer equipmentTypeId) {
+        List<EquipmentProfileVO.WarningIndicator> list = new ArrayList<>();
+        if (equipmentTypeId == null) {
+            return list;
+        }
+        List<EquipmentWarningType> rels = equipmentWarningTypeMapper.selectList(
+                new LambdaQueryWrapper<EquipmentWarningType>().eq(EquipmentWarningType::getEquipmentTypeId, equipmentTypeId));
+        if (rels.isEmpty()) {
+            return list;
+        }
+        List<Integer> wtIds = rels.stream().map(EquipmentWarningType::getWarningTypeId).collect(Collectors.toList());
+        List<WarningType> types = warningTypeMapper.selectBatchIds(wtIds);
+        Map<Integer, WarningType> typeMap = types.stream().collect(Collectors.toMap(WarningType::getId, t -> t, (a, b) -> a));
+        for (EquipmentWarningType rel : rels) {
+            WarningType wt = typeMap.get(rel.getWarningTypeId());
+            if (wt == null) {
+                continue;
+            }
+            EquipmentProfileVO.WarningIndicator ind = new EquipmentProfileVO.WarningIndicator();
+            ind.setWarningCode(wt.getWarningCode());
+            ind.setWarningName(wt.getWarningName());
+            ind.setWarningUnit(wt.getWarningUnit());
+            list.add(ind);
+        }
+        return list;
+    }
+
+    @Override
+    public Page<AlarmVO> pageActiveAlarms(long pageNum, long pageSize) {
+        Page<AlarmData> page = alarmDataMapper.selectPage(new Page<>(pageNum, pageSize),
+                new LambdaQueryWrapper<AlarmData>()
+                        .eq(AlarmData::getAuditStatus, 1)
+                        .eq(AlarmData::getAlarmStatus, 0)
+                        .orderByDesc(AlarmData::getCreateTime));
+
+        List<WarningType> wtList = warningTypeMapper.selectList(null);
+        Map<String, WarningType> wtMap = wtList.stream()
+                .collect(Collectors.toMap(WarningType::getWarningCode, w -> w, (a, b) -> a));
+
+        List<String> codes = page.getRecords().stream().map(AlarmData::getDeviceCode)
+                .filter(Objects::nonNull).distinct().collect(Collectors.toList());
+        Map<String, EquipmentBase> devMap = new HashMap<>();
+        if (!codes.isEmpty()) {
+            devMap = equipmentBaseMapper.selectList(
+                            new LambdaQueryWrapper<EquipmentBase>().in(EquipmentBase::getEquipmentCode, codes))
+                    .stream().collect(Collectors.toMap(EquipmentBase::getEquipmentCode, e -> e, (a, b) -> a));
+        }
+
+        Page<AlarmVO> result = new Page<>(page.getCurrent(), page.getSize(), page.getTotal());
+        List<AlarmVO> records = new ArrayList<>();
+        for (AlarmData a : page.getRecords()) {
+            AlarmVO vo = new AlarmVO();
+            BeanUtils.copyProperties(a, vo);
+            WarningType wt = wtMap.get(a.getWarningCode());
+            if (wt != null) {
+                vo.setWarningName(wt.getWarningName());
+                vo.setWarningUnit(wt.getWarningUnit());
+            }
+            EquipmentBase dev = devMap.get(a.getDeviceCode());
+            if (dev != null) {
+                vo.setDeviceName(dev.getEquipmentName());
+                vo.setDeviceLocation(dev.getEquipmentLocation());
+            }
+            records.add(vo);
+        }
+        result.setRecords(records);
+        return result;
+    }
+
+    @Override
+    public List<AreaRankVO> areaRank() {
+        List<Map<String, Object>> rows = perceptionMapper.selectAreaStats();
+        List<AreaRankVO> list = new ArrayList<>();
+        for (Map<String, Object> row : rows) {
+            AreaRankVO vo = new AreaRankVO();
+            Integer district = row.get("district") == null ? null : ((Number) row.get("district")).intValue();
+            vo.setDistrict(district);
+            vo.setDistrictName(DISTRICT_NAMES.getOrDefault(district, "未知"));
+            vo.setTotal(toLong(row.get("total")));
+            vo.setOnline(toLong(row.get("online")));
+            vo.setOffline(toLong(row.get("offline")));
+            vo.setAlarm(toLong(row.get("alarm")));
+            list.add(vo);
+        }
+        // 按在线率降序排行(在线率 = online / total),并列时按在线数降序
+        list.sort((x, y) -> {
+            double rx = (x.getTotal() == null || x.getTotal() == 0) ? 0 : (double) x.getOnline() / x.getTotal();
+            double ry = (y.getTotal() == null || y.getTotal() == 0) ? 0 : (double) y.getOnline() / y.getTotal();
+            if (rx != ry) return Double.compare(ry, rx);
+            return Long.compare(y.getOnline() == null ? 0 : y.getOnline(), x.getOnline() == null ? 0 : x.getOnline());
+        });
+        return list;
+    }
+
+    @Override
+    public List<AlarmTypeDistVO> alarmTypeDist() {
+        List<Map<String, Object>> rows = perceptionMapper.selectAlarmTypeDist();
+        List<WarningType> wtList = warningTypeMapper.selectList(null);
+        Map<String, WarningType> wtMap = wtList.stream()
+                .collect(Collectors.toMap(WarningType::getWarningCode, w -> w, (a, b) -> a));
+        List<AlarmTypeDistVO> list = new ArrayList<>();
+        for (Map<String, Object> row : rows) {
+            String code = (String) row.get("warningCode");
+            AlarmTypeDistVO vo = new AlarmTypeDistVO();
+            vo.setWarningCode(code);
+            vo.setCount(toLong(row.get("cnt")));
+            WarningType wt = wtMap.get(code);
+            if (wt != null) {
+                vo.setWarningName(wt.getWarningName());
+                vo.setWarningUnit(wt.getWarningUnit());
+            }
+            list.add(vo);
+        }
+        return list;
+    }
+
+    /**
+     * 按原始采集表类型,把最近 50 条原始记录转换为前端可直接渲染的展示视图。
+     * 列名 / 字段说明 / 单位 / 展示形式严格按各原始表定义。表中缺失的列优雅降级为"—"。
+     */
+    private EquipmentProfileVO.RecordView buildRecordView(String table, List<Map<String, Object>> raw) {
+        EquipmentProfileVO.RecordView view = new EquipmentProfileVO.RecordView();
+        view.setColumns(new ArrayList<>());
+        view.setRows(new ArrayList<>());
+        if (raw == null) {
+            raw = Collections.emptyList();
+        }
+        if (table == null) {
+            table = "";
+        }
+        switch (table) {
+            case "e_real_time_data":
+                // 竖表:一行一指标,node_id 决定监测因子
+                view.setLayout("long");
+                view.setColumns(Arrays.asList(
+                        column("create_time", "采集时间", "", false),
+                        column("indicator", "水质指标", "", false)));
+                for (Map<String, Object> r : raw) {
+                    EquipmentProfileVO.RecordRow row = new EquipmentProfileVO.RecordRow();
+                    row.setCreateTime(asString(r.get("create_time")));
+                    Integer node = toInt(r.get("node_id"));
+                    String src;
+                    String name;
+                    String unit;
+                    if (node != null && node == 1) {
+                        name = "悬浮物";
+                        src = "float_value";
+                        unit = "mg/L";
+                    } else if (node != null && node == 2) {
+                        name = "COD";
+                        src = "hum";
+                        unit = "mg/L";
+                    } else if (node != null && node == 3) {
+                        name = "氨氮";
+                        src = "hum";
+                        unit = "mg/L";
+                    } else if (node != null && node == 4) {
+                        name = "电导率";
+                        src = "hum";
+                        unit = "μS/cm";
+                    } else if (node != null && node == 5) {
+                        name = "PH";
+                        src = "hum";
+                        unit = "";
+                    } else {
+                        name = "未知因子";
+                        src = "hum";
+                        unit = "";
+                    }
+                    EquipmentProfileVO.RecordCell cell = new EquipmentProfileVO.RecordCell();
+                    cell.setKey("indicator");
+                    String disp = name + ":" + formatVal(r.get(src), unit);
+                    cell.setDisplay(disp);
+                    cell.setValue(disp);
+                    cell.setAlarm(false);
+                    row.setCells(Collections.singletonList(cell));
+                    view.getRows().add(row);
+                }
+                break;
+            case "firefighting_pressure":
+                view.setLayout("wide");
+                view.setColumns(Arrays.asList(
+                        column("create_time", "采集时间", "", false),
+                        column("pressure_value", "压力", "MPa", false)));
+                view.setRows(mapWide(raw, Collections.singletonList(
+                        new ColDef("pressure_value", "压力", "MPa", false))));
+                break;
+            case "gas_monitor_data": {
+                List<ColDef> defs = Arrays.asList(
+                        new ColDef("gas_concentration", "气体浓度", "%LEL", false),
+                        new ColDef("temperature", "温度", "℃", false),
+                        new ColDef("humidity", "湿度", "%RH", false),
+                        new ColDef("battery", "电池电量", "%", false));
+                view.setLayout("wide");
+                view.setColumns(columnsWithTime(defs));
+                view.setRows(mapWide(raw, defs));
+                break;
+            }
+            case "jg_device_data": {
+                List<ColDef> defs = Arrays.asList(
+                        new ColDef("tilt_angle", "倾斜角度", "°", false),
+                        new ColDef("water_infiltration_alarm_status", "水浸报警状态", "", true),
+                        new ColDef("water_level_alarm_status", "水位报警状态", "", true),
+                        new ColDef("temperature_value", "温度", "℃", false),
+                        new ColDef("battery_level", "电池电量", "%", false));
+                view.setLayout("wide");
+                view.setColumns(columnsWithTime(defs));
+                view.setRows(mapWide(raw, defs));
+                break;
+            }
+            case "radar_data": {
+                List<ColDef> defs = Arrays.asList(
+                        new ColDef("meter1_net_total", "累计流量", "m³/h", false),
+                        new ColDef("meter1_instant_flow", "瞬时流量", "m³/h", false),
+                        new ColDef("flow_speed", "流速", "m/s", false),
+                        new ColDef("water_level", "水位", "m", false));
+                view.setLayout("wide");
+                view.setColumns(columnsWithTime(defs));
+                view.setRows(mapWide(raw, defs));
+                break;
+            }
+            case "telemetry_data": {
+                List<ColDef> defs = Arrays.asList(
+                        new ColDef("pressure", "压力", "MPa", false),
+                        new ColDef("flow_speed", "流速", "m/s", false),
+                        new ColDef("temperature", "温度", "℃", false));
+                view.setLayout("wide");
+                view.setColumns(columnsWithTime(defs));
+                view.setRows(mapWide(raw, defs));
+                break;
+            }
+            default:
+                view.setLayout("wide");
+                break;
+        }
+        return view;
+    }
+
+    private EquipmentProfileVO.RecordColumn column(String key, String label, String unit, boolean alarm) {
+        EquipmentProfileVO.RecordColumn c = new EquipmentProfileVO.RecordColumn();
+        c.setKey(key);
+        c.setLabel(label);
+        c.setUnit(unit);
+        c.setAlarm(alarm);
+        return c;
+    }
+
+    private List<EquipmentProfileVO.RecordColumn> columnsWithTime(List<ColDef> defs) {
+        List<EquipmentProfileVO.RecordColumn> cols = new ArrayList<>();
+        cols.add(column("create_time", "采集时间", "", false));
+        for (ColDef d : defs) {
+            cols.add(d.toColumn());
+        }
+        return cols;
+    }
+
+    private List<EquipmentProfileVO.RecordRow> mapWide(List<Map<String, Object>> raw, List<ColDef> defs) {
+        List<EquipmentProfileVO.RecordRow> rows = new ArrayList<>();
+        for (Map<String, Object> r : raw) {
+            EquipmentProfileVO.RecordRow row = new EquipmentProfileVO.RecordRow();
+            row.setCreateTime(asString(r.get("create_time")));
+            List<EquipmentProfileVO.RecordCell> cells = new ArrayList<>();
+            for (ColDef d : defs) {
+                EquipmentProfileVO.RecordCell c = new EquipmentProfileVO.RecordCell();
+                c.setKey(d.source);
+                if (d.alarm) {
+                    Integer v = toInt(r.get(d.source));
+                    boolean on = v != null && v == 1;
+                    c.setDisplay(on ? "报警" : "正常");
+                    c.setValue(c.getDisplay());
+                    c.setAlarm(on);
+                } else {
+                    c.setDisplay(formatVal(r.get(d.source), d.unit));
+                    c.setValue(formatPlain(r.get(d.source)));
+                    c.setAlarm(false);
+                }
+                cells.add(c);
+            }
+            row.setCells(cells);
+            rows.add(row);
+        }
+        return rows;
+    }
+
+    private String formatVal(Object v, String unit) {
+        if (v == null) {
+            return "—";
+        }
+        if (v instanceof Number) {
+            double d = ((Number) v).doubleValue();
+            if (Double.isNaN(d) || Double.isInfinite(d)) {
+                return "—";
+            }
+            return new DecimalFormat("0.##").format(d) + unit;
+        }
+        String s = String.valueOf(v).trim();
+        if (s.isEmpty()) {
+            return "—";
+        }
+        return s + unit;
+    }
+
+    private String formatPlain(Object v) {
+        if (v == null) {
+            return "—";
+        }
+        if (v instanceof Number) {
+            double d = ((Number) v).doubleValue();
+            if (Double.isNaN(d) || Double.isInfinite(d)) {
+                return "—";
+            }
+            return new DecimalFormat("0.##").format(d);
+        }
+        String s = String.valueOf(v).trim();
+        return s.isEmpty() ? "—" : s;
+    }
+
+    private Integer toInt(Object o) {
+        if (o == null) {
+            return null;
+        }
+        if (o instanceof Number) {
+            return ((Number) o).intValue();
+        }
+        try {
+            return Integer.parseInt(String.valueOf(o).trim());
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
+    private String asString(Object o) {
+        if (o == null) {
+            return "";
+        }
+        if (o instanceof TemporalAccessor) {
+            try {
+                return DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").format((TemporalAccessor) o);
+            } catch (Exception ignored) {
+                // 回退到下面处理
+            }
+        }
+        if (o instanceof java.util.Date) {
+            // MyBatis 默认将 datetime 映射为 java.sql.Timestamp,其 toString 会带 .0,需格式化去除
+            return new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format((java.util.Date) o);
+        }
+        return String.valueOf(o);
+    }
+
+    /** 采集记录列定义:source=原始表列名;label=展示标题;unit=单位;alarm=0正常/1报警。 */
+    private static class ColDef {
+        final String source;
+        final String label;
+        final String unit;
+        final boolean alarm;
+
+        ColDef(String source, String label, String unit, boolean alarm) {
+            this.source = source;
+            this.label = label;
+            this.unit = unit;
+            this.alarm = alarm;
+        }
+
+        EquipmentProfileVO.RecordColumn toColumn() {
+            EquipmentProfileVO.RecordColumn c = new EquipmentProfileVO.RecordColumn();
+            c.setKey(source);
+            c.setLabel(label);
+            c.setUnit(unit);
+            c.setAlarm(alarm);
+            return c;
+        }
+    }
+
+    private Long toLong(Object o) {
+        if (o == null) {
+            return 0L;
+        }
+        if (o instanceof Number) {
+            return ((Number) o).longValue();
+        }
+        return 0L;
+    }
+}

+ 37 - 0
pipe-network-service/zksy-system/src/main/resources/mapper/perception/PerceptionMapper.xml

@@ -0,0 +1,37 @@
+<?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.perception.mapper.PerceptionMapper">
+
+    <select id="selectLatestRecords" resultType="java.util.HashMap">
+        SELECT * FROM ${table}
+        WHERE ${idColumn} = #{deviceCode}
+        ORDER BY create_time DESC
+        LIMIT #{limit}
+    </select>
+
+    <select id="selectLastCommTime" resultType="java.time.LocalDateTime">
+        SELECT MAX(create_time) FROM ${table}
+        WHERE ${idColumn} = #{deviceCode}
+    </select>
+
+    <select id="selectAreaStats" resultType="java.util.HashMap">
+        SELECT b.district AS district,
+               COUNT(*) AS total,
+               SUM(CASE WHEN s.online_status = 1 THEN 1 ELSE 0 END) AS online,
+               SUM(CASE WHEN s.online_status = 0 THEN 1 ELSE 0 END) AS offline,
+               SUM(CASE WHEN s.alarm_status = 1 THEN 1 ELSE 0 END) AS alarm
+        FROM equipment_base b
+        LEFT JOIN equipment_status s ON s.equipment_id = b.equipment_id
+        GROUP BY b.district
+        ORDER BY b.district
+    </select>
+
+    <select id="selectAlarmTypeDist" resultType="java.util.HashMap">
+        SELECT a.warning_code AS warningCode, COUNT(*) AS cnt
+        FROM alarm_data a
+        WHERE a.audit_status = 1 AND a.alarm_status = 0
+        GROUP BY a.warning_code
+        ORDER BY cnt DESC
+    </select>
+
+</mapper>