liuaini 2 dagar sedan
förälder
incheckning
ba312cacd2

+ 0 - 1
common/common-se/src/main/java/com/hdkj/lt/bf/entity/FhzgSeCurrentEvent.java

@@ -37,5 +37,4 @@ public class FhzgSeCurrentEvent implements Serializable {
     private Integer reconStatus;
     private LocalDateTime createTime;
     private LocalDateTime updateTime;
-    private String stateEstimationJson;
 }

+ 0 - 0
services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/entity/FhzgSeMonitorCurrent.java → common/common-se/src/main/java/com/hdkj/lt/bf/entity/FhzgSeMonitorCurrent.java


+ 0 - 0
services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/entity/FhzgSeMonitorVoltage.java → common/common-se/src/main/java/com/hdkj/lt/bf/entity/FhzgSeMonitorVoltage.java


+ 3 - 1
services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/mapper/FhzgSeMonitorCurrentMapper.java → common/common-se/src/main/java/com/hdkj/lt/bf/mapper/FhzgSeMonitorCurrentMapper.java

@@ -2,6 +2,8 @@ package com.hdkj.lt.bf.mapper;
 
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.hdkj.lt.bf.entity.FhzgSeMonitorCurrent;
+import org.apache.ibatis.annotations.Mapper;
 
+@Mapper
 public interface FhzgSeMonitorCurrentMapper extends BaseMapper<FhzgSeMonitorCurrent> {
-}
+}

+ 3 - 1
services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/mapper/FhzgSeMonitorVoltageMapper.java → common/common-se/src/main/java/com/hdkj/lt/bf/mapper/FhzgSeMonitorVoltageMapper.java

@@ -2,6 +2,8 @@ package com.hdkj.lt.bf.mapper;
 
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.hdkj.lt.bf.entity.FhzgSeMonitorVoltage;
+import org.apache.ibatis.annotations.Mapper;
 
+@Mapper
 public interface FhzgSeMonitorVoltageMapper extends BaseMapper<FhzgSeMonitorVoltage> {
-}
+}

+ 201 - 0
common/common-se/src/main/java/com/hdkj/lt/bf/scheduler/SeEventCleanupScheduler.java

@@ -0,0 +1,201 @@
+package com.hdkj.lt.bf.scheduler;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.hdkj.lt.bf.entity.FhzgSeCurrentEvent;
+import com.hdkj.lt.bf.entity.FhzgSeMonitorCurrent;
+import com.hdkj.lt.bf.entity.FhzgSeMonitorVoltage;
+import com.hdkj.lt.bf.entity.FhzgSeVoltageEvent;
+import com.hdkj.lt.bf.mapper.FhzgSeCurrentEventMapper;
+import com.hdkj.lt.bf.mapper.FhzgSeMonitorCurrentMapper;
+import com.hdkj.lt.bf.mapper.FhzgSeMonitorVoltageMapper;
+import com.hdkj.lt.bf.mapper.FhzgSeVoltageEventMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 运行态事件自动超时关闭(公共模块)
+ * <p>
+ * 关闭超过超时阈值仍未恢复的活跃事件(重过载 + 电压越限),防止因
+ * 数据断档 / 断面链路中断导致的事件失联(status 永远挂着 1)。
+ * <p>
+ * 正常恢复由断面处理时的状态机走(负载率/电压回落 → status=2),
+ * 此任务仅做兜底:事件活跃超过 {@link #TIMEOUT_HOURS} 小时(按
+ * firstOverTime 首次越限时间判定)自动关闭,并同步重置对应监测状态机,
+ * 使后续越限可重新触发新事件。
+ * <p>
+ * 定时调度由 pwfhzg-job 通过 sys_job 配置触发(建议每小时一次)。
+ *
+ * @author lsl
+ * @since 2026-08-11
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class SeEventCleanupScheduler {
+
+    /** 事件超时自动关闭阈值(小时):活跃超过 24h 未恢复则强制关闭 */
+    public static final long TIMEOUT_HOURS = 24L;
+
+    /** 监测状态机活跃窗口(分钟):lastSnapTime 在此窗口内视为链路正常 */
+    private static final long MONITOR_ALIVE_MINUTES = 60L;
+
+    /** 单轮清扫处理上限(条):防单轮 select 全表 + 上千条 UPDATE 挤占连接 */
+    private static final int BATCH_LIMIT = 500;
+
+    private final FhzgSeCurrentEventMapper currentEventMapper;
+    private final FhzgSeVoltageEventMapper voltageEventMapper;
+    private final FhzgSeMonitorCurrentMapper monitorCurrentMapper;
+    private final FhzgSeMonitorVoltageMapper monitorVoltageMapper;
+
+    /**
+     * 清扫所有超时未恢复的活跃事件(手动触发时调用)
+     * <p>
+     * 说明:不包裹大事务——逐条处理自动提交,事务粒度=单事件关闭+单状态机重置,
+     * 避免上千条 stale 事件在一个长事务里与实时断面处理线程争锁。
+     *
+     * @return 关闭的事件总数(电流 + 电压)
+     */
+    public int cleanupStaleEvents() {
+        LocalDateTime threshold = LocalDateTime.now().minusHours(TIMEOUT_HOURS);
+        int total = 0;
+        total += closeStaleCurrentEvents(threshold);
+        total += closeStaleVoltageEvents(threshold);
+        if (total > 0) {
+            log.info("[事件清扫] 自动超时关闭 {} 条活跃事件(firstOverTime < {})", total, threshold);
+        }
+        return total;
+    }
+
+    /**
+     * 关闭超时未恢复的重过载(电流)事件,并重置对应监测状态机
+     * <p>
+     * 每次最多处理 {@link #BATCH_LIMIT} 条,剩余下轮清扫继续(幂等,可重复执行)。
+     */
+    private int closeStaleCurrentEvents(LocalDateTime threshold) {
+        List<FhzgSeCurrentEvent> stale = currentEventMapper.selectList(
+                new LambdaQueryWrapper<FhzgSeCurrentEvent>()
+                        .eq(FhzgSeCurrentEvent::getStatus, 1)
+                        .lt(FhzgSeCurrentEvent::getFirstOverTime, threshold)
+                        .last("LIMIT " + BATCH_LIMIT));
+        int count = 0;
+        for (FhzgSeCurrentEvent e : stale) {
+            // 链路正常且设备持续越限:事件持续挂起是真实状态,不应清扫关闭
+            // (否则 24h 一关一建,无限重建+重复触发重构,事件按馈线×天数增长)
+            if (isCurrentMonitorAlive(e.getFeederId())) {
+                log.debug("电流事件跳过清扫(断面链路正常,设备持续越限): eventId={}, feederId={}",
+                        e.getId(), e.getFeederId());
+                continue;
+            }
+            FhzgSeCurrentEvent update = new FhzgSeCurrentEvent();
+            update.setId(e.getId());
+            update.setStatus(2);
+            update.setRecoverTime(LocalDateTime.now());
+            currentEventMapper.updateById(update);
+            resetCurrentMonitor(e.getFeederId());
+            count++;
+        }
+        return count;
+    }
+
+    /**
+     * 关闭超时未恢复的电压事件(feeder / mvtrans / consumer 全级别),并重置对应监测状态机
+     * <p>
+     * 每次最多处理 {@link #BATCH_LIMIT} 条,剩余下轮清扫继续(幂等,可重复执行)。
+     */
+    private int closeStaleVoltageEvents(LocalDateTime threshold) {
+        List<FhzgSeVoltageEvent> stale = voltageEventMapper.selectList(
+                new LambdaQueryWrapper<FhzgSeVoltageEvent>()
+                        .eq(FhzgSeVoltageEvent::getStatus, 1)
+                        .lt(FhzgSeVoltageEvent::getFirstOverTime, threshold)
+                        .last("LIMIT " + BATCH_LIMIT));
+        int count = 0;
+        for (FhzgSeVoltageEvent e : stale) {
+            // 同电流:断面链路正常时设备持续越限属真实状态,跳过(防清扫-重建死循环)
+            if (isVoltageMonitorAlive(e.getDeviceId(), e.getDeviceType())) {
+                log.debug("电压事件跳过清扫(断面链路正常,设备持续越限): eventId={}, deviceId={}",
+                        e.getId(), e.getDeviceId());
+                continue;
+            }
+            FhzgSeVoltageEvent update = new FhzgSeVoltageEvent();
+            update.setId(e.getId());
+            update.setStatus(2);
+            update.setRecoverTime(LocalDateTime.now());
+            voltageEventMapper.updateById(update);
+            resetVoltageMonitor(e.getDeviceId(), e.getDeviceType());
+            count++;
+        }
+        return count;
+    }
+
+    /**
+     * 电流监测状态机是否仍在被断面链路推进(lastSnapTime 在活跃窗口内)
+     * <p>
+     * 活跃窗口 = 2 个断面周期(15min×2=30min)+ 补拉/抖动余量,取 60min。
+     */
+    private boolean isCurrentMonitorAlive(String feederId) {
+        if (feederId == null) return false;
+        try {
+            FhzgSeMonitorCurrent m = monitorCurrentMapper.selectOne(
+                    new LambdaQueryWrapper<FhzgSeMonitorCurrent>()
+                            .eq(FhzgSeMonitorCurrent::getFeederId, feederId)
+                            .last("LIMIT 1"));
+            return m != null && m.getLastSnapTime() != null
+                    && m.getLastSnapTime().isAfter(LocalDateTime.now().minusMinutes(MONITOR_ALIVE_MINUTES));
+        } catch (Exception e) {
+            log.warn("查询电流监测状态机失败: feederId={}, error={}", feederId, e.getMessage());
+            return false;
+        }
+    }
+
+    /**
+     * 电压监测状态机是否仍在被断面链路推进(lastSnapTime 在活跃窗口内)
+     */
+    private boolean isVoltageMonitorAlive(String deviceId, String deviceType) {
+        if (deviceId == null || deviceType == null) return false;
+        try {
+            FhzgSeMonitorVoltage m = monitorVoltageMapper.selectOne(
+                    new LambdaQueryWrapper<FhzgSeMonitorVoltage>()
+                            .eq(FhzgSeMonitorVoltage::getDeviceId, deviceId)
+                            .eq(FhzgSeMonitorVoltage::getDeviceType, deviceType)
+                            .last("LIMIT 1"));
+            return m != null && m.getLastSnapTime() != null
+                    && m.getLastSnapTime().isAfter(LocalDateTime.now().minusMinutes(MONITOR_ALIVE_MINUTES));
+        } catch (Exception e) {
+            log.warn("查询电压监测状态机失败: deviceId={}, error={}", deviceId, e.getMessage());
+            return false;
+        }
+    }
+
+    /**
+     * 重置馈线电流监测状态机:使该馈线后续越限可重新生成事件
+     */
+    private void resetCurrentMonitor(String feederId) {
+        if (feederId == null) return;
+        FhzgSeMonitorCurrent reset = new FhzgSeMonitorCurrent();
+        reset.setFirstOverTime(null);
+        reset.setAlarmTriggered(0);
+        reset.setAlarmEventId(null);
+        monitorCurrentMapper.update(reset,
+                new LambdaQueryWrapper<FhzgSeMonitorCurrent>()
+                        .eq(FhzgSeMonitorCurrent::getFeederId, feederId));
+    }
+
+    /**
+     * 重置电压监测状态机(按设备 + 类型精确匹配):使该设备后续越限可重新生成事件
+     */
+    private void resetVoltageMonitor(String deviceId, String deviceType) {
+        if (deviceId == null || deviceType == null) return;
+        FhzgSeMonitorVoltage reset = new FhzgSeMonitorVoltage();
+        reset.setFirstOverTime(null);
+        reset.setAlarmTriggered(0);
+        reset.setAlarmEventId(null);
+        monitorVoltageMapper.update(reset,
+                new LambdaQueryWrapper<FhzgSeMonitorVoltage>()
+                        .eq(FhzgSeMonitorVoltage::getDeviceId, deviceId)
+                        .eq(FhzgSeMonitorVoltage::getDeviceType, deviceType));
+    }
+}

+ 0 - 46
common/common-se/src/main/java/com/hdkj/lt/bf/scheduler/SeVoltageCleanupScheduler.java

@@ -1,46 +0,0 @@
-package com.hdkj.lt.bf.scheduler;
-
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
-import com.hdkj.lt.bf.entity.FhzgSeVoltageEvent;
-import com.hdkj.lt.bf.mapper.FhzgSeVoltageEventMapper;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.stereotype.Component;
-
-import java.time.LocalDateTime;
-
-/**
- * 电压事件兜底清扫逻辑(公共模块)
- * <p>
- * 关闭超过24小时仍未恢复的活跃事件,防止因断档/数据缺失导致的事件失联。
- * 正常恢复由断面处理时的状态机走,此任务仅做兜底。
- * 定时调度由 pwfhzg-job 通过 sys_job 配置触发(凌晨3点)。
- *
- * @author lsl
- * @since 2026-07-27
- */
-@Slf4j
-@Component
-@RequiredArgsConstructor
-public class SeVoltageCleanupScheduler {
-
-    private final FhzgSeVoltageEventMapper voltageEventMapper;
-
-    /**
-     * 清扫超过24小时未恢复的电压事件(手动触发时调用)
-     */
-    public int cleanupStaleVoltageEvents() {
-        LocalDateTime threshold = LocalDateTime.now().minusHours(24);
-        FhzgSeVoltageEvent update = new FhzgSeVoltageEvent();
-        update.setStatus(2);
-        update.setRecoverTime(LocalDateTime.now());
-        int updated = voltageEventMapper.update(update,
-                new LambdaQueryWrapper<FhzgSeVoltageEvent>()
-                        .eq(FhzgSeVoltageEvent::getStatus, 1)
-                        .lt(FhzgSeVoltageEvent::getTriggerTime, threshold));
-        if (updated > 0) {
-            log.info("[电压事件清扫] 关闭 {} 条超过24h未恢复的电压事件(triggerTime < {})", updated, threshold);
-        }
-        return updated;
-    }
-}

+ 31 - 6
services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/controller/optimization/IndicatorController.java

@@ -179,9 +179,13 @@ public class IndicatorController extends BaseController {
     /**
      * 手动触发状估断面拉取
      * <p>
-     * 按县域调 SI queryEquipByCounty 拉取状估数据,聚合后送入 processSnapshot 解析。
-     * 传 snapTime 时启用覆盖更新:先删该断面时刻存量明细再重新写入。
+     * 按县域调 SI queryEquipByCounty 拉取状估数据,聚合后送入解析。
      * 传 pointTime 时按指定历史时刻拉取(SI 侧优先使用该时刻),不传则拉当前最新。
+     * <p>
+     * 注意:手动端点适配"指定时间补断面"场景——传 pointTime(指定拉取时刻)
+     * 或 snapTime(覆盖更新)任一参数时,均走 backfill 模式:只写断面明细/线损表,
+     * <b>不推状态机、不触发事件</b>,防止补拉历史数据导致状态机时间倒退、
+     * 重复触发事件/重构;都不传(拉当前最新)才走完整实时链路。
      *
      * @param params {snapTime(可选, yyyy-MM-dd HH:mm:ss),传值时覆盖更新; pointTime(可选, yyyy-MM-dd HH:mm:ss),指定拉取时刻}
      */
@@ -191,8 +195,10 @@ public class IndicatorController extends BaseController {
         String pointTime = params.get("pointTime");
         // 传 snapTime 或 pointTime 均走覆盖更新:手动补断面就是要重写该时刻存量
         boolean overwrite = StringUtils.isNotBlank(snapTimeStr) || StringUtils.isNotBlank(pointTime);
+        // 手动补断面(传了任一指定时间)→ backfill-only:不推状态机、不触发事件
+        boolean backfill = overwrite;
 
-        log.info("手动状估拉取开始 overwrite={}, pointTime={}", overwrite, pointTime);
+        log.info("手动状估拉取开始 overwrite={}, backfill={}, pointTime={}", overwrite, backfill, pointTime);
         int successCount = 0;
         List<String> errors = new ArrayList<>();
 
@@ -219,14 +225,33 @@ public class IndicatorController extends BaseController {
                     continue;
                 }
 
+                // 时刻一致性校验:请求指定 pointTime 时,si 返回的数据时刻应等于请求值;
+                // 不一致说明 si 未按 pointTime 拉取(返回了最新断面),删除/写入都会落在
+                // 实际时刻而非用户期望时刻,属数据错配,必须明确告警
+                if (StringUtils.isNotBlank(pointTime) && merged.getPointTime() != null
+                        && !merged.getPointTime().isEmpty()) {
+                    String actual = merged.getPointTime().get(0);
+                    if (!pointTime.equals(actual)) {
+                        log.warn("手动状估拉取时刻错配 county={}: 请求pointTime={}, si实际返回={},"
+                                + "删除/写入将按实际时刻执行,请确认数据是否符合预期",
+                                countyName, pointTime, actual);
+                    }
+                }
+
                 String json = JSON.toJSONString(merged);
-                if (overwrite) {
-                    seSnapshotService.processSnapshotOverwrite(json);
+                if (backfill) {
+                    // 补历史断面:只写数据表,不推状态机/事件(overwrite 时先删该时刻存量)
+                    if (overwrite) {
+                        seSnapshotService.processSnapshotOverwrite(json);
+                    } else {
+                        seSnapshotService.processSnapshotBackfill(json);
+                    }
                 } else {
+                    // 拉当前最新:完整实时链路
                     seSnapshotService.processSnapshot(json);
                 }
                 successCount++;
-                log.info("手动状估拉取完成 county={}, pointTime={}", countyName, merged.getPointTime());
+                log.info("手动状估拉取完成 county={}, pointTime={}, backfill={}", countyName, merged.getPointTime(), backfill);
             } catch (Exception e) {
                 log.error("手动状估拉取异常 county={}", countyName, e);
                 errors.add(countyName + "异常:" + e.getMessage());

+ 48 - 0
services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/entity/dto/ReconQueueMsg.java

@@ -0,0 +1,48 @@
+package com.hdkj.lt.bf.entity.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 重构触发 Redis Stream 队列消息
+ * <p>
+ * 用于可靠投递重构触发任务:进程内 @Async 事件在服务重启时任务全丢、
+ * reconStatus 卡在"生成中",改为先 XADD 入队,消费端处理后 XACK,
+ * 重启后 pending 消息不丢。
+ *
+ * @author lsl
+ * @since 2026-08-11
+ */
+@Data
+public class ReconQueueMsg implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 告警事件ID */
+    private Long eventId;
+
+    /** 问题线路ID */
+    private String feederId;
+
+    /** 馈线名称 */
+    private String feederName;
+
+    /** 区县ID */
+    private String countyId;
+
+    /** 变电站ID */
+    private String subsId;
+
+    /** 告警类型(current_overload / current_heavy / voltage_over / voltage_under) */
+    private String alarmType;
+
+    /** 持续1小时的那个起始断面时刻(yyyy-MM-dd HH:mm:ss) */
+    private String pointTime;
+
+    /** 触发时间(yyyy-MM-dd HH:mm:ss) */
+    private String triggerTime;
+
+    /** 已重试次数(处理失败重新入队时 +1,超上限后丢弃) */
+    private Integer retryCount = 0;
+}

+ 276 - 13
services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/listener/ReconTriggerListener.java

@@ -2,22 +2,22 @@ package com.hdkj.lt.bf.listener;
 
 import cn.hutool.core.lang.Snowflake;
 import cn.hutool.core.util.IdUtil;
+import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONObject;
-import com.alibaba.fastjson2.JSON;
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.hdkj.hussar.ApiResponse;
-import com.hdkj.lt.base.exception.ExceptionCast;
 import com.hdkj.lt.bf.entity.FhzgSeCurrentEvent;
 import com.hdkj.lt.bf.entity.FhzgSeEventStateEstimation;
 import com.hdkj.lt.bf.entity.FhzgSeVoltageEvent;
-import com.hdkj.lt.bf.entity.dto.XlRelDTO;
+import com.hdkj.lt.bf.entity.dto.ReconQueueMsg;
 import com.hdkj.lt.bf.entity.vo.XlRelVO;
 import com.hdkj.lt.bf.event.ReconTriggerEvent;
 import com.hdkj.lt.bf.mapper.FhzgSeCurrentEventMapper;
 import com.hdkj.lt.bf.mapper.FhzgSeEventStateEstimationMapper;
 import com.hdkj.lt.bf.mapper.FhzgSeVoltageEventMapper;
 import com.hdkj.lt.bf.mapper.XlRelMapper;
+import com.hdkj.lt.bf.entity.dto.XlRelDTO;
 import com.hdkj.lt.core.bizms.modle.dto.StateEstimation;
 import com.hdkj.lt.core.bizms.modle.po.DwdShbDsFeederBase;
 import com.hdkj.lt.core.bizms.modle.po.Jxz;
@@ -35,22 +35,36 @@ import com.hdkj.lt.modle.vo.recon.ReconstructionParam;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.context.event.EventListener;
+import org.springframework.data.redis.connection.stream.Consumer;
+import org.springframework.data.redis.connection.stream.MapRecord;
+import org.springframework.data.redis.connection.stream.ReadOffset;
+import org.springframework.data.redis.connection.stream.RecordId;
+import org.springframework.data.redis.connection.stream.StreamOffset;
+import org.springframework.data.redis.connection.stream.StreamReadOptions;
+import org.springframework.data.redis.connection.stream.StreamRecords;
+import org.springframework.data.redis.core.RedisTemplate;
 import org.springframework.scheduling.annotation.Async;
+import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 
 import java.time.LocalDateTime;
-import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
 import java.util.*;
-import java.util.concurrent.*;
+import java.util.concurrent.CompletableFuture;
 import java.util.stream.Collectors;
 
 /**
- * 重构算法触发监听器
+ * 重构算法触发监听器(可靠队列版)
  * <p>
- * 当线路重过载/电压越限告警持续1小时触发时,异步调用同事编写的重构接口。
+ * 当线路重过载/电压越限告警持续触发时,异步调用同事编写的重构接口。
  * 通过 Feign 客户端 IReconApiClient 调用 SI 模块的 POST /recon/trigger。
  * <p>
+ * 可靠性改造:原来 @Async + @EventListener 是进程内线程池,服务重启时
+ * 任务直接丢失、reconStatus 卡在"生成中"。现改为 Redis Stream 可靠队列:
+ * <ul>
+ *   <li>发布端:事件入队 XADD(Redis Stream,跨进程持久)</li>
+ *   <li>消费端:定时轮询 XREADGROUP → 处理后 XACK(重启后 pending 消息不丢)</li>
+ * </ul>
  * 调用过程中同步更新告警事件的 reconStatus:
  * <ul>
  *   <li>1 — 重构生成中</li>
@@ -74,6 +88,7 @@ public class ReconTriggerListener {
     private final DwdShbDsFeederBaseMapper feederBaseMapper;
     private final FhzgSeCurrentEventMapper currentEventMapper;
     private final FhzgSeVoltageEventMapper voltageEventMapper;
+    private final RedisTemplate<String, Object> redisTemplate;
     private final FhzgSeEventStateEstimationMapper stateEstimationMapper;
     private final IStateEstimationApiClient stateEstimationApiClient;
 
@@ -84,9 +99,201 @@ public class ReconTriggerListener {
     private static final int RECON_NO_PLAN   = 3;
     private static final int RECON_FAILED    = -1;
 
-    @Async
+    /** Redis Stream key:重构触发任务队列 */
+    private static final String RECON_STREAM_KEY = "se:recon:queue";
+    /** 消费组名 */
+    private static final String RECON_GROUP = "se-recon-group";
+    /** 消费者名:多副本部署时每实例需唯一,用 HOSTNAME;重启后同名可重新认领自己的 pending 消息 */
+    private final String reconConsumer = "se-recon-consumer-"
+            + (System.getenv("HOSTNAME") != null ? System.getenv("HOSTNAME") : "local");
+
+    // ============================================================
+    // 发布端:事件入队
+    // ============================================================
+
+    /** Redis 连续入队失败计数(熔断用,多线程并发 ++ 需原子) */
+    private final java.util.concurrent.atomic.AtomicInteger redisFailCount = new java.util.concurrent.atomic.AtomicInteger(0);
+    /** Redis 连续失败超过该次数后暂停降级直连,仅记录(防雪崩) */
+    private static final int REDIS_FAIL_THRESHOLD = 5;
+
+    @Async("seAsyncExecutor")
     @EventListener
     public void onReconTrigger(ReconTriggerEvent event) {
+        try {
+            ReconQueueMsg msg = toQueueMsg(event);
+            Map<String, Object> body = new HashMap<>(2);
+            body.put("payload", JSON.toJSONString(msg));
+            redisTemplate.opsForStream().add(StreamRecords.newRecord()
+                    .in(RECON_STREAM_KEY)
+                    .ofMap(body));
+            redisFailCount.set(0);
+            log.info("重构触发已入队: eventId={}, feederId={}, alarmType={}, pointTime={}",
+                    event.getEventId(), event.getFeederId(), event.getAlarmType(), event.getPointTime());
+        } catch (Exception e) {
+            int failCount = redisFailCount.incrementAndGet();
+            // Redis 不可用时降级:异步直连处理(不阻塞当前线程池)。
+            // 连续失败超阈值后仅记录,不再降级直连(防批量事件触发时全部同步阻塞拖垮线程池)
+            if (failCount <= REDIS_FAIL_THRESHOLD) {
+                log.error("重构触发入队失败,降级直连处理: eventId={}, failCount={}, error={}",
+                        event.getEventId(), failCount, e.getMessage(), e);
+                CompletableFuture.runAsync(() -> handleReconTrigger(event));
+            } else {
+                log.error("重构触发入队失败且超过熔断阈值,跳过降级直连(留日志人工处理): eventId={}, failCount={}, error={}",
+                        event.getEventId(), failCount, e.getMessage(), e);
+            }
+        }
+    }
+
+    // ============================================================
+    // 消费端:定时轮询队列并处理(每 10 秒)
+    // ============================================================
+
+    @Scheduled(cron = "0/10 * * * * ?")
+    public void consumeReconQueue() {
+        try {
+            ensureConsumerGroup();
+            List<MapRecord<String, Object, Object>> records = redisTemplate.opsForStream().read(
+                    Consumer.from(RECON_GROUP, reconConsumer),
+                    StreamReadOptions.empty().count(20),
+                    StreamOffset.create(RECON_STREAM_KEY, ReadOffset.lastConsumed()));
+            if (records == null || records.isEmpty()) return;
+            for (MapRecord<String, Object, Object> record : records) {
+                processQueueRecord(record);
+            }
+        } catch (Exception e) {
+            log.error("重构队列消费异常", e);
+        }
+    }
+
+    /** 处理失败重试上限(超过后丢弃并记录 ERROR,防无限重试) */
+    private static final int MAX_RETRY = 3;
+
+    private void processQueueRecord(MapRecord<String, Object, Object> record) {
+        RecordId id = record.getId();
+        Object payloadObj = null;
+        try {
+            payloadObj = record.getValue().get("payload");
+            if (payloadObj == null) {
+                log.warn("重构队列消息缺少 payload, id={}", id);
+                redisTemplate.opsForStream().acknowledge(RECON_STREAM_KEY, RECON_GROUP, id);
+                return;
+            }
+            // FastJson2 序列化 Map 时可能带 autotype 信息,读回形态可能是 String 或 Map,
+            // 统一转成 JSON 字符串再解析
+            String payloadJson;
+            if (payloadObj instanceof Map) {
+                payloadJson = JSON.toJSONString(payloadObj);
+            } else if (payloadObj instanceof byte[]) {
+                payloadJson = new String((byte[]) payloadObj, java.nio.charset.StandardCharsets.UTF_8);
+            } else {
+                payloadJson = String.valueOf(payloadObj);
+            }
+            ReconQueueMsg msg = JSON.parseObject(payloadJson, ReconQueueMsg.class);
+            ReconTriggerEvent event = toEvent(msg);
+            handleReconTrigger(event);
+            // 处理成功才 ACK
+            redisTemplate.opsForStream().acknowledge(RECON_STREAM_KEY, RECON_GROUP, id);
+        } catch (Exception e) {
+            // 失败分类:消息损坏(payload 缺失/JSON 解析失败)→ 重试无意义,直接 ACK 丢弃;
+            // 业务处理失败(handleReconTrigger 抛异常)→ 重新入队重试,超上限丢弃
+            boolean malformed = payloadObj == null || !isParsablePayload(payloadObj);
+            if (malformed) {
+                log.error("重构队列消息损坏(丢弃): id={}, error={}", id, e.getMessage(), e);
+                redisTemplate.opsForStream().acknowledge(RECON_STREAM_KEY, RECON_GROUP, id);
+            } else {
+                handleRetryOrDrop(id, payloadObj, e);
+            }
+        }
+    }
+
+    /**
+     * 判断 payload 是否能解析(粗略判断,供失败分类用)
+     */
+    private boolean isParsablePayload(Object payloadObj) {
+        try {
+            if (payloadObj instanceof Map) {
+                JSON.toJSONString(payloadObj);
+            } else if (payloadObj instanceof byte[]) {
+                new String((byte[]) payloadObj, java.nio.charset.StandardCharsets.UTF_8);
+            } else {
+                String.valueOf(payloadObj);
+            }
+            return true;
+        } catch (Exception e) {
+            return false;
+        }
+    }
+
+    /**
+     * 业务失败:重试次数 < 上限则重新入队(retryCount+1)并 ACK 原消息;
+     * 超过上限则丢弃并记录 ERROR(人工排查)。
+     */
+    private void handleRetryOrDrop(RecordId id, Object payloadObj, Exception cause) {
+        try {
+            String payloadJson;
+            if (payloadObj instanceof Map) {
+                payloadJson = JSON.toJSONString(payloadObj);
+            } else {
+                payloadJson = String.valueOf(payloadObj);
+            }
+            ReconQueueMsg msg = JSON.parseObject(payloadJson, ReconQueueMsg.class);
+            int retry = msg.getRetryCount() == null ? 0 : msg.getRetryCount();
+            if (retry < MAX_RETRY) {
+                msg.setRetryCount(retry + 1);
+                Map<String, Object> body = new HashMap<>(2);
+                body.put("payload", JSON.toJSONString(msg));
+                redisTemplate.opsForStream().add(StreamRecords.newRecord()
+                        .in(RECON_STREAM_KEY)
+                        .ofMap(body));
+                redisTemplate.opsForStream().acknowledge(RECON_STREAM_KEY, RECON_GROUP, id);
+                log.warn("重构队列消息处理失败,重试入队({}/{}): id={}, error={}",
+                        retry + 1, MAX_RETRY, id, cause.getMessage());
+            } else {
+                redisTemplate.opsForStream().acknowledge(RECON_STREAM_KEY, RECON_GROUP, id);
+                log.error("重构队列消息重试超限(丢弃,需人工排查): id={}, retry={}, error={}",
+                        id, retry, cause.getMessage(), cause);
+            }
+        } catch (Exception e2) {
+            // 重试入队本身失败:ACK 原消息避免死循环,留日志人工处理
+            log.error("重构队列消息重试入队失败(ACK丢弃): id={}, error={}", id, e2.getMessage(), e2);
+            try {
+                redisTemplate.opsForStream().acknowledge(RECON_STREAM_KEY, RECON_GROUP, id);
+            } catch (Exception ignored) {
+            }
+        }
+    }
+
+    /**
+     * 确保消费组存在(首次启动时创建,MKSTREAM 自动建空流)
+     * <p>
+     * 注意:必须用 ReadOffset.from("0") 从头创建组,不能用默认($)——
+     * 默认 $ 表示"只接收组创建之后的新消息",若组创建前已有 XADD 的历史
+     * 消息(如重启期间入队、消费端晚于发布端启动),会被永久跳过。
+     */
+    private void ensureConsumerGroup() {
+        try {
+            redisTemplate.opsForStream().createGroup(RECON_STREAM_KEY, ReadOffset.from("0"), RECON_GROUP);
+        } catch (Exception e) {
+            // BUSYGROUP 已存在则忽略;其余异常抛出
+            String msg = e.getMessage() == null ? "" : e.getMessage();
+            if (!msg.contains("BUSYGROUP")) {
+                throw e;
+            }
+        }
+    }
+
+    // ============================================================
+    // 核心处理逻辑(原 onReconTrigger 主体)
+    // ============================================================
+
+    private void handleReconTrigger(ReconTriggerEvent event) {
+        // 幂等保护:事件已完成重构(有方案/无方案)则跳过,防重投/重复触发重复调用重构接口
+        Integer doneStatus = queryReconStatus(event.getEventId(), event.getAlarmType());
+        if (doneStatus != null && (doneStatus == RECON_COMPLETED || doneStatus == RECON_NO_PLAN)) {
+            log.info("重构已处理过(跳过): eventId={}, reconStatus={}", event.getEventId(), doneStatus);
+            return;
+        }
+
         updateReconStatus(event.getEventId(), event.getAlarmType(), RECON_RUNNING);
 
         Map<String, Object> params = new HashMap<>();
@@ -200,22 +407,51 @@ public class ReconTriggerListener {
         }
     }
 
+    /**
+     * 解析重构接口响应(真实形状: ApiResponse{code, success, data:{success,...}, msg})
+     * <p>
+     * code=10000(ResultCode.SUCCESS) 且 data.success=true → 有方案(2)
+     * code=10000 且 data.success=false → 无方案(3)
+     * code=10001(业务失败) / 解析失败 → 调用失败(-1)
+     */
     private int parseReconResponse(String response) {
         if (response == null || response.isEmpty()) return RECON_FAILED;
         try {
             JSONObject root = com.alibaba.fastjson.JSON.parseObject(response);
-            String status = root.getString("status");
-            if ("200".equals(status) || "success".equalsIgnoreCase(status)) {
+            int code = root.getIntValue("code");
+            if (code == 10000) {
                 Object data = root.get("data");
                 if (data instanceof JSONObject) {
-                    Boolean success = ((JSONObject) data).getBoolean("success");
-                    return Boolean.TRUE.equals(success) ? RECON_COMPLETED : RECON_NO_PLAN;
+                    Boolean dataSuccess = ((JSONObject) data).getBoolean("success");
+                    return Boolean.TRUE.equals(dataSuccess) ? RECON_COMPLETED : RECON_NO_PLAN;
                 }
+                // data 缺失/非对象:按成功处理(接口正常返回但无明细,视为有方案)
+                return RECON_COMPLETED;
             }
+            return RECON_FAILED;
         } catch (Exception e) {
             log.warn("解析重构接口响应失败: {}", e.getMessage());
+            return RECON_FAILED;
         }
-        return RECON_COMPLETED;
+    }
+
+    /**
+     * 查询事件当前 reconStatus(null = 事件不存在或未处理过)
+     */
+    private Integer queryReconStatus(Long eventId, String alarmType) {
+        if (eventId == null) return null;
+        try {
+            if (alarmType != null && alarmType.startsWith("current_")) {
+                FhzgSeCurrentEvent cur = currentEventMapper.selectById(eventId);
+                return cur != null ? cur.getReconStatus() : null;
+            } else if (alarmType != null && alarmType.startsWith("voltage_")) {
+                FhzgSeVoltageEvent cur = voltageEventMapper.selectById(eventId);
+                return cur != null ? cur.getReconStatus() : null;
+            }
+        } catch (Exception e) {
+            log.warn("查询事件 reconStatus 失败: eventId={}, error={}", eventId, e.getMessage());
+        }
+        return null;
     }
 
     private void updateReconStatus(Long eventId, String alarmType, int reconStatus) {
@@ -238,6 +474,33 @@ public class ReconTriggerListener {
         }
     }
 
+    // ============================================================
+    // 消息转换
+    // ============================================================
+
+    private ReconQueueMsg toQueueMsg(ReconTriggerEvent e) {
+        ReconQueueMsg msg = new ReconQueueMsg();
+        msg.setEventId(e.getEventId());
+        msg.setFeederId(e.getFeederId());
+        msg.setFeederName(e.getFeederName());
+        msg.setCountyId(e.getCountyId());
+        msg.setSubsId(e.getSubsId());
+        msg.setAlarmType(e.getAlarmType());
+        msg.setPointTime(e.getPointTime() != null ? e.getPointTime().format(DT_FMT) : null);
+        msg.setTriggerTime(e.getTriggerTime() != null ? e.getTriggerTime().format(DT_FMT) : null);
+        return msg;
+    }
+
+    private ReconTriggerEvent toEvent(ReconQueueMsg msg) {
+        LocalDateTime pointTime = msg.getPointTime() != null
+                ? LocalDateTime.parse(msg.getPointTime(), DT_FMT) : null;
+        LocalDateTime triggerTime = msg.getTriggerTime() != null
+                ? LocalDateTime.parse(msg.getTriggerTime(), DT_FMT) : null;
+        return new ReconTriggerEvent(
+                msg.getEventId(), msg.getFeederId(), msg.getFeederName(),
+                msg.getCountyId(), msg.getSubsId(), msg.getAlarmType(),
+                pointTime, triggerTime);
+    }
     /**
      * 状估数据保存
      * @param event

+ 300 - 0
services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/scheduler/SeBackfillService.java

@@ -0,0 +1,300 @@
+package com.hdkj.lt.bf.scheduler;
+
+import com.alibaba.fastjson.JSON;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.hdkj.fhzg.optimization.request.MaintOrgRequest;
+import com.hdkj.hussar.ApiResponse;
+import com.hdkj.lt.bf.adapter.SiStateEstimationClient;
+import com.hdkj.lt.bf.common.SeSnapshotConstants;
+import com.hdkj.lt.bf.entity.FhzgSeSnapshotDetail;
+import com.hdkj.lt.bf.mapper.FhzgSeSnapshotDetailMapper;
+import com.hdkj.lt.bf.service.SeSnapshotService;
+import com.hdkj.lt.core.bizms.modle.dto.StateEstimation;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.context.event.ApplicationReadyEvent;
+import org.springframework.context.event.EventListener;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 状估断面断档自动补拉(自动补数,不推状态机)
+ * <p>
+ * 定时任务每 15 分钟拉取的是"当前时刻对齐15min再减 periodMinutes"的断面,
+ * 服务更新重启时可能漏拉某县某断面,导致明细/线损表永久缺口。
+ * 本组件在启动时 + 每小时检测断档并自动补拉:
+ * <ul>
+ *   <li>下界:DB 中该县实际最大断面时刻(权威数据,不信任 Redis 缓存)</li>
+ *   <li>上界:si 当前理论最新可拉时刻(now 对齐15min - periodMinutes,与 si 算法同源)</li>
+ *   <li>补拉:走 processSnapshotBackfill —— 只写明细/线损表,不推状态机、不触发事件</li>
+ *   <li>保护:断档超 {@link #MAX_BACKFILL_HOURS} 小时只告警不自动补(防海量补拉打爆 si)</li>
+ * </ul>
+ *
+ * @author lsl
+ * @since 2026-08-11
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class SeBackfillService {
+
+    private final SiStateEstimationClient siStateEstimationClient;
+    private final SeSnapshotService seSnapshotService;
+    private final FhzgSeSnapshotDetailMapper snapshotDetailMapper;
+    private final RedisTemplate<String, Object> redisTemplate;
+
+    private static final DateTimeFormatter DT_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+    /** si 侧断面回看分钟数(与 StateEstimationServiceImpl.queryPeriodByFeeder-minutes 对齐,默认60) */
+    private static final long PERIOD_MINUTES = 60L;
+    /** 断面粒度(分钟) */
+    private static final long SNAP_INTERVAL_MINUTES = 15L;
+    /** 断档超过该小时数不自动补,只告警(防停机多天启动后海量补拉) */
+    private static final long MAX_BACKFILL_HOURS = 24L;
+    /** 单轮补拉最多断面数(防异常场景一次补太多;同时限制尝试次数,防 si 全挂时单轮 96 次调用) */
+    private static final int MAX_BACKFILL_PER_RUN = 16;
+    /** 同一时刻补拉失败后的记忆时长(小时):期间不再重复尝试,防每小时无限重试+日志刷屏 */
+    private static final long FAIL_MEMORY_HOURS = 24L;
+    /** 失败时刻记忆:<时刻, 首次失败时间戳毫秒>,用于跳过近期已失败时刻 */
+    private final Map<LocalDateTime, Long> failMemory = new java.util.concurrent.ConcurrentHashMap<>();
+    /** Redis 每县最后成功断面时刻(仅缓存,DB 才是权威) */
+    private static final String REDIS_LAST_KEY_PREFIX = "se:snapshot:last:";
+
+    /** 启动完成后自动补拉一次 */
+    @EventListener(ApplicationReadyEvent.class)
+    public void onApplicationReady() {
+        try {
+            log.info("[断面补拉] 应用启动完成,开始断档检测补拉");
+            detectAndBackfill();
+        } catch (Exception e) {
+            log.error("[断面补拉] 启动补拉异常", e);
+        }
+    }
+
+    /** 每 6 小时自动检测补拉(定时链路已覆盖大部分,此为本组件兜底;频率低防 bf 任务臃肿) */
+    @Scheduled(cron = "0 5 */6 * * ?")
+    public void scheduledBackfill() {
+        try {
+            log.info("[断面补拉] 定时检测开始");
+            detectAndBackfill();
+        } catch (Exception e) {
+            log.error("[断面补拉] 定时检测异常", e);
+        }
+    }
+
+    /**
+     * 检测并补拉断档断面(每县)
+     */
+    public void detectAndBackfill() {
+        for (Map.Entry<String, String[]> county : SeSnapshotConstants.COUNTIES) {
+            String maintOrg = county.getKey();
+            String countyName = county.getValue()[1];
+            try {
+                backfillCounty(maintOrg, countyName);
+            } catch (Exception e) {
+                log.error("[断面补拉] 县域{}补拉异常", countyName, e);
+            }
+        }
+    }
+
+    private void backfillCounty(String maintOrg, String countyName) {
+        // 1. 下界:DB 实际最大断面时刻(权威)
+        LocalDateTime lastDb = queryLastSnapshotTime(maintOrg, countyName);
+        // 2. 上界:si 理论最新可拉时刻(与 si buildStateEstimationReq 同源)
+        LocalDateTime latest = latestAvailableSnapTime();
+        if (lastDb == null) {
+            log.info("[断面补拉] 县域{} 尚无任何断面数据,跳过自动补拉(首次全量需人工评估)", countyName);
+            return;
+        }
+        if (!latest.isAfter(lastDb)) {
+            log.debug("[断面补拉] 县域{} 无断档(lastDb={}, latest={})", countyName, lastDb, latest);
+            return;
+        }
+
+        // 3. 断档窗口过大只告警
+        Duration gap = Duration.between(lastDb, latest);
+        if (gap.toHours() > MAX_BACKFILL_HOURS) {
+            log.warn("[断面补拉] 县域{} 断档过大({}h)超过保护阈值{}h,不自动补拉,请人工评估",
+                    countyName, gap.toHours(), MAX_BACKFILL_HOURS);
+            return;
+        }
+
+        // 4. 枚举缺失的 15min 网格时刻(从 lastDb 对齐后的下一网格到 latest)
+        List<LocalDateTime> missing = enumerateMissingSnapTimes(lastDb, latest);
+        if (missing.isEmpty()) {
+            log.debug("[断面补拉] 县域{} 断档窗口内无缺失时刻", countyName);
+            return;
+        }
+        // 过滤掉近期已失败的时刻(防 si 无数据时每小时无限重试同一批)
+        long nowMs = System.currentTimeMillis();
+        List<LocalDateTime> pending = new ArrayList<>();
+        for (LocalDateTime t : missing) {
+            Long firstFail = failMemory.get(t);
+            if (firstFail != null && nowMs - firstFail < FAIL_MEMORY_HOURS * 3600_000L) {
+                continue;
+            }
+            pending.add(t);
+        }
+        if (pending.isEmpty()) {
+            log.info("[断面补拉] 县域{} 检测到 {} 个缺失断面但均处于失败记忆期,跳过本轮", countyName, missing.size());
+            return;
+        }
+        log.info("[断面补拉] 县域{} 检测到 {} 个缺失断面(去失败记忆后{}个): {} ~ {}, 开始补拉",
+                countyName, missing.size(), pending.size(), pending.get(0), pending.get(pending.size() - 1));
+
+        int success = 0;
+        int attempted = 0;
+        for (LocalDateTime t : pending) {
+            if (success >= MAX_BACKFILL_PER_RUN || attempted >= MAX_BACKFILL_PER_RUN) {
+                log.warn("[断面补拉] 县域{} 本轮补拉达到上限{}个(成功{}次/尝试{}次),剩余下轮继续",
+                        countyName, MAX_BACKFILL_PER_RUN, success, attempted);
+                break;
+            }
+            attempted++;
+            try {
+                boolean ok = backfillOne(maintOrg, countyName, t);
+                if (ok) {
+                    success++;
+                    updateRedisLast(countyName, t);
+                    failMemory.remove(t);
+                } else {
+                    failMemory.putIfAbsent(t, System.currentTimeMillis());
+                }
+            } catch (Exception e) {
+                log.error("[断面补拉] 县域{} 补拉断面{}失败: {}", countyName, t, e.getMessage(), e);
+                failMemory.putIfAbsent(t, System.currentTimeMillis());
+            }
+        }
+        log.info("[断面补拉] 县域{} 本轮补拉完成,尝试{}次成功{}个", countyName, attempted, success);
+    }
+
+    /**
+     * 补拉单个断面时刻:调 si 拉该时刻数据 → backfill 写入(只补数据不推状态机)
+     */
+    private boolean backfillOne(String maintOrg, String countyName, LocalDateTime pointTime) {
+        MaintOrgRequest request = new MaintOrgRequest();
+        request.setMaintOrg(maintOrg);
+        request.setPointTime(pointTime.format(DT_FMT));
+
+        ApiResponse<List<StateEstimation>> resp = siStateEstimationClient.queryEquipByCounty(request);
+        if (resp == null || !resp.isSuccess() || resp.getData() == null || resp.getData().isEmpty()) {
+            log.warn("[断面补拉] 县域{} 时刻{} 无数据, resp={}", countyName, pointTime,
+                    resp != null ? resp.isSuccess() : "null");
+            return false;
+        }
+        List<StateEstimation> mergedList = resp.getData();
+        // 聚合(与 SeSnapshotScheduler 相同逻辑:多馈线结果合并为一个断面)
+        StateEstimation merged = mergeCountyResults(mergedList);
+        if (merged == null) {
+            log.warn("[断面补拉] 县域{} 时刻{} 聚合后无数据", countyName, pointTime);
+            return false;
+        }
+        seSnapshotService.processSnapshotBackfill(JSON.toJSONString(merged));
+        log.info("[断面补拉] 县域{} 补拉断面{}成功", countyName, pointTime);
+        return true;
+    }
+
+    /**
+     * 查询该县 DB 实际最大断面时刻(权威下界)
+     * <p>
+     * 明细表 county_id 存的就是 feeder 树 type=2 节点 ID(即区县 ID),
+     * 与 SeSnapshotConstants.COUNTIES 的 key(maintOrg)同源,可直接过滤。
+     */
+    private LocalDateTime queryLastSnapshotTime(String maintOrg, String countyName) {
+        List<FhzgSeSnapshotDetail> rows = snapshotDetailMapper.selectList(
+                new LambdaQueryWrapper<FhzgSeSnapshotDetail>()
+                        .select(FhzgSeSnapshotDetail::getSnapshotTime)
+                        .eq(FhzgSeSnapshotDetail::getCountyId, maintOrg)
+                        .orderByDesc(FhzgSeSnapshotDetail::getSnapshotTime)
+                        .last("LIMIT 1"));
+        if (rows == null || rows.isEmpty()) return null;
+        LocalDateTime t = rows.get(0).getSnapshotTime();
+        // 缓存一份到 Redis(仅参考,不用于决策)
+        try {
+            redisTemplate.opsForValue().set(REDIS_LAST_KEY_PREFIX + countyName, t.toString());
+        } catch (Exception ignored) {
+        }
+        return t;
+    }
+
+    /**
+     * si 当前理论最新可拉断面时刻:now 对齐15min - periodMinutes(与 si 算法同源)
+     */
+    private LocalDateTime latestAvailableSnapTime() {
+        LocalDateTime now = LocalDateTime.now();
+        int slot = (now.getMinute() / (int) SNAP_INTERVAL_MINUTES) * (int) SNAP_INTERVAL_MINUTES;
+        LocalDateTime floorNow = now.withMinute(slot).withSecond(0).withNano(0);
+        return floorNow.minusMinutes(PERIOD_MINUTES);
+    }
+
+    /**
+     * 枚举 lastDb 之后到 latest 之间缺失的 15min 网格时刻
+     * <p>
+     * lastDb 可能不在 15min 网格上(例如 09:10,补拉写入的时刻带分钟偏移),
+     * 需先将 lastDb 对齐到<b>下一个</b>网格起点(09:10 → 09:15),否则 09:15 网格点
+     * 会被永久漏掉(永远认为该时刻已存在)。
+     */
+    private List<LocalDateTime> enumerateMissingSnapTimes(LocalDateTime lastDb, LocalDateTime latest) {
+        List<LocalDateTime> result = new ArrayList<>();
+        // 对齐到下一网格起点:分钟向下取整到 15 的倍数,若已是整格则从 +15min 开始
+        int minute = lastDb.getMinute();
+        int slotBase = (minute / (int) SNAP_INTERVAL_MINUTES) * (int) SNAP_INTERVAL_MINUTES;
+        LocalDateTime cursor = lastDb.withMinute(slotBase).withSecond(0).withNano(0)
+                .plusMinutes(SNAP_INTERVAL_MINUTES);
+        while (!cursor.isAfter(latest)) {
+            result.add(cursor);
+            cursor = cursor.plusMinutes(SNAP_INTERVAL_MINUTES);
+        }
+        return result;
+    }
+
+    private void updateRedisLast(String countyName, LocalDateTime t) {
+        try {
+            redisTemplate.opsForValue().set(REDIS_LAST_KEY_PREFIX + countyName, t.toString());
+        } catch (Exception e) {
+            log.debug("[断面补拉] 更新Redis last失败(不影响主流程): {}", e.getMessage());
+        }
+    }
+
+    /**
+     * 多馈线结果合并(与 SeSnapshotScheduler.mergeCountyResults 相同逻辑)
+     */
+    private StateEstimation mergeCountyResults(List<StateEstimation> list) {
+        if (list == null || list.isEmpty()) return null;
+        StateEstimation merged = new StateEstimation();
+        List<StateEstimation.StateEstimationCommonResult> allFeeders = new ArrayList<>();
+        List<StateEstimation.StateEstimationTransResult> allMvtrans = new ArrayList<>();
+        List<StateEstimation.StateEstimationSwitchResult> allSwitches = new ArrayList<>();
+        List<StateEstimation.StateEstimationCommonResult> allEquips = new ArrayList<>();
+        List<StateEstimation.StateEstimationSegmentResult> allSegments = new ArrayList<>();
+        List<String> pointTime = null;
+        for (StateEstimation se : list) {
+            if (se == null) continue;
+            if (pointTime == null && se.getPointTime() != null) pointTime = se.getPointTime();
+            if (se.getPeriodFeederSeResult() != null) allFeeders.addAll(se.getPeriodFeederSeResult());
+            if (se.getPeriodMVTransSeResult() != null) allMvtrans.addAll(se.getPeriodMVTransSeResult());
+            if (se.getPeriodSwitchSeResult() != null) allSwitches.addAll(se.getPeriodSwitchSeResult());
+            if (se.getPeriodEquipResult() != null) allEquips.addAll(se.getPeriodEquipResult());
+            if (se.getPeriodSegmentSeResult() != null) allSegments.addAll(se.getPeriodSegmentSeResult());
+        }
+        if (allFeeders.isEmpty() && allMvtrans.isEmpty()) {
+            log.warn("[断面补拉] 聚合后无任何数据");
+            return null;
+        }
+        merged.setPeriodFeederSeResult(allFeeders);
+        merged.setPeriodMVTransSeResult(allMvtrans);
+        merged.setPeriodSwitchSeResult(allSwitches);
+        merged.setPeriodEquipResult(allEquips);
+        merged.setPeriodSegmentSeResult(allSegments);
+        merged.setPointTime(pointTime);
+        return merged;
+    }
+}

+ 8 - 0
services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/service/SeSnapshotService.java

@@ -31,6 +31,14 @@ public interface SeSnapshotService {
      */
     void processSnapshotOverwrite(String snapshotJson);
 
+    /**
+     * 处理一个状估断面,仅补数据(backfill),不推状态机、不触发事件<br>
+     * 用于补拉历史断面 / 手动补数,防止状态机时间倒退导致事件错乱。
+     *
+     * @param snapshotJson 状估JSON字符串(单县)
+     */
+    void processSnapshotBackfill(String snapshotJson);
+
     /**
      * 处理一批状估断面(多县 JSONArray)
      *

+ 64 - 13
services/load-transfer-bf/src/main/java/com/hdkj/lt/bf/service/impl/SeSnapshotServiceImpl.java

@@ -36,6 +36,7 @@ import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.context.ApplicationEventPublisher;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
 
 import java.math.BigDecimal;
 import java.math.RoundingMode;
@@ -75,6 +76,23 @@ public class SeSnapshotServiceImpl implements SeSnapshotService {
 
     @Override
     public void processSnapshot(String snapshotJson) {
+        // 实时链路:补数据 + 推状态机(状态机推进 + 事件触发 + 重构触发)
+        processInternal(snapshotJson, true);
+    }
+
+    @Override
+    public void processSnapshotBackfill(String snapshotJson) {
+        // 补数链路:只写明细表 + 线损表,不推状态机、不触发事件(防状态机时间倒退)
+        processInternal(snapshotJson, false);
+    }
+
+    /**
+     * 断面解析统一入口
+     *
+     * @param snapshotJson 状估JSON字符串(单县)
+     * @param advanceState true=完整链路(数据+状态机+事件);false=仅补数据(backfill)
+     */
+    private void processInternal(String snapshotJson, boolean advanceState) {
         JSONObject root = JSON.parseObject(snapshotJson);
         if (root == null) return;
 
@@ -102,18 +120,27 @@ public class SeSnapshotServiceImpl implements SeSnapshotService {
                 if (id != null) allDeviceIds.add(id);
             }
         }
-        Map<String, FhzgSeMonitorCurrent> currentStateMap = loadCurrentStateMap(feederIds);
-        Map<String, FhzgSeMonitorVoltage> voltageStateMap = loadVoltageStateMap(allDeviceIds);
+        Map<String, FhzgSeMonitorCurrent> currentStateMap = null;
+        Map<String, FhzgSeMonitorVoltage> voltageStateMap = null;
+        Map<String, String> transformerNameMap = null;
+        if (advanceState) {
+            currentStateMap = loadCurrentStateMap(feederIds);
+            voltageStateMap = loadVoltageStateMap(allDeviceIds);
+        }
 
         // === 1. 馈线 ===
         if (feeders != null) {
+            // 同批次内 deviceId 去重(防重查的是 DB 存量,不含当前批内重复项)
+            Set<String> seenFeederIds = new HashSet<>();
             for (int i = 0; i < feeders.size(); i++) {
                 JSONObject f = feeders.getJSONObject(i);
                 FhzgSeSnapshotDetail d = parseFeeder(f, snapTime, feederMap);
-                if (d != null) {
+                if (d != null && d.getDeviceId() != null && seenFeederIds.add(d.getDeviceId())) {
                     batch.add(d);
-                    processFeederCurrent(f, d, snapTime, feederMap, currentStateMap);
-                    processFeederVoltage(f, d, snapTime, feederMap, voltageStateMap);
+                    if (advanceState) {
+                        processFeederCurrent(f, d, snapTime, feederMap, currentStateMap);
+                        processFeederVoltage(f, d, snapTime, feederMap, voltageStateMap);
+                    }
                 }
             }
         }
@@ -126,13 +153,19 @@ public class SeSnapshotServiceImpl implements SeSnapshotService {
                 String id = mvtrans.getJSONObject(i).getString("psrId");
                 if (id != null) mvtransIds.add(id);
             }
-            Map<String, String> transformerNameMap = buildTransformerMapping(mvtransIds);
+            if (advanceState) {
+                transformerNameMap = buildTransformerMapping(mvtransIds);
+            }
+            // 同批次内 deviceId 去重(同上)
+            Set<String> seenMvtransIds = new HashSet<>();
             for (int i = 0; i < mvtrans.size(); i++) {
                 JSONObject m = mvtrans.getJSONObject(i);
                 FhzgSeSnapshotDetail d = parseMvtrans(m, snapTime, feederMap);
-                if (d != null) {
+                if (d != null && d.getDeviceId() != null && seenMvtransIds.add(d.getDeviceId())) {
                     batch.add(d);
-                    processMvtransMonitor(m, d, snapTime, feederMap, transformerNameMap, voltageStateMap);
+                    if (advanceState) {
+                        processMvtransMonitor(m, d, snapTime, feederMap, transformerNameMap, voltageStateMap);
+                    }
                 }
             }
         }
@@ -173,6 +206,7 @@ public class SeSnapshotServiceImpl implements SeSnapshotService {
 
     private FhzgSeSnapshotDetail parseFeeder(JSONObject f, LocalDateTime snapTime, Map<String, String[]> feederMap) {
         String feederId = f.getString("psrId");
+        if (feederId == null || feederId.isEmpty()) return null;  // 缺 psrId 直接丢弃,防 NULL 脏写
         String[] loc = feederMap.getOrDefault(feederId, new String[]{"", "", ""});
         BigDecimal loadRate = firstValue(f.getJSONArray("loadRates"));
         BigDecimal voltage = firstValue(f.getJSONArray("seoUs"));
@@ -214,6 +248,13 @@ public class SeSnapshotServiceImpl implements SeSnapshotService {
             if (isOverLimit) state.setFirstOverTime(snapTime);
             monitorCurrentMapper.insert(state);
         } else {
+            // 单调守卫:乱序/回拨断面(snapTime <= lastSnapTime)不推进状态机,
+            // 防止补拉历史断面或乱序数据导致 firstOverTime 时间倒退、重复触发事件
+            if (state.getLastSnapTime() != null && !snapTime.isAfter(state.getLastSnapTime())) {
+                log.warn("乱序断面跳过电流状态机: feederId={}, snapTime={}, lastSnapTime={}",
+                        feederId, snapTime, state.getLastSnapTime());
+                return;
+            }
             LocalDateTime lastSnap = state.getLastSnapTime();
             if (lastSnap != null && Duration.between(lastSnap, snapTime).toMinutes() > SeMonitorThreshold.SNAP_INTERVAL_MINUTES) {
                 // 断档间隙:先关掉还在活跃的事件,再清空状态机引用
@@ -309,6 +350,7 @@ public class SeSnapshotServiceImpl implements SeSnapshotService {
 
     private FhzgSeSnapshotDetail parseMvtrans(JSONObject m, LocalDateTime snapTime, Map<String, String[]> feederMap) {
         String mvtransId = m.getString("psrId");
+        if (mvtransId == null || mvtransId.isEmpty()) return null;  // 缺 psrId 直接丢弃,防 NULL 脏写
         String feederId = m.getString("feederId");
         String[] loc = feederMap.getOrDefault(feederId, new String[]{"", "", ""});
         BigDecimal lvVoltageA = firstValue(m.getJSONArray("seiLvUas"));
@@ -372,6 +414,13 @@ public class SeSnapshotServiceImpl implements SeSnapshotService {
             if (isOverLimit) state.setFirstOverTime(snapTime);
             monitorVoltageMapper.insert(state);
         } else {
+            // 单调守卫:乱序/回拨断面(snapTime <= lastSnapTime)不推进状态机,
+            // 防止补拉历史断面或乱序数据导致 firstOverTime 时间倒退、重复触发事件
+            if (state.getLastSnapTime() != null && !snapTime.isAfter(state.getLastSnapTime())) {
+                log.warn("乱序断面跳过电压状态机: deviceId={}, deviceType={}, snapTime={}, lastSnapTime={}",
+                        deviceId, deviceType, snapTime, state.getLastSnapTime());
+                return;
+            }
             LocalDateTime lastSnap = state.getLastSnapTime();
             if (lastSnap != null && Duration.between(lastSnap, snapTime).toMinutes() > SeMonitorThreshold.SNAP_INTERVAL_MINUTES) {
                 // 断档间隙:先关掉还在活跃的事件,再清空状态机引用
@@ -851,6 +900,7 @@ public class SeSnapshotServiceImpl implements SeSnapshotService {
     // ============================================================
 
     @Override
+    @Transactional(rollbackFor = Exception.class)
     public void processSnapshotOverwrite(String snapshotJson) {
         JSONObject root = JSON.parseObject(snapshotJson);
         if (root == null) return;
@@ -859,7 +909,8 @@ public class SeSnapshotServiceImpl implements SeSnapshotService {
         if (pointTimes == null || pointTimes.isEmpty()) return;
         LocalDateTime snapTime = LocalDateTime.parse(pointTimes.getString(0), DT_FMT);
 
-        // 先删该断面时刻的存量明细(覆盖更新;无大事务,删除独立提交)
+        // 先删该断面时刻的存量明细(覆盖更新;与后续插入同事务,失败整体回滚,
+        // 避免"删成功插失败"导致该时刻数据永久缺失的窗口)
         int deleted = snapshotDetailMapper.delete(
                 new LambdaQueryWrapper<FhzgSeSnapshotDetail>()
                         .eq(FhzgSeSnapshotDetail::getSnapshotTime, snapTime));
@@ -869,9 +920,9 @@ public class SeSnapshotServiceImpl implements SeSnapshotService {
                         .eq(FhzgSeLineLossDetail::getSnapTime, snapTime));
         log.info("状估覆盖更新 snapTime={}, 删除断面{}条, 删除线损{}条", snapTime, deleted, deletedLoss);
 
-        // 重新走正常流程写入(此时防重逻辑不会跳过任何记录)
-        // 注意:不包裹大事务——手动补断面耗时可能超 Druid removeAbandonedTimeout,
-        // 若删+写整体回滚会导致断面永远无法写入;宁可中途失败下次再补
-        processSnapshot(snapshotJson);
+        // 重新走补数链路写入(此时防重逻辑不会跳过任何记录)
+        // 注意:覆盖更新用于补历史断面,只写数据不推状态机——防止状态机时间倒退、
+        // 重复触发事件/重构;状态机由实时链路单调推进
+        processInternal(snapshotJson, false);
     }
 }

+ 12 - 8
services/ruoyi-job/src/main/java/com/hdkj/lt/job/task/SeVoltageCleanupTask.java

@@ -1,14 +1,18 @@
 package com.hdkj.lt.job.task;
 
-import com.hdkj.lt.bf.scheduler.SeVoltageCleanupScheduler;
+import com.hdkj.lt.bf.scheduler.SeEventCleanupScheduler;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Component;
 
 /**
- * 电压事件兜底清扫定时任务(凌晨3点
+ * 运行态事件自动超时关闭定时任务(每小时
  * <p>
- * sys_job 配置:invokeTarget = seVoltageCleanup.noParams(),cron = 0 0 3 * * ?
+ * sys_job 配置:invokeTarget = seVoltageCleanup.noParams(),cron = 0 0 * * * ?
+ * <p>
+ * 职责(委派 common-se SeEventCleanupScheduler):
+ * 关闭超过 24h 未恢复的活跃事件(重过载 current + 电压 voltage 全级别),
+ * 并同步重置对应监测状态机,防止事件永久挂起 status=1。
  *
  * @author lsl
  * @since 2026-08-05
@@ -18,15 +22,15 @@ import org.springframework.stereotype.Component;
 @RequiredArgsConstructor
 public class SeVoltageCleanupTask {
 
-    private final SeVoltageCleanupScheduler seVoltageCleanupScheduler;
+    private final SeEventCleanupScheduler seEventCleanupScheduler;
 
     public void noParams() {
-        log.info("[电压事件清扫] 开始执行");
+        log.info("[事件清扫] 开始执行自动超时关闭");
         try {
-            int updated = seVoltageCleanupScheduler.cleanupStaleVoltageEvents();
-            log.info("[电压事件清扫] 执行完成,共关闭 {} 条", updated);
+            int updated = seEventCleanupScheduler.cleanupStaleEvents();
+            log.info("[事件清扫] 执行完成,共关闭 {} 条", updated);
         } catch (Exception e) {
-            log.error("[电压事件清扫] 执行失败", e);
+            log.error("[事件清扫] 执行失败", e);
         }
     }
 }