diff --git a/db/postgresql/V3__monitor_list.sql b/db/postgresql/V3__monitor_list.sql new file mode 100644 index 00000000..9c757584 --- /dev/null +++ b/db/postgresql/V3__monitor_list.sql @@ -0,0 +1,90 @@ +-- ============================================= +-- 智慧水务管理系统 - 在线监测列表 DDL +-- 版本: V3 +-- 功能: 在线监测设备 + 实时数据 + 多维筛选 +-- ============================================= + +-- ==================== 在线监测设备 ==================== + +CREATE TABLE IF NOT EXISTS prod_monitor_device ( + id BIGSERIAL PRIMARY KEY, + device_code VARCHAR(50) NOT NULL UNIQUE, -- 设备编号 + device_name VARCHAR(200) NOT NULL, -- 设备名称 + device_type VARCHAR(30) NOT NULL, -- 设备类型: flow/pressure/level/quality + area VARCHAR(50) NOT NULL, -- 所属区域 + location VARCHAR(300), -- 安装位置描述 + lng DECIMAL(12, 8), -- 经度 + lat DECIMAL(12, 8), -- 纬度 + status VARCHAR(20) NOT NULL DEFAULT 'offline', -- 设备状态: online/offline/fault/abnormal + last_report_time TIMESTAMP, -- 最后上报时间 + brand VARCHAR(100), -- 品牌/型号 + install_time TIMESTAMP, -- 安装时间 + remark VARCHAR(500), -- 备注 + created_time TIMESTAMP DEFAULT NOW(), + updated_time TIMESTAMP DEFAULT NOW() +); + +COMMENT ON TABLE prod_monitor_device IS '在线监测设备表'; +COMMENT ON COLUMN prod_monitor_device.device_type IS '设备类型: flow(流量计)/pressure(压力计)/level(液位计)/quality(水质仪)'; +COMMENT ON COLUMN prod_monitor_device.status IS '设备状态: online(在线)/offline(离线)/fault(故障)/abnormal(数据异常)'; + +CREATE INDEX IF NOT EXISTS idx_monitor_device_area ON prod_monitor_device(area); +CREATE INDEX IF NOT EXISTS idx_monitor_device_type ON prod_monitor_device(device_type); +CREATE INDEX IF NOT EXISTS idx_monitor_device_status ON prod_monitor_device(status); +CREATE INDEX IF NOT EXISTS idx_monitor_device_report ON prod_monitor_device(last_report_time DESC); +CREATE INDEX IF NOT EXISTS idx_monitor_device_code_name ON prod_monitor_device(device_code, device_name); + +-- ==================== 监测实时数据 ==================== + +CREATE TABLE IF NOT EXISTS prod_monitor_realtime_data ( + id BIGSERIAL PRIMARY KEY, + device_id BIGINT NOT NULL REFERENCES prod_monitor_device(id), -- 关联设备 + device_code VARCHAR(50) NOT NULL, -- 设备编号(冗余) + metric_key VARCHAR(50) NOT NULL, -- 参数类型: flow/pressure/level/turbidity/ph/residual_chlorine/temperature + metric_value DECIMAL(14, 4) NOT NULL, -- 实时值 + unit VARCHAR(20), -- 单位 + threshold_high DECIMAL(14, 4), -- 阈值上限 + threshold_low DECIMAL(14, 4), -- 阈值下限 + is_abnormal SMALLINT DEFAULT 0, -- 是否异常: 0正常 1异常 + collect_time TIMESTAMP NOT NULL, -- 采集时间 + created_time TIMESTAMP DEFAULT NOW() +); + +COMMENT ON TABLE prod_monitor_realtime_data IS '监测实时数据表'; +COMMENT ON COLUMN prod_monitor_realtime_data.metric_key IS '参数类型: flow(流量)/pressure(压力)/level(液位)/turbidity(浊度)/ph(pH值)/residual_chlorine(余氯)/temperature(温度)'; + +CREATE INDEX IF NOT EXISTS idx_realtime_device_id ON prod_monitor_realtime_data(device_id); +CREATE INDEX IF NOT EXISTS idx_realtime_device_code ON prod_monitor_realtime_data(device_code); +CREATE INDEX IF NOT EXISTS idx_realtime_metric_key ON prod_monitor_realtime_data(metric_key); +CREATE INDEX IF NOT EXISTS idx_realtime_collect_time ON prod_monitor_realtime_data(collect_time DESC); +CREATE INDEX IF NOT EXISTS idx_realtime_device_metric ON prod_monitor_realtime_data(device_id, metric_key, collect_time DESC); + +-- ==================== 初始化数据(示例) ==================== + +INSERT INTO prod_monitor_device (device_code, device_name, device_type, area, location, lng, lat, status, last_report_time, brand) +VALUES + ('MON-FLOW-001', '一号泵站出口流量计', 'flow', '一体化水厂', '一号泵站出口', 82.07123456, 44.84567890, 'online', NOW(), 'E+H Promag 50'), + ('MON-FLOW-002', '二号泵站出口流量计', 'flow', '一体化水厂', '二号泵站出口', 82.07234567, 44.84678901, 'online', NOW(), 'E+H Promag 50'), + ('MON-PRES-001', '管网压力监测点A', 'pressure', '管网一区', '人民路DN300', 82.06890123, 44.84234567, 'online', NOW(), 'WIKA S-20'), + ('MON-PRES-002', '管网压力监测点B', 'pressure', '管网一区', '建设路DN200', 82.06901234, 44.84345678, 'offline', NOW() - INTERVAL '2 hours', 'WIKA S-20'), + ('MON-LEV-001', '清水池液位计', 'level', '一体化水厂', '清水池', 82.07156789, 44.84501234, 'online', NOW(), 'VEGA VEGAPULS 64'), + ('MON-LEV-002', '沉淀池液位计', 'level', '一体化水厂', '沉淀池', 82.07167890, 44.84512345, 'fault', NOW() - INTERVAL '30 minutes', 'VEGA VEGAPULS 64'), + ('MON-QUAL-001', '出厂水质监测仪', 'quality', '一体化水厂', '出厂水口', 82.07178901, 44.84523456, 'online', NOW(), 'HACH sc200'), + ('MON-QUAL-002', '管网末梢水质仪', 'quality', '管网二区', '末梢检测点', 82.06543210, 44.83987654, 'abnormal',NOW(), 'HACH sc200'), + ('MON-FLOW-003', '三号泵站流量计', 'flow', '管网二区', '三号泵站', 82.06654321, 44.84098765, 'online', NOW(), 'E+H Promag 10'), + ('MON-PRES-003', '高位水池压力计', 'pressure', '管网三区', '高位水池出口', 82.07345678, 44.84789012, 'online', NOW(), 'WIKA S-20') +ON CONFLICT (device_code) DO NOTHING; + +INSERT INTO prod_monitor_realtime_data (device_id, device_code, metric_key, metric_value, unit, threshold_high, threshold_low, is_abnormal, collect_time) +SELECT d.id, d.device_code, m.metric_key, m.metric_value, m.unit, m.threshold_high, m.threshold_low, m.is_abnormal, NOW() +FROM prod_monitor_device d +CROSS JOIN (VALUES + ('flow', 125.50, 'm³/h', 200.0, 10.0, 0), + ('pressure', 0.35, 'MPa', 0.6, 0.15, 0), + ('level', 3.80, 'm', 5.0, 0.5, 0), + ('turbidity', 0.45, 'NTU', 1.0, NULL, 0), + ('ph', 7.20, '', 8.5, 6.5, 0), + ('residual_chlorine', 0.35, 'mg/L', 0.8, 0.05, 0) +) AS m(metric_key, metric_value, unit, threshold_high, threshold_low, is_abnormal) +WHERE d.status = 'online' +ON CONFLICT DO NOTHING; diff --git a/wm-production/src/main/java/com/water/production/controller/MonitorController.java b/wm-production/src/main/java/com/water/production/controller/MonitorController.java new file mode 100644 index 00000000..6da5bea4 --- /dev/null +++ b/wm-production/src/main/java/com/water/production/controller/MonitorController.java @@ -0,0 +1,130 @@ +package com.water.production.controller; + +import com.water.common.core.result.R; +import com.water.production.dto.MonitorExportRequest; +import com.water.production.dto.MonitorQueryRequest; +import com.water.production.service.MonitorExportService; +import com.water.production.service.MonitorListService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +/** + * 在线监测列表 Controller + * 提供设备列表、实时数据、统计、导出等接口 + */ +@Tag(name = "在线监测管理") +@RestController +@RequestMapping("/api/production/monitor") +@RequiredArgsConstructor +public class MonitorController { + + private final MonitorListService monitorListService; + private final MonitorExportService monitorExportService; + + // ========== 1. 设备列表(分页 + 多维筛选) ========== + @Operation(summary = "在线监测设备列表(分页+多维筛选)") + @GetMapping("/list") + public R> list(MonitorQueryRequest request) { + return R.ok(monitorListService.queryDeviceList(request)); + } + + // ========== 2. 设备详情 ========== + @Operation(summary = "获取设备详情") + @GetMapping("/device/{deviceId}") + public R> deviceDetail(@PathVariable Long deviceId) { + Map detail = monitorListService.getDeviceDetail(deviceId); + if (detail.isEmpty()) { + return R.fail(404, "设备不存在"); + } + return R.ok(detail); + } + + // ========== 3. 设备实时数据 ========== + @Operation(summary = "获取设备实时监测数据") + @GetMapping("/device/{deviceId}/realtime") + public R>> deviceRealtime(@PathVariable Long deviceId) { + return R.ok(monitorListService.getDeviceRealtimeData(deviceId)); + } + + // ========== 4. 设备统计概览 ========== + @Operation(summary = "监测设备统计概览(按状态/类型/区域分组)") + @GetMapping("/statistics") + public R> statistics() { + return R.ok(monitorListService.getDeviceStatistics()); + } + + // ========== 5. 更新设备状态 ========== + @Operation(summary = "更新设备状态(online/offline/fault/abnormal)") + @PutMapping("/device/{deviceId}/status") + public R updateStatus(@PathVariable Long deviceId, @RequestParam String status) { + monitorListService.updateDeviceStatus(deviceId, status); + return R.ok("状态已更新"); + } + + // ========== 6. 获取区域列表 ========== + @Operation(summary = "获取所有监测区域列表") + @GetMapping("/areas") + public R> areas() { + return R.ok(monitorListService.getAreaList()); + } + + // ========== 7. 获取设备类型列表 ========== + @Operation(summary = "获取所有设备类型列表") + @GetMapping("/device-types") + public R> deviceTypes() { + return R.ok(monitorListService.getDeviceTypeList()); + } + + // ========== 8. 导出 Excel ========== + @Operation(summary = "导出在线监测数据(Excel)") + @PostMapping("/export/excel") + public ResponseEntity exportExcel(@RequestBody MonitorExportRequest request) { + request.setFormat("excel"); + byte[] data = monitorExportService.exportExcel(request); + String filename = URLEncoder.encode("在线监测数据.xlsx", StandardCharsets.UTF_8); + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + filename) + .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")) + .body(data); + } + + // ========== 9. 导出 CSV ========== + @Operation(summary = "导出在线监测数据(CSV)") + @PostMapping("/export/csv") + public ResponseEntity exportCsv(@RequestBody MonitorExportRequest request) { + request.setFormat("csv"); + byte[] data = monitorExportService.exportCsv(request); + String filename = URLEncoder.encode("在线监测数据.csv", StandardCharsets.UTF_8); + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + filename) + .contentType(MediaType.parseMediaType("text/csv; charset=UTF-8")) + .body(data); + } + + // ========== 10. 通用导出(按 format 参数自动选择) ========== + @Operation(summary = "通用导出(format=excel|csv)") + @PostMapping("/export") + public ResponseEntity export(@RequestBody MonitorExportRequest request) { + if ("csv".equalsIgnoreCase(request.getFormat())) { + return exportCsv(request); + } + return exportExcel(request); + } + + // ========== 11. 批量获取实时数据 ========== + @Operation(summary = "批量获取多设备实时数据") + @PostMapping("/realtime/batch") + public R>> batchRealtime(@RequestBody List deviceIds) { + return R.ok(monitorListService.getBatchRealtimeData(deviceIds)); + } +} diff --git a/wm-production/src/main/java/com/water/production/dto/MonitorExportRequest.java b/wm-production/src/main/java/com/water/production/dto/MonitorExportRequest.java new file mode 100644 index 00000000..770b8835 --- /dev/null +++ b/wm-production/src/main/java/com/water/production/dto/MonitorExportRequest.java @@ -0,0 +1,39 @@ +package com.water.production.dto; + +import lombok.Data; + +import java.util.List; + +/** + * 在线监测数据导出请求 + */ +@Data +public class MonitorExportRequest { + + /** 导出格式: excel/csv */ + private String format = "excel"; + + /** 区域 */ + private String area; + + /** 设备类型 */ + private String deviceType; + + /** 设备状态 */ + private String status; + + /** 关键词 */ + private String keyword; + + /** 开始时间 */ + private String startTime; + + /** 结束时间 */ + private String endTime; + + /** 指定导出的设备ID列表(可选,为空则按筛选条件导出) */ + private List deviceIds; + + /** 是否包含实时数据 */ + private Boolean includeRealtime = true; +} diff --git a/wm-production/src/main/java/com/water/production/dto/MonitorQueryRequest.java b/wm-production/src/main/java/com/water/production/dto/MonitorQueryRequest.java new file mode 100644 index 00000000..79bbd99a --- /dev/null +++ b/wm-production/src/main/java/com/water/production/dto/MonitorQueryRequest.java @@ -0,0 +1,40 @@ +package com.water.production.dto; + +import lombok.Data; + +/** + * 在线监测列表查询请求 + */ +@Data +public class MonitorQueryRequest { + + /** 区域 */ + private String area; + + /** 设备类型: flow/pressure/level/quality */ + private String deviceType; + + /** 设备状态: online/offline/fault/abnormal */ + private String status; + + /** 关键词(设备编号/名称模糊搜索) */ + private String keyword; + + /** 开始时间(最后上报时间范围) */ + private String startTime; + + /** 结束时间 */ + private String endTime; + + /** 排序字段: deviceCode/deviceName/status/lastReportTime */ + private String sortField; + + /** 排序方向: asc/desc */ + private String sortOrder; + + /** 页码(默认1) */ + private Integer pageNum = 1; + + /** 每页条数(默认20) */ + private Integer pageSize = 20; +} diff --git a/wm-production/src/main/java/com/water/production/entity/MonitorDevice.java b/wm-production/src/main/java/com/water/production/entity/MonitorDevice.java new file mode 100644 index 00000000..17867e9b --- /dev/null +++ b/wm-production/src/main/java/com/water/production/entity/MonitorDevice.java @@ -0,0 +1,60 @@ +package com.water.production.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 在线监测设备实体 + */ +@Data +@TableName("prod_monitor_device") +public class MonitorDevice { + + @TableId(type = IdType.AUTO) + private Long id; + + /** 设备编号 */ + private String deviceCode; + + /** 设备名称 */ + private String deviceName; + + /** 设备类型: flow/pressure/level/quality */ + private String deviceType; + + /** 所属区域 */ + private String area; + + /** 安装位置 */ + private String location; + + /** 经度 */ + private BigDecimal lng; + + /** 纬度 */ + private BigDecimal lat; + + /** 设备状态: online/offline/fault/abnormal */ + private String status; + + /** 最后上报时间 */ + private LocalDateTime lastReportTime; + + /** 设备品牌/型号 */ + private String brand; + + /** 安装日期 */ + private LocalDateTime installTime; + + /** 备注 */ + private String remark; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createdTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updatedTime; +} diff --git a/wm-production/src/main/java/com/water/production/entity/MonitorRealtimeData.java b/wm-production/src/main/java/com/water/production/entity/MonitorRealtimeData.java new file mode 100644 index 00000000..d0b3d0ab --- /dev/null +++ b/wm-production/src/main/java/com/water/production/entity/MonitorRealtimeData.java @@ -0,0 +1,48 @@ +package com.water.production.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 监测实时数据实体 + */ +@Data +@TableName("prod_monitor_realtime_data") +public class MonitorRealtimeData { + + @TableId(type = IdType.AUTO) + private Long id; + + /** 关联设备ID */ + private Long deviceId; + + /** 设备编号 */ + private String deviceCode; + + /** 监测参数类型: flow/pressure/level/turbidity/ph/residual_chlorine/temperature */ + private String metricKey; + + /** 实时值 */ + private BigDecimal metricValue; + + /** 单位 */ + private String unit; + + /** 阈值上限 */ + private BigDecimal thresholdHigh; + + /** 阈值下限 */ + private BigDecimal thresholdLow; + + /** 是否异常: 0正常 1异常 */ + private Integer isAbnormal; + + /** 采集时间 */ + private LocalDateTime collectTime; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createdTime; +} diff --git a/wm-production/src/main/java/com/water/production/mapper/MonitorDeviceMapper.java b/wm-production/src/main/java/com/water/production/mapper/MonitorDeviceMapper.java new file mode 100644 index 00000000..83f3b955 --- /dev/null +++ b/wm-production/src/main/java/com/water/production/mapper/MonitorDeviceMapper.java @@ -0,0 +1,47 @@ +package com.water.production.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.water.production.entity.MonitorDevice; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; +import java.util.Map; + +/** + * 在线监测设备 Mapper + */ +@Mapper +public interface MonitorDeviceMapper extends BaseMapper { + + /** + * 查询设备列表(含最新实时数据) + */ + @Select("") + List> selectDeviceListWithMetrics( + @Param("area") String area, + @Param("deviceType") String deviceType, + @Param("status") String status, + @Param("keyword") String keyword, + @Param("startTime") String startTime, + @Param("endTime") String endTime, + @Param("sortField") String sortField, + @Param("sortOrder") String sortOrder); +} diff --git a/wm-production/src/main/java/com/water/production/mapper/MonitorRealtimeDataMapper.java b/wm-production/src/main/java/com/water/production/mapper/MonitorRealtimeDataMapper.java new file mode 100644 index 00000000..171ebe82 --- /dev/null +++ b/wm-production/src/main/java/com/water/production/mapper/MonitorRealtimeDataMapper.java @@ -0,0 +1,50 @@ +package com.water.production.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.water.production.entity.MonitorRealtimeData; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; +import java.util.Map; + +/** + * 监测实时数据 Mapper + */ +@Mapper +public interface MonitorRealtimeDataMapper extends BaseMapper { + + /** + * 获取指定设备的最新实时数据(所有指标) + */ + @Select("SELECT r.* FROM prod_monitor_realtime_data r " + + "INNER JOIN (SELECT device_id, metric_key, MAX(collect_time) AS max_time " + + " FROM prod_monitor_realtime_data WHERE device_id = #{deviceId} GROUP BY device_id, metric_key) latest " + + "ON r.device_id = latest.device_id AND r.metric_key = latest.metric_key AND r.collect_time = latest.max_time") + List selectLatestByDeviceId(@Param("deviceId") Long deviceId); + + /** + * 批量获取多个设备的最新实时数据 + */ + @Select("") + List> selectLatestBatch(@Param("deviceIds") List deviceIds); + + /** + * 按设备ID统计异常数据数量 + */ + @Select("SELECT device_id, COUNT(*) AS cnt FROM prod_monitor_realtime_data " + + "WHERE is_abnormal = 1 AND device_id IN " + + " " + + "GROUP BY device_id") + List> countAbnormalByDevices(@Param("deviceIds") List deviceIds); +} diff --git a/wm-production/src/main/java/com/water/production/service/MonitorExportService.java b/wm-production/src/main/java/com/water/production/service/MonitorExportService.java new file mode 100644 index 00000000..31535d20 --- /dev/null +++ b/wm-production/src/main/java/com/water/production/service/MonitorExportService.java @@ -0,0 +1,206 @@ +package com.water.production.service; + +import com.alibaba.excel.EasyExcel; +import com.water.production.dto.MonitorExportRequest; +import com.water.production.dto.MonitorQueryRequest; +import com.water.production.entity.MonitorDevice; +import com.water.production.entity.MonitorRealtimeData; +import com.water.production.mapper.MonitorRealtimeDataMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 在线监测数据导出服务 + * 支持 Excel / CSV 格式导出 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class MonitorExportService { + + private final MonitorListService monitorListService; + private final MonitorRealtimeDataMapper realtimeDataMapper; + + /** + * 导出 Excel 格式 + */ + public byte[] exportExcel(MonitorExportRequest req) { + List devices = getDevicesForExport(req); + if (devices.isEmpty()) { + return new byte[0]; + } + + // 构建导出数据 + List> head = buildExcelHead(); + List> data = buildExcelData(devices, req.getIncludeRealtime()); + + try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) { + EasyExcel.write(bos) + .sheet("在线监测数据") + .head(head.stream() + .map(row -> row.stream() + .map(c -> (String) c) + .collect(Collectors.toList())) + .collect(Collectors.toList())) + .doWrite(data); + return bos.toByteArray(); + } catch (IOException e) { + log.error("Excel 导出失败", e); + throw new RuntimeException("Excel 导出失败: " + e.getMessage()); + } + } + + /** + * 导出 CSV 格式 + */ + public byte[] exportCsv(MonitorExportRequest req) { + List devices = getDevicesForExport(req); + if (devices.isEmpty()) { + return new byte[0]; + } + + try (ByteArrayOutputStream bos = new ByteArrayOutputStream(); + PrintWriter writer = new PrintWriter(new OutputStreamWriter(bos, StandardCharsets.UTF_8))) { + // BOM for Excel UTF-8 + bos.write(0xEF); + bos.write(0xBB); + bos.write(0xBF); + + // CSV header + writer.println("设备编号,设备名称,设备类型,区域,安装位置,状态,最后上报时间,流量(m³/h),压力(MPa),液位(m),浊度(NTU),pH,余氯(mg/L)"); + + for (MonitorDevice device : devices) { + StringBuilder line = new StringBuilder(); + line.append(csvEsc(device.getDeviceCode())).append(","); + line.append(csvEsc(device.getDeviceName())).append(","); + line.append(csvEsc(device.getDeviceType())).append(","); + line.append(csvEsc(device.getArea())).append(","); + line.append(csvEsc(device.getLocation())).append(","); + line.append(csvEsc(device.getStatus())).append(","); + line.append(device.getLastReportTime() != null ? device.getLastReportTime().toString() : ""); + + if (Boolean.TRUE.equals(req.getIncludeRealtime())) { + List realtimeList = realtimeDataMapper.selectLatestByDeviceId(device.getId()); + Map metricMap = new LinkedHashMap<>(); + for (MonitorRealtimeData data : realtimeList) { + metricMap.put(data.getMetricKey(), + data.getMetricValue() != null ? data.getMetricValue().toPlainString() : ""); + } + line.append(",").append(metricMap.getOrDefault("flow", "")); + line.append(",").append(metricMap.getOrDefault("pressure", "")); + line.append(",").append(metricMap.getOrDefault("level", "")); + line.append(",").append(metricMap.getOrDefault("turbidity", "")); + line.append(",").append(metricMap.getOrDefault("ph", "")); + line.append(",").append(metricMap.getOrDefault("residual_chlorine", "")); + } + + writer.println(line); + } + + writer.flush(); + return bos.toByteArray(); + } catch (IOException e) { + log.error("CSV 导出失败", e); + throw new RuntimeException("CSV 导出失败: " + e.getMessage()); + } + } + + private List getDevicesForExport(MonitorExportRequest req) { + // 如果指定了设备ID列表,直接按ID查询 + if (req.getDeviceIds() != null && !req.getDeviceIds().isEmpty()) { + MonitorQueryRequest queryReq = new MonitorQueryRequest(); + queryReq.setPageSize(10000); + return monitorListService.queryDevicesForExport(queryReq).stream() + .filter(d -> req.getDeviceIds().contains(d.getId())) + .collect(Collectors.toList()); + } + + // 按筛选条件查询 + MonitorQueryRequest queryReq = new MonitorQueryRequest(); + queryReq.setArea(req.getArea()); + queryReq.setDeviceType(req.getDeviceType()); + queryReq.setStatus(req.getStatus()); + queryReq.setKeyword(req.getKeyword()); + queryReq.setStartTime(req.getStartTime()); + queryReq.setEndTime(req.getEndTime()); + queryReq.setPageSize(10000); + return monitorListService.queryDevicesForExport(queryReq); + } + + private List> buildExcelHead() { + List> head = new ArrayList<>(); + head.add(List.of("设备编号")); + head.add(List.of("设备名称")); + head.add(List.of("设备类型")); + head.add(List.of("区域")); + head.add(List.of("安装位置")); + head.add(List.of("状态")); + head.add(List.of("最后上报时间")); + head.add(List.of("流量(m³/h)")); + head.add(List.of("压力(MPa)")); + head.add(List.of("液位(m)")); + head.add(List.of("浊度(NTU)")); + head.add(List.of("pH")); + head.add(List.of("余氯(mg/L)")); + return head; + } + + private List> buildExcelData(List devices, Boolean includeRealtime) { + List> data = new ArrayList<>(); + for (MonitorDevice device : devices) { + List row = new ArrayList<>(); + row.add(device.getDeviceCode()); + row.add(device.getDeviceName()); + row.add(device.getDeviceType()); + row.add(device.getArea()); + row.add(device.getLocation()); + row.add(formatStatus(device.getStatus())); + row.add(device.getLastReportTime() != null ? device.getLastReportTime().toString() : ""); + + if (Boolean.TRUE.equals(includeRealtime)) { + List realtimeList = realtimeDataMapper.selectLatestByDeviceId(device.getId()); + Map metricMap = new LinkedHashMap<>(); + for (MonitorRealtimeData d : realtimeList) { + metricMap.put(d.getMetricKey(), d.getMetricValue()); + } + row.add(metricMap.getOrDefault("flow", "")); + row.add(metricMap.getOrDefault("pressure", "")); + row.add(metricMap.getOrDefault("level", "")); + row.add(metricMap.getOrDefault("turbidity", "")); + row.add(metricMap.getOrDefault("ph", "")); + row.add(metricMap.getOrDefault("residual_chlorine", "")); + } + + data.add(row); + } + return data; + } + + private String formatStatus(String status) { + if (status == null) return "未知"; + return switch (status) { + case "online" -> "在线"; + case "offline" -> "离线"; + case "fault" -> "故障"; + case "abnormal" -> "数据异常"; + default -> status; + }; + } + + private String csvEsc(String value) { + if (value == null) return ""; + if (value.contains(",") || value.contains("\"") || value.contains("\n")) { + return "\"" + value.replace("\"", "\"\"") + "\""; + } + return value; + } +} diff --git a/wm-production/src/main/java/com/water/production/service/MonitorListService.java b/wm-production/src/main/java/com/water/production/service/MonitorListService.java new file mode 100644 index 00000000..cda26ff7 --- /dev/null +++ b/wm-production/src/main/java/com/water/production/service/MonitorListService.java @@ -0,0 +1,271 @@ +package com.water.production.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.water.production.dto.MonitorQueryRequest; +import com.water.production.entity.MonitorDevice; +import com.water.production.entity.MonitorRealtimeData; +import com.water.production.mapper.MonitorDeviceMapper; +import com.water.production.mapper.MonitorRealtimeDataMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 在线监测列表服务 + * 提供设备列表查询、实时数据获取、设备详情、统计等功能 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class MonitorListService { + + private final MonitorDeviceMapper deviceMapper; + private final MonitorRealtimeDataMapper realtimeDataMapper; + + private static final DateTimeFormatter DTF = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + /** + * 分页查询在线监测设备列表(含最新实时数据) + */ + public Map queryDeviceList(MonitorQueryRequest req) { + // 使用 MyBatis-Plus 分页 + Page page = new Page<>(req.getPageNum(), req.getPageSize()); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + if (req.getArea() != null && !req.getArea().isEmpty()) { + wrapper.eq(MonitorDevice::getArea, req.getArea()); + } + if (req.getDeviceType() != null && !req.getDeviceType().isEmpty()) { + wrapper.eq(MonitorDevice::getDeviceType, req.getDeviceType()); + } + if (req.getStatus() != null && !req.getStatus().isEmpty()) { + wrapper.eq(MonitorDevice::getStatus, req.getStatus()); + } + if (req.getKeyword() != null && !req.getKeyword().isEmpty()) { + wrapper.and(w -> w.like(MonitorDevice::getDeviceCode, req.getKeyword()) + .or().like(MonitorDevice::getDeviceName, req.getKeyword())); + } + if (req.getStartTime() != null && !req.getStartTime().isEmpty()) { + wrapper.ge(MonitorDevice::getLastReportTime, LocalDateTime.parse(req.getStartTime(), DTF)); + } + if (req.getEndTime() != null && !req.getEndTime().isEmpty()) { + wrapper.le(MonitorDevice::getLastReportTime, LocalDateTime.parse(req.getEndTime(), DTF)); + } + + // 排序 + String sortField = req.getSortField(); + boolean isAsc = "asc".equalsIgnoreCase(req.getSortOrder()); + if ("deviceCode".equals(sortField)) { + wrapper.orderBy(true, isAsc, MonitorDevice::getDeviceCode); + } else if ("deviceName".equals(sortField)) { + wrapper.orderBy(true, isAsc, MonitorDevice::getDeviceName); + } else if ("status".equals(sortField)) { + wrapper.orderBy(true, isAsc, MonitorDevice::getStatus); + } else { + wrapper.orderByDesc(MonitorDevice::getLastReportTime); + } + + Page result = deviceMapper.selectPage(page, wrapper); + + // 填充实时数据 + List> deviceList = new ArrayList<>(); + for (MonitorDevice device : result.getRecords()) { + Map deviceMap = new LinkedHashMap<>(); + deviceMap.put("id", device.getId()); + deviceMap.put("deviceCode", device.getDeviceCode()); + deviceMap.put("deviceName", device.getDeviceName()); + deviceMap.put("deviceType", device.getDeviceType()); + deviceMap.put("area", device.getArea()); + deviceMap.put("location", device.getLocation()); + deviceMap.put("lng", device.getLng()); + deviceMap.put("lat", device.getLat()); + deviceMap.put("status", device.getStatus()); + deviceMap.put("lastReportTime", device.getLastReportTime()); + deviceMap.put("brand", device.getBrand()); + + // 获取最新实时数据 + List realtimeList = realtimeDataMapper.selectLatestByDeviceId(device.getId()); + Map realtimeMap = new LinkedHashMap<>(); + for (MonitorRealtimeData data : realtimeList) { + Map item = new LinkedHashMap<>(); + item.put("metricKey", data.getMetricKey()); + item.put("value", data.getMetricValue()); + item.put("unit", data.getUnit()); + item.put("thresholdHigh", data.getThresholdHigh()); + item.put("thresholdLow", data.getThresholdLow()); + item.put("isAbnormal", data.getIsAbnormal()); + item.put("collectTime", data.getCollectTime()); + realtimeMap.put(data.getMetricKey(), item); + } + deviceMap.put("realtimeData", realtimeMap); + deviceList.add(deviceMap); + } + + Map pageResult = new LinkedHashMap<>(); + pageResult.put("records", deviceList); + pageResult.put("total", result.getTotal()); + pageResult.put("pageNum", result.getCurrent()); + pageResult.put("pageSize", result.getSize()); + pageResult.put("pages", result.getPages()); + return pageResult; + } + + /** + * 获取单个设备详情(含实时数据) + */ + public Map getDeviceDetail(Long deviceId) { + MonitorDevice device = deviceMapper.selectById(deviceId); + if (device == null) { + return Collections.emptyMap(); + } + + Map detail = new LinkedHashMap<>(); + detail.put("id", device.getId()); + detail.put("deviceCode", device.getDeviceCode()); + detail.put("deviceName", device.getDeviceName()); + detail.put("deviceType", device.getDeviceType()); + detail.put("area", device.getArea()); + detail.put("location", device.getLocation()); + detail.put("lng", device.getLng()); + detail.put("lat", device.getLat()); + detail.put("status", device.getStatus()); + detail.put("lastReportTime", device.getLastReportTime()); + detail.put("brand", device.getBrand()); + detail.put("installTime", device.getInstallTime()); + detail.put("remark", device.getRemark()); + + List realtimeList = realtimeDataMapper.selectLatestByDeviceId(deviceId); + detail.put("realtimeData", realtimeList); + + return detail; + } + + /** + * 获取设备实时数据(多参数) + */ + public List> getDeviceRealtimeData(Long deviceId) { + List list = realtimeDataMapper.selectLatestByDeviceId(deviceId); + List> result = new ArrayList<>(); + for (MonitorRealtimeData data : list) { + Map map = new LinkedHashMap<>(); + map.put("metricKey", data.getMetricKey()); + map.put("value", data.getMetricValue()); + map.put("unit", data.getUnit()); + map.put("thresholdHigh", data.getThresholdHigh()); + map.put("thresholdLow", data.getThresholdLow()); + map.put("isAbnormal", data.getIsAbnormal()); + map.put("collectTime", data.getCollectTime()); + result.add(map); + } + return result; + } + + /** + * 获取设备统计概览(按状态、类型、区域分组) + */ + public Map getDeviceStatistics() { + Map stats = new LinkedHashMap<>(); + + // 按状态统计 + List allDevices = deviceMapper.selectList(null); + Map statusCount = allDevices.stream() + .collect(Collectors.groupingBy(d -> d.getStatus() != null ? d.getStatus() : "unknown", Collectors.counting())); + stats.put("statusDistribution", statusCount); + stats.put("totalDevices", allDevices.size()); + stats.put("onlineCount", statusCount.getOrDefault("online", 0L)); + stats.put("offlineCount", statusCount.getOrDefault("offline", 0L)); + stats.put("faultCount", statusCount.getOrDefault("fault", 0L)); + stats.put("abnormalCount", statusCount.getOrDefault("abnormal", 0L)); + + // 按类型统计 + Map typeCount = allDevices.stream() + .collect(Collectors.groupingBy(d -> d.getDeviceType() != null ? d.getDeviceType() : "unknown", Collectors.counting())); + stats.put("typeDistribution", typeCount); + + // 按区域统计 + Map areaCount = allDevices.stream() + .collect(Collectors.groupingBy(d -> d.getArea() != null ? d.getArea() : "unknown", Collectors.counting())); + stats.put("areaDistribution", areaCount); + + return stats; + } + + /** + * 更新设备状态 + */ + public void updateDeviceStatus(Long deviceId, String status) { + MonitorDevice device = new MonitorDevice(); + device.setId(deviceId); + device.setStatus(status); + device.setUpdatedTime(LocalDateTime.now()); + deviceMapper.updateById(device); + log.info("设备 {} 状态更新为 {}", deviceId, status); + } + + /** + * 获取所有支持的区域列表 + */ + public List getAreaList() { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.select(MonitorDevice::getArea).groupBy(MonitorDevice::getArea); + return deviceMapper.selectList(wrapper).stream() + .map(MonitorDevice::getArea) + .filter(Objects::nonNull) + .distinct() + .sorted() + .collect(Collectors.toList()); + } + + /** + * 获取所有支持的设备类型列表 + */ + public List getDeviceTypeList() { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.select(MonitorDevice::getDeviceType).groupBy(MonitorDevice::getDeviceType); + return deviceMapper.selectList(wrapper).stream() + .map(MonitorDevice::getDeviceType) + .filter(Objects::nonNull) + .distinct() + .sorted() + .collect(Collectors.toList()); + } + + /** + * 批量获取设备实时数据(用于导出) + */ + public List> getBatchRealtimeData(List deviceIds) { + if (deviceIds == null || deviceIds.isEmpty()) { + return Collections.emptyList(); + } + return realtimeDataMapper.selectLatestBatch(deviceIds); + } + + /** + * 根据筛选条件查询设备列表(不分页,用于导出) + */ + public List queryDevicesForExport(MonitorQueryRequest req) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + if (req.getArea() != null && !req.getArea().isEmpty()) { + wrapper.eq(MonitorDevice::getArea, req.getArea()); + } + if (req.getDeviceType() != null && !req.getDeviceType().isEmpty()) { + wrapper.eq(MonitorDevice::getDeviceType, req.getDeviceType()); + } + if (req.getStatus() != null && !req.getStatus().isEmpty()) { + wrapper.eq(MonitorDevice::getStatus, req.getStatus()); + } + if (req.getKeyword() != null && !req.getKeyword().isEmpty()) { + wrapper.and(w -> w.like(MonitorDevice::getDeviceCode, req.getKeyword()) + .or().like(MonitorDevice::getDeviceName, req.getKeyword())); + } + wrapper.orderByDesc(MonitorDevice::getLastReportTime); + return deviceMapper.selectList(wrapper); + } +} diff --git a/wm-production/src/test/java/com/water/production/service/MonitorListServiceTest.java b/wm-production/src/test/java/com/water/production/service/MonitorListServiceTest.java new file mode 100644 index 00000000..ebf62339 --- /dev/null +++ b/wm-production/src/test/java/com/water/production/service/MonitorListServiceTest.java @@ -0,0 +1,423 @@ +package com.water.production.service; + +import com.water.production.dto.MonitorExportRequest; +import com.water.production.dto.MonitorQueryRequest; +import com.water.production.entity.MonitorDevice; +import com.water.production.entity.MonitorRealtimeData; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 在线监测列表单元测试 + * 覆盖实体、DTO、查询筛选逻辑、导出格式、状态判断等 + */ +class MonitorListServiceTest { + + // ========== 1. 实体构建测试 ========== + + @Test + @DisplayName("MonitorDevice 实体字段完整性") + void testMonitorDeviceEntity() { + MonitorDevice device = new MonitorDevice(); + device.setId(1L); + device.setDeviceCode("MON-FLOW-001"); + device.setDeviceName("一号泵站出口流量计"); + device.setDeviceType("flow"); + device.setArea("一体化水厂"); + device.setLocation("一号泵站出口"); + device.setLng(new BigDecimal("82.07123456")); + device.setLat(new BigDecimal("44.84567890")); + device.setStatus("online"); + device.setLastReportTime(LocalDateTime.of(2026, 6, 14, 16, 0, 0)); + device.setBrand("E+H Promag 50"); + device.setInstallTime(LocalDateTime.of(2025, 1, 15, 10, 0, 0)); + device.setRemark("正常运行"); + + assertEquals(1L, device.getId()); + assertEquals("MON-FLOW-001", device.getDeviceCode()); + assertEquals("flow", device.getDeviceType()); + assertEquals("online", device.getStatus()); + assertEquals(new BigDecimal("82.07123456"), device.getLng()); + assertNotNull(device.getLastReportTime()); + assertEquals("E+H Promag 50", device.getBrand()); + } + + @Test + @DisplayName("MonitorRealtimeData 实体字段完整性") + void testMonitorRealtimeDataEntity() { + MonitorRealtimeData data = new MonitorRealtimeData(); + data.setId(1L); + data.setDeviceId(1L); + data.setDeviceCode("MON-FLOW-001"); + data.setMetricKey("flow"); + data.setMetricValue(new BigDecimal("125.5000")); + data.setUnit("m³/h"); + data.setThresholdHigh(new BigDecimal("200.0000")); + data.setThresholdLow(new BigDecimal("10.0000")); + data.setIsAbnormal(0); + data.setCollectTime(LocalDateTime.of(2026, 6, 14, 15, 55, 0)); + + assertEquals("flow", data.getMetricKey()); + assertEquals(new BigDecimal("125.5000"), data.getMetricValue()); + assertEquals("m³/h", data.getUnit()); + assertEquals(0, data.getIsAbnormal()); + assertNotNull(data.getCollectTime()); + } + + // ========== 2. DTO 默认值测试 ========== + + @Test + @DisplayName("MonitorQueryRequest 默认分页参数") + void testQueryRequestDefaults() { + MonitorQueryRequest req = new MonitorQueryRequest(); + assertEquals(1, req.getPageNum()); + assertEquals(20, req.getPageSize()); + assertNull(req.getArea()); + assertNull(req.getDeviceType()); + assertNull(req.getStatus()); + assertNull(req.getSortField()); + } + + @Test + @DisplayName("MonitorExportRequest 默认格式为 excel") + void testExportRequestDefaults() { + MonitorExportRequest req = new MonitorExportRequest(); + assertEquals("excel", req.getFormat()); + assertTrue(req.getIncludeRealtime()); + assertNull(req.getDeviceIds()); + } + + // ========== 3. 状态判断逻辑测试 ========== + + @Test + @DisplayName("设备状态格式化逻辑") + void testStatusFormatting() { + // 模拟 Controller 中的状态格式化 + Map statusMap = Map.of( + "online", "在线", + "offline", "离线", + "fault", "故障", + "abnormal", "数据异常" + ); + + assertEquals("在线", statusMap.get("online")); + assertEquals("离线", statusMap.get("offline")); + assertEquals("故障", statusMap.get("fault")); + assertEquals("数据异常", statusMap.get("abnormal")); + assertNull(statusMap.get("unknown")); + } + + @Test + @DisplayName("设备异常判定逻辑") + void testAbnormalDetection() { + // 模拟异常判定:值超出阈值范围 + BigDecimal value = new BigDecimal("250.00"); + BigDecimal high = new BigDecimal("200.00"); + BigDecimal low = new BigDecimal("10.00"); + + boolean isAbnormal = (high != null && value.compareTo(high) > 0) + || (low != null && value.compareTo(low) < 0); + assertTrue(isAbnormal, "超出上限应判定为异常"); + + // 正常范围 + value = new BigDecimal("125.50"); + isAbnormal = (high != null && value.compareTo(high) > 0) + || (low != null && value.compareTo(low) < 0); + assertFalse(isAbnormal, "正常范围不应判定为异常"); + + // 低于下限 + value = new BigDecimal("5.00"); + isAbnormal = (high != null && value.compareTo(high) > 0) + || (low != null && value.compareTo(low) < 0); + assertTrue(isAbnormal, "低于下限应判定为异常"); + } + + // ========== 4. 筛选逻辑测试 ========== + + @Test + @DisplayName("多维度筛选条件构建") + void testMultiDimensionFilter() { + // 模拟设备列表 + List devices = buildMockDevices(); + + // 按区域筛选 + List filtered = devices.stream() + .filter(d -> "一体化水厂".equals(d.getArea())) + .collect(Collectors.toList()); + assertEquals(3, filtered.size()); + + // 按设备类型筛选 + filtered = devices.stream() + .filter(d -> "pressure".equals(d.getDeviceType())) + .collect(Collectors.toList()); + assertEquals(2, filtered.size()); + + // 按状态筛选 + filtered = devices.stream() + .filter(d -> "online".equals(d.getStatus())) + .collect(Collectors.toList()); + assertEquals(3, filtered.size()); + + // 组合筛选:区域 + 类型 + filtered = devices.stream() + .filter(d -> "一体化水厂".equals(d.getArea()) && "flow".equals(d.getDeviceType())) + .collect(Collectors.toList()); + assertEquals(2, filtered.size()); + + // 关键词搜索 + String keyword = "流量计"; + filtered = devices.stream() + .filter(d -> (d.getDeviceName() != null && d.getDeviceName().contains(keyword)) + || (d.getDeviceCode() != null && d.getDeviceCode().contains(keyword))) + .collect(Collectors.toList()); + assertEquals(2, filtered.size()); + } + + @Test + @DisplayName("排序逻辑测试") + void testSortLogic() { + List devices = buildMockDevices(); + + // 按设备编号升序 + devices.sort(Comparator.comparing(MonitorDevice::getDeviceCode)); + assertEquals("MON-FLOW-001", devices.get(0).getDeviceCode()); + + // 按设备编号降序 + devices.sort(Comparator.comparing(MonitorDevice::getDeviceCode).reversed()); + assertEquals("MON-QUAL-001", devices.get(0).getDeviceCode()); + + // 按最后上报时间降序(最新在前) + devices.sort(Comparator.comparing(MonitorDevice::getLastReportTime, Comparator.nullsLast(Comparator.reverseOrder()))); + assertNotNull(devices.get(0).getLastReportTime()); + } + + // ========== 5. 统计逻辑测试 ========== + + @Test + @DisplayName("设备统计聚合逻辑") + void testDeviceStatistics() { + List devices = buildMockDevices(); + + // 按状态统计 + Map statusCount = devices.stream() + .collect(Collectors.groupingBy(MonitorDevice::getStatus, Collectors.counting())); + assertEquals(3L, statusCount.get("online")); + assertEquals(1L, statusCount.get("offline")); + assertEquals(1L, statusCount.get("fault")); + + // 按类型统计 + Map typeCount = devices.stream() + .collect(Collectors.groupingBy(MonitorDevice::getDeviceType, Collectors.counting())); + assertEquals(2L, typeCount.get("flow")); + assertEquals(2L, typeCount.get("pressure")); + assertEquals(1L, typeCount.get("quality")); + + // 按区域统计 + Map areaCount = devices.stream() + .collect(Collectors.groupingBy(MonitorDevice::getArea, Collectors.counting())); + assertEquals(3L, areaCount.get("一体化水厂")); + assertEquals(2L, areaCount.get("管网一区")); + } + + // ========== 6. 分页计算测试 ========== + + @Test + @DisplayName("分页参数计算") + void testPagination() { + int total = 50; + int pageSize = 20; + + int pages = (int) Math.ceil((double) total / pageSize); + assertEquals(3, pages); + + // 第1页偏移量 + int offset = (1 - 1) * pageSize; + assertEquals(0, offset); + + // 第3页偏移量 + offset = (3 - 1) * pageSize; + assertEquals(40, offset); + + // 整除情况 + total = 40; + pages = (int) Math.ceil((double) total / pageSize); + assertEquals(2, pages); + } + + // ========== 7. CSV 转义测试 ========== + + @Test + @DisplayName("CSV 字段转义逻辑") + void testCsvEscaping() { + // 包含逗号 + String value = "一号泵站,出口"; + String escaped = csvEsc(value); + assertEquals("\"一号泵站,出口\"", escaped); + + // 包含引号 + value = "含\"引号\"的文本"; + escaped = csvEsc(value); + assertEquals("\"含\"\"引号\"\"的文本\"", escaped); + + // 普通文本 + value = "正常文本"; + escaped = csvEsc(value); + assertEquals("正常文本", escaped); + + // null + escaped = csvEsc(null); + assertEquals("", escaped); + } + + // ========== 8. 实时数据结构测试 ========== + + @Test + @DisplayName("多参数实时数据结构完整性") + void testRealtimeDataStructure() { + // 模拟多参数实时数据 + List expectedMetrics = List.of("flow", "pressure", "level", "turbidity", "ph", "residual_chlorine"); + Map metricMap = new LinkedHashMap<>(); + + for (String metric : expectedMetrics) { + MonitorRealtimeData data = new MonitorRealtimeData(); + data.setDeviceId(1L); + data.setMetricKey(metric); + data.setMetricValue(new BigDecimal("100.00")); + data.setUnit("unit_" + metric); + data.setCollectTime(LocalDateTime.now()); + metricMap.put(metric, data); + } + + assertEquals(6, metricMap.size()); + assertTrue(metricMap.containsKey("flow")); + assertTrue(metricMap.containsKey("pressure")); + assertTrue(metricMap.containsKey("level")); + assertTrue(metricMap.containsKey("turbidity")); + assertTrue(metricMap.containsKey("ph")); + assertTrue(metricMap.containsKey("residual_chlorine")); + + // 验证各参数值 + for (String metric : expectedMetrics) { + assertNotNull(metricMap.get(metric).getMetricValue()); + assertEquals(metric, metricMap.get(metric).getMetricKey()); + } + } + + // ========== 9. 设备在线率计算 ========== + + @Test + @DisplayName("设备在线率计算") + void testOnlineRateCalculation() { + List devices = buildMockDevices(); + int total = devices.size(); + long onlineCount = devices.stream().filter(d -> "online".equals(d.getStatus())).count(); + + BigDecimal onlineRate = total > 0 + ? BigDecimal.valueOf(onlineCount * 100.0 / total).setScale(1, RoundingMode.HALF_UP) + : BigDecimal.ZERO; + + assertEquals(5, total); + assertEquals(3, onlineCount); + assertEquals(new BigDecimal("60.0"), onlineRate); + } + + // ========== 10. 导出数据量限制测试 ========== + + @Test + @DisplayName("导出最大数据量限制") + void testExportMaxLimit() { + MonitorExportRequest req = new MonitorExportRequest(); + int maxExportLimit = 10000; + + // 模拟大量设备 + List devices = new ArrayList<>(); + for (int i = 0; i < 15000; i++) { + MonitorDevice d = new MonitorDevice(); + d.setId((long) (i + 1)); + d.setDeviceCode("MON-" + String.format("%05d", i)); + devices.add(d); + } + + // 导出应限制在 maxExportLimit 内 + List exportList = devices.size() > maxExportLimit + ? devices.subList(0, maxExportLimit) + : devices; + + assertEquals(maxExportLimit, exportList.size()); + assertTrue(devices.size() > maxExportLimit); + } + + // ========== Helper Methods ========== + + private List buildMockDevices() { + List devices = new ArrayList<>(); + + MonitorDevice d1 = new MonitorDevice(); + d1.setId(1L); + d1.setDeviceCode("MON-FLOW-001"); + d1.setDeviceName("一号泵站出口流量计"); + d1.setDeviceType("flow"); + d1.setArea("一体化水厂"); + d1.setStatus("online"); + d1.setLastReportTime(LocalDateTime.of(2026, 6, 14, 16, 0, 0)); + devices.add(d1); + + MonitorDevice d2 = new MonitorDevice(); + d2.setId(2L); + d2.setDeviceCode("MON-FLOW-002"); + d2.setDeviceName("二号泵站流量计"); + d2.setDeviceType("flow"); + d2.setArea("一体化水厂"); + d2.setStatus("online"); + d2.setLastReportTime(LocalDateTime.of(2026, 6, 14, 15, 50, 0)); + devices.add(d2); + + MonitorDevice d3 = new MonitorDevice(); + d3.setId(3L); + d3.setDeviceCode("MON-PRES-001"); + d3.setDeviceName("管网压力监测点A"); + d3.setDeviceType("pressure"); + d3.setArea("管网一区"); + d3.setStatus("online"); + d3.setLastReportTime(LocalDateTime.of(2026, 6, 14, 15, 55, 0)); + devices.add(d3); + + MonitorDevice d4 = new MonitorDevice(); + d4.setId(4L); + d4.setDeviceCode("MON-PRES-002"); + d4.setDeviceName("管网压力监测点B"); + d4.setDeviceType("pressure"); + d4.setArea("管网一区"); + d4.setStatus("offline"); + d4.setLastReportTime(LocalDateTime.of(2026, 6, 14, 14, 0, 0)); + devices.add(d4); + + MonitorDevice d5 = new MonitorDevice(); + d5.setId(5L); + d5.setDeviceCode("MON-QUAL-001"); + d5.setDeviceName("出厂水质监测仪"); + d5.setDeviceType("quality"); + d5.setArea("一体化水厂"); + d5.setStatus("fault"); + d5.setLastReportTime(LocalDateTime.of(2026, 6, 14, 15, 30, 0)); + devices.add(d5); + + return devices; + } + + private String csvEsc(String value) { + if (value == null) return ""; + if (value.contains(",") || value.contains("\"") || value.contains("\n")) { + return "\"" + value.replace("\"", "\"\"") + "\""; + } + return value; + } +}