Browse Source

1.漏损音频附件存入minio;2.报警审核bug修复

zengy 1 tuần trước cách đây
mục cha
commit
12819022dc

+ 6 - 0
audio-service/pom.xml

@@ -35,6 +35,12 @@
             <artifactId>zk-api-service</artifactId>
             <version>1.0.0</version>
         </dependency>
+        <!--MinIO 对象存储:音频文件统一上传至 MinIO(noise 业务目录)-->
+        <dependency>
+            <groupId>io.minio</groupId>
+            <artifactId>minio</artifactId>
+            <version>7.1.0</version>
+        </dependency>
         <dependency>
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-starter</artifactId>

+ 31 - 0
audio-service/src/main/java/com/zksy/audio/config/MinioConfig.java

@@ -0,0 +1,31 @@
+package com.zksy.audio.config;
+
+import io.minio.MinioClient;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * MinIO 客户端配置(audio-service 独立模块,未扫描 zk-common 的 MinioConfig,这里自行声明)。
+ * 配置项与 nacos 上的 minio.* 保持一致;bucket 由使用方通过 @Value("${minio.bucket}") 注入。
+ */
+@Configuration
+public class MinioConfig {
+
+    @Value("${minio.endpoint}")
+    private String endpoint;
+
+    @Value("${minio.accessKey}")
+    private String accessKey;
+
+    @Value("${minio.secretKey}")
+    private String secretKey;
+
+    @Bean
+    public MinioClient minioClient() {
+        return MinioClient.builder()
+                .endpoint(endpoint)
+                .credentials(accessKey, secretKey)
+                .build();
+    }
+}

+ 73 - 0
audio-service/src/main/java/com/zksy/audio/utils/DataParser.java

@@ -13,6 +13,7 @@ import javax.sound.sampled.AudioInputStream;
 import javax.sound.sampled.AudioSystem;
 import javax.sound.sampled.UnsupportedAudioFileException;
 import java.io.File;
+import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.nio.ByteBuffer;
 import java.nio.ByteOrder;
@@ -119,4 +120,76 @@ public class DataParser {
         }
         return centerFrequency;
     }
+
+    /** 直接基于内存中的 WAV 字节计算 RMS(避免落盘,配合 MinIO 上传使用) */
+    public static int calculateRMS(byte[] data) {
+        try (AudioInputStream audio = AudioSystem.getAudioInputStream(new ByteArrayInputStream(data))) {
+            AudioFormat f = audio.getFormat();
+            if (f.getEncoding() != AudioFormat.Encoding.PCM_SIGNED || f.getSampleSizeInBits() != 16
+                    || f.getChannels() != 1 || f.getSampleRate() != 8192) {
+                throw new IllegalArgumentException("需要单声道16位8192Hz PCM音频");
+            }
+            long count = 0;
+            double sum = 0, squares = 0;
+            byte[] sample = new byte[2];
+            int n;
+            while ((n = audio.read(sample)) != -1) {
+                if (n != 2) throw new IllegalArgumentException("音频包含不完整采样");
+                int value = toInt16(sample, f.isBigEndian());
+                sum += value;
+                squares += (double) value * value;
+                count++;
+            }
+            if (count == 0) throw new IllegalArgumentException("音频采样为空");
+            double mean = sum / count;
+            return (int) Math.sqrt(Math.max(0, squares / count - mean * mean));
+        } catch (IOException | UnsupportedAudioFileException e) {
+            throw new IllegalArgumentException("音频读取失败,不能用0替代实际值", e);
+        }
+    }
+
+    /** 直接基于内存中的 WAV 字节计算中心频率(避免落盘,配合 MinIO 上传使用) */
+    public static double calcCenterFrequency(byte[] data) {
+        int fftSize = 1024;
+        double sampleRate = 8192; // 固定采样率
+        double centerFrequency = 0;
+        try (AudioInputStream audioStream = AudioSystem.getAudioInputStream(new ByteArrayInputStream(data))) {
+            AudioFormat format = audioStream.getFormat();
+            if (format.getSampleRate() != 8192 || format.getSampleSizeInBits() != 16) {
+                throw new IllegalArgumentException("不支持的音频格式: 需要16位/8192Hz");
+            }
+            byte[] buffer = new byte[fftSize * 2];
+            float[] floatBuffer = new float[fftSize];
+            Complex[] fftBuffer = new Complex[fftSize];
+            double maxMagnitude = 0;
+            int maxIndex = 0;
+            FastFourierTransformer fft = new FastFourierTransformer(DftNormalization.STANDARD);
+            int bytesRead;
+            while ((bytesRead = audioStream.read(buffer)) > 0) {
+                if (bytesRead == 2048) {
+                    for (int i = 0; i < bytesRead / 2; i++) {
+                        short sample = toInt16(buffer, i * 2);
+                        floatBuffer[i] = sample / 32768f;
+                        fftBuffer[i] = new Complex(floatBuffer[i], 0);
+                    }
+                    Complex[] fftResult = fft.transform(fftBuffer, TransformType.FORWARD);
+                    maxMagnitude = 0;
+                    maxIndex = 0;
+                    for (int i = 0; i < fftSize / 2; i++) {
+                        double magnitude = Math.sqrt(fftResult[i].getReal() * fftResult[i].getReal()
+                                + fftResult[i].getImaginary() * fftResult[i].getImaginary());
+                        if (i > 0 && magnitude > maxMagnitude) {
+                            maxMagnitude = magnitude;
+                            maxIndex = i;
+                        }
+                    }
+                    double frequencyResolution = sampleRate / fftSize;
+                    centerFrequency = maxIndex * frequencyResolution;
+                }
+            }
+        } catch (UnsupportedAudioFileException | IOException e) {
+            throw new RuntimeException("音频处理错误", e);
+        }
+        return centerFrequency;
+    }
 }

+ 37 - 23
audio-service/src/main/java/com/zksy/audio/utils/MessageHandler.java

@@ -13,12 +13,15 @@ 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 java.io.ByteArrayInputStream;
 import java.nio.charset.StandardCharsets;
-import java.nio.file.*;
 import java.time.LocalDateTime;
 import java.time.format.DateTimeFormatter;
 import java.util.*;
 import java.util.concurrent.*;
+import io.minio.MinioClient;
+import io.minio.PutObjectArgs;
+import io.minio.RemoveObjectArgs;
 
 /** 只处理已组好的文本行或完整WAV,一份音频只计算和保存一次。 */
 @Slf4j
@@ -27,7 +30,8 @@ import java.util.concurrent.*;
 public class MessageHandler extends ChannelInboundHandlerAdapter {
     public final ConcurrentMap<ChannelId, ChannelInfo> channelGroup = new ConcurrentHashMap<>();
     public final ConcurrentMap<String, ChannelInfo> deviceGroup = new ConcurrentHashMap<>();
-    @Value("${wlds.fileUploadBaseDir}") private String fileUploadBaseDir;
+    @Autowired private MinioClient minioClient;
+    @Value("${minio.bucket}") private String minioBucket;
     // 正常/异常录音每24小时上报;连续重传按滑动静默窗口去重,下一份录音独立处理。
     @Value("${wlds.retransmission-window-seconds:120}") private long retransmissionWindowSeconds = 120;
     private final AudioUploadDeduplicator uploads = new AudioUploadDeduplicator();
@@ -81,37 +85,47 @@ public class MessageHandler extends ChannelInboundHandlerAdapter {
     }
 
     private void saveAudio(ChannelHandlerContext ctx, ChannelInfo info, byte[] bytes) throws Exception {
-        Path file = null;
-        boolean saved = false;
+        LocalDateTime now = LocalDateTime.now();
+        String dateDir = now.format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
+        String uuid = UUID.randomUUID().toString().replace("-", "");
+        // 统一归类到 MinIO 的 noise 业务目录,对象名:noise/{yyyy/MM/dd}/{uuid}.wav
+        String objectName = "noise/" + dateDir + "/" + uuid + ".wav";
+        NoiseInfo noise = new NoiseInfo();
+        boolean uploaded = false;
         try {
-            LocalDateTime now = LocalDateTime.now();
-            String relative = "/devices/" + info.getDeviceNo() + "/" + now.toLocalDate().toString().replace("-", "")
-                    + "/" + UUID.randomUUID() + ".wav";
-            Path base = Paths.get(fileUploadBaseDir).toAbsolutePath().normalize();
-            file = base.resolve(relative.substring(1)).normalize();
-            if (!file.startsWith(base)) throw new IllegalArgumentException("音频路径无效");
-            Files.createDirectories(file.getParent());
-            // 完整写入后再计算,避免异步追加造成不存在/不完整的文件。
-            Files.write(file, bytes, StandardOpenOption.CREATE_NEW);
-            NoiseInfo noise = new NoiseInfo();
+            // 先上传到 MinIO,成功后再入库,避免产生无法播放的孤立记录。
+            minioClient.putObject(PutObjectArgs.builder()
+                    .bucket(minioBucket)
+                    .object(objectName)
+                    .stream(new ByteArrayInputStream(bytes), bytes.length, -1)
+                    .contentType("audio/wav")
+                    .build());
+            uploaded = true;
             noise.setEncode(info.getDeviceNo());
             noise.setPower(info.getPower());
             noise.setGain(info.getGain());
-            noise.setRms(DataParser.calculateRMS(file.toString()));
-            noise.setCenterFrequency(DataParser.calcCenterFrequency(file.toString()));
-            noise.setFilepath(relative);
+            noise.setRms(DataParser.calculateRMS(bytes));
+            noise.setCenterFrequency(DataParser.calcCenterFrequency(bytes));
+            String relativePath = "/" + objectName;
+            noise.setFilepath(relativePath);
             noise.setTs(now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
             noise.setCreateTime(new Date());
             requireSaved(service.save(noise));
-            saved = true;
-            info.setPath(relative);
-            info.setFilePath(relative);
+            info.setPath(relativePath);
+            info.setFilePath(relativePath);
             acknowledge(ctx, noise.getEncode());
             run(noise.getEncode(), "漏失报警预警", () -> noiseLeakAlarmService.evaluate(noise));
-        } finally {
-            if (!saved && file != null) {
-                try { Files.deleteIfExists(file); } catch (Exception e) { log.warn("清理未入库音频失败", e); }
+        } catch (Exception e) {
+            // 入库失败则回滚已上传的 MinIO 对象,保持"只有成功入库的音频才保留"。
+            if (uploaded) {
+                try {
+                    minioClient.removeObject(RemoveObjectArgs.builder()
+                            .bucket(minioBucket).object(objectName).build());
+                } catch (Exception ignore) {
+                    log.warn("清理上传失败的音频对象失败: {}", objectName, ignore);
+                }
             }
+            throw e;
         }
     }
 

+ 0 - 11
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/mobile/MobileEarlyWarningController.java

@@ -127,17 +127,6 @@ public class MobileEarlyWarningController {
         }
     }
 
-    @SuppressWarnings("unchecked")
-    private Map<String, Object> basicInfo(Map<String, Object> detail) {
-        return (Map<String, Object>) detail.getOrDefault("basicInfo", Collections.emptyMap());
-    }
-
-    @SuppressWarnings("unchecked")
-    private List<String> actions(Map<String, Object> detail) {
-        Object value = basicInfo(detail).get("availableActions");
-        return value instanceof List ? (List<String>) value : Collections.emptyList();
-    }
-
     private String normalizeObjectName(String value) {
         if (value == null || value.trim().isEmpty()) return null;
         String objectName = value.trim();

+ 11 - 3
pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/service/AlarmReviewService.java

@@ -99,11 +99,16 @@ public class AlarmReviewService {
         String attachUrl = attachments.resolve(request.getAttachments());
         LocalDateTime now = LocalDateTime.now();
         // 原子条件更新锁定待审核报警;并发审核/监测恢复时,只有一方成功。
-        int updated = alarmMapper.update(null, new LambdaUpdateWrapper<AlarmData>()
+        LambdaUpdateWrapper<AlarmData> update = new LambdaUpdateWrapper<AlarmData>()
                 .eq(AlarmData::getId, id)
                 .eq(AlarmData::getAlarmStatus, AlarmRecordStatusEnum.UNHANDLED.getCode())
                 .eq(AlarmData::getAuditStatus, AlarmAuditStatusEnum.UNREVIEWED.getCode())
-                .set(AlarmData::getAuditStatus, result.getCode()).set(AlarmData::getUpdateTime, now));
+                .set(AlarmData::getAuditStatus, result.getCode()).set(AlarmData::getUpdateTime, now);
+        // 误报直接解除报警:alarm_status 置为已处理(1),与监测恢复口径一致。
+        if (result == AlarmAuditStatusEnum.FALSE_ALARM) {
+            update.set(AlarmData::getAlarmStatus, AlarmRecordStatusEnum.HANDLED.getCode());
+        }
+        int updated = alarmMapper.update(null, update);
         if (updated != 1) throw new ServiceException("报警已审核或已处理,请刷新后重试");
         AlarmLog log = new AlarmLog();
         log.setAlarmId(id);
@@ -129,8 +134,11 @@ public class AlarmReviewService {
             alarm.setWorkOrderId(orderId);
         }
         if (logMapper.insert(log) != 1) throw new ServiceException("审核日志保存失败");
-        // 审核不会修改 equipment_status;误报仅记录结论,保留原始报警。
+        // 审核不直接写 equipment_status;误报已在上一步将 alarm_status 置为已处理(1)。
         alarm.setAuditStatus(result.getCode());
+        if (result == AlarmAuditStatusEnum.FALSE_ALARM) {
+            alarm.setAlarmStatus(AlarmRecordStatusEnum.HANDLED.getCode());
+        }
         labels(alarm);
         return alarm;
     }

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

@@ -57,6 +57,7 @@ public class PerceptionServiceImpl implements PerceptionService {
         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");
+        TABLE_ID_COLUMN.put("noise_info", "encode");
     }
 
     /** 片区编码 -> 名称。 */
@@ -404,6 +405,18 @@ public class PerceptionServiceImpl implements PerceptionService {
                 view.setRows(mapWide(raw, defs));
                 break;
             }
+            case "noise_info": {
+                // 噪音采集:特征值 / 增益值 / 中心频率 / 音频文件(filepath 由前端渲染为播放器)
+                List<ColDef> defs = Arrays.asList(
+                        new ColDef("rms", "特征值", "", false),
+                        new ColDef("gain", "增益值", "", false),
+                        new ColDef("center_frequency", "中心频率", "", false),
+                        new ColDef("filepath", "音频文件", "", false));
+                view.setLayout("wide");
+                view.setColumns(columnsWithTime(defs));
+                view.setRows(mapWide(raw, defs));
+                break;
+            }
             default:
                 view.setLayout("wide");
                 break;

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

@@ -471,9 +471,15 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
         String assignee = isBlank(requestedHandler) ? warning.getHandler() : requestedHandler.trim();
         // 发布不能再自动指派当前登录用户,处置人必须由发布人明确选择或提前保存。
         participants.requireCandidate(warningId, "handler", assignee);
-        String reviewer = isBlank(requestedReviewer) ? (isBlank(warning.getReviewer()) ? currentUser() : warning.getReviewer().trim()) : requestedReviewer.trim();
-        participants.requireCandidate(warningId, "reviewer", reviewer);
-        warning.setReviewer(reviewer);
+        String reviewer = isBlank(requestedReviewer) ? warning.getReviewer() : requestedReviewer.trim();
+        if (!isBlank(reviewer)) {
+            reviewer = reviewer.trim();
+            participants.requireCandidate(warningId, "reviewer", reviewer);
+            warning.setReviewer(reviewer);
+        } else {
+            // 未指定核验人时保持为空,不默认填充发布人
+            warning.setReviewer(null);
+        }
 
         itemBinding.bind(warning);
         warning.setStatus(EarlyWarningStatusEnum.PENDING.getCode());
@@ -487,7 +493,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
         boolean result = updateById(warning);
 
         if (result) {
-            addDisposalRecord(warningId, "RELEASE", null, null, currentUser(), "发布预警;处置人:" + assignee + ";核查人:" + reviewer);
+            addDisposalRecord(warningId, "RELEASE", null, null, currentUser(), "发布预警;处置人:" + assignee + ";核查人:" + (isBlank(reviewer) ? "未指定" : reviewer));
             createTodo(warningId, assignee, "接收并处置预警", "TODO", "HANDLE");
         }
 

+ 0 - 3
pipe-network-service/zksy-system/src/main/java/com/zksy/dispatch/support/SseEmitterManager.java

@@ -36,7 +36,6 @@ public class SseEmitterManager {
                 // 旧连接已失效,忽略
             }
         }
-        log.info("SSE 连接建立 userId={}", userId);
     }
 
     /**
@@ -44,7 +43,6 @@ public class SseEmitterManager {
      */
     public void remove(Long userId, SseEmitter emitter) {
         CONNECTIONS.remove(userId, emitter);
-        log.info("SSE 连接断开 userId={}", userId);
     }
 
     /**
@@ -68,7 +66,6 @@ public class SseEmitterManager {
             } catch (Exception e) {
                 // 发送失败(客户端已断开等):移除连接,下次重连
                 CONNECTIONS.remove(userId, emitter);
-                log.info("SSE 下发失败移除连接 userId={}", userId);
             }
         }
     }

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

@@ -635,6 +635,10 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
             if (StringUtils.isNotEmpty(deviceCode)) {
                 alarmClearService.review(deviceCode, "工单结案");
             }
+            // 工单已结案:将关联本工单的全部报警(alarm_data.work_order_id = 本单)标记为已处理(alarm_status=1)
+            alarmDataService.update(new LambdaUpdateWrapper<AlarmData>()
+                    .eq(AlarmData::getWorkOrderId, id)
+                    .set(AlarmData::getAlarmStatus, 1));
         }
         writeLog(id, passed ? WorkOrderLogOperTypeEnum.VERIFY_PASS : WorkOrderLogOperTypeEnum.VERIFY_REJECT, buildDesc("验收意见", dto.getContent()), dto.getAttachUrl());
         return updated;

+ 1 - 1
pipe-network-service/zksy-system/src/main/resources/mapper/warning/AlarmWarningRelMapper.xml

@@ -9,7 +9,7 @@
              WHERE e.equipment_code = a.device_code ORDER BY e.equipment_id LIMIT 1) AS device_name,
             a.device_code,
             (SELECT t.type_name FROM app_user.equipment_base e
-             JOIN app_user.equipment_type t ON t.id::text = e.equipment_type_id::text
+             JOIN app_user.equipment_type t ON CAST(t.id AS text) = CAST(e.equipment_type_id AS text)
              WHERE e.equipment_code = a.device_code ORDER BY e.equipment_id LIMIT 1) AS device_type,
             wt.warning_name AS warning_type,
             a.warning_code,

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

@@ -1088,12 +1088,14 @@
             COALESCE(NULLIF(TRIM(hu.nick_name), ''), w.handler) AS handler_name,
             COALESCE(NULLIF(TRIM(ru.nick_name), ''), w.reviewer) AS reviewer_name,
             COALESCE(NULLIF(TRIM(pu.nick_name), ''), w.publisher) AS publisher_name,
-            COALESCE(NULLIF(TRIM(cu.nick_name), ''), w.create_by) AS creator_name
+            COALESCE(NULLIF(TRIM(cu.nick_name), ''), w.create_by) AS creator_name,
+            COALESCE(eb.equipment_location, w.location) AS location
         FROM early_warning w
         LEFT JOIN app_user.sys_user hu ON hu.user_name = w.handler
         LEFT JOIN app_user.sys_user ru ON ru.user_name = w.reviewer
         LEFT JOIN app_user.sys_user pu ON pu.user_name = w.publisher
         LEFT JOIN app_user.sys_user cu ON cu.user_name = w.create_by
+        LEFT JOIN app_user.equipment_base eb ON eb.equipment_code = w.device_code
         WHERE w.warning_id = #{warningId}
     </select>
 

+ 2 - 2
pipe-network-service/zksy-system/src/test/java/com/zksy/base/alarm/service/AlarmReviewServiceTest.java

@@ -126,7 +126,7 @@ class AlarmReviewServiceTest {
         request.setAttachments(Collections.singletonList("/2026/10/09/alarm/jpg/abc123.png"));
         when(attachments.resolve(request.getAttachments())).thenReturn("/profile/test.pdf");
         AlarmReviewVO result = service.review("a1", query, request);
-        assertEquals(0, result.getAlarmStatus()); assertEquals(2, result.getAuditStatus());
+        assertEquals(1, result.getAlarmStatus()); assertEquals(2, result.getAuditStatus());
         verifyNoInteractions(orders);
         ArgumentCaptor<AlarmLog> log = ArgumentCaptor.forClass(AlarmLog.class);
         verify(logs).insert(log.capture()); assertEquals("/profile/test.pdf", log.getValue().getAttachUrl());
@@ -161,7 +161,7 @@ class AlarmReviewServiceTest {
         request.setOpinion(opinion);
         AlarmReviewVO result = service.review("a1", query, request);
         assertEquals(AlarmAuditStatusEnum.FALSE_ALARM.getCode(), result.getAuditStatus());
-        assertEquals(AlarmRecordStatusEnum.UNHANDLED.getCode(), result.getAlarmStatus());
+        assertEquals(AlarmRecordStatusEnum.HANDLED.getCode(), result.getAlarmStatus());
         verifyNoInteractions(orders);
         ArgumentCaptor<AlarmLog> log = ArgumentCaptor.forClass(AlarmLog.class);
         verify(logs).insert(log.capture());