Przeglądaj źródła

feat: 智能清洗进度全 SSE 推送(去轮询)+ 完成后自动跳转统计页

问题:纯 AI/4级批次没有 doClean,SSE 清洗进度永不达 100%,完成判定与自动跳转
永不触发(此前靠已移除的「查看导入结果」按钮兜底,移除后卡在「任务执行中」)。

后端:
- SeeDataDTO 增加 payload(结构化载荷,兼容原两参构造)
- AiSmartCleanService 新增 SSE 推送:每判定一张表/每个阶段切换/每张 AI 表入库/批次终态
  推送状态快照(cmd 103 携带 SmartCleanStatusVO);终态推送顺序=统计(101,100%)
  → 快照(103) → 治理宣告(102),保证治理启动晚于全部清洗动作
- DmService silent 语义调整:各趟 doClean 全静默(收口消息统一由终态推送发出),
  清理副作用(缓存/人员库/全文检索/临时文件)照常执行

前端(cleanProgress.vue,首次入库文件):
- 删除 2 秒轮询,cmd 103 驱动进度环/处理日志/完成判定
- 打开页面(含刷新恢复)仅做一次性状态同步;SSE 断连且批次未终态时给出「改为手动清洗」出口
- 修复完成判定 TDZ:watch(isComplete) 在 setup 早期急切求值未初始化的 aiStageFinished
  导致整页白屏(完成判定与自动跳转移至 aiStageFinished 声明之后)

验证(浏览器实测,无轮询):纯 AI 批次判定完成约130s后未刷新自动跳转统计页
(列表显示导入成功 16 条);刷新恢复路径同样跳转;指纹直通秒完批次正常跳回;
aiclean 76 单测全绿(静默趟断言更新为两趟全静默)
cc 6 godzin temu
rodzic
commit
fa8a54997f

+ 15 - 1
ai-server/src/main/java/com/zsjz/ai/common/model/plat/dto/SeeDataDTO.java

@@ -1,4 +1,18 @@
 package com.zsjz.ai.common.model.plat.dto;
 
-public record SeeDataDTO(int completedCount, int failCount) {
+/**
+ * SSE 消息的附带数据。
+ *
+ * @param completedCount 完成条数(清洗/治理统计,原语义不变)
+ * @param failCount      失败条数(原语义不变)
+ * @param payload        结构化载荷(可空)。智能清洗用它携带批次状态快照
+ *                       ({@code SmartCleanStatusVO}),前端收到即可直接刷新进度,
+ *                       不必再轮询状态接口(2026-10-11 起全部改 SSE 推送)
+ */
+public record SeeDataDTO(int completedCount, int failCount, Object payload) {
+
+    /** 兼容原两参调用点 */
+    public SeeDataDTO(int completedCount, int failCount) {
+        this(completedCount, failCount, null);
+    }
 }

+ 106 - 14
ai-server/src/main/java/com/zsjz/ai/module/aiclean/service/AiSmartCleanService.java

@@ -7,12 +7,18 @@ import com.zsjz.ai.common.context.CaseContextHolder;
 import com.zsjz.ai.common.exception.ServerException;
 import com.zsjz.ai.common.model.dm.dto.DoCleanDTO;
 import com.zsjz.ai.common.model.dm.entity.FileInfo;
+import com.zsjz.ai.common.model.plat.dto.SeeDataDTO;
+import com.zsjz.ai.common.model.plat.dto.SseDTO;
 import com.zsjz.ai.module.aiclean.entity.AiCleanBatch;
 import com.zsjz.ai.module.aiclean.entity.AiCleanJob;
 import com.zsjz.ai.module.aiclean.enums.AiCleanStageEnum;
 import com.zsjz.ai.module.aiclean.enums.AiCleanStatusEnum;
 import com.zsjz.ai.module.aiclean.mapper.AiCleanBatchMapper;
+import com.zsjz.ai.module.aiclean.mapper.AiCleanJobMapper;
+import com.zsjz.ai.module.dm.mapper.FileInfoMapper;
 import com.zsjz.ai.module.dm.service.DmService;
+import com.zsjz.ai.module.govern.serivce.GovernConfService;
+import com.zsjz.ai.module.plat.service.SseService;
 import jakarta.annotation.PreDestroy;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
@@ -36,12 +42,10 @@ import java.util.concurrent.atomic.AtomicLong;
 /**
  * 智能清洗融合编排:一次请求把「既有模板清洗」和「AI 清洗未匹配文件」跑完。
  *
- * <h3>为什么判完再统一发一次 doClean</h3>
- * {@code DmService.doClean} 是 fire-and-forget,且完成回调里会 {@code sseService.closeSee()}。
- * 一个批次里若让 AI 命中既有模板的那些 sheet 各自再发一次 doClean,就会出现
- * ①同一文件被洗两遍 ②多个 SSE 会话先后关闭、进度页中途断流。所以本类的顺序是固定的:
- * <b>判定(并发) → 全部判完 → 合并成一次 doClean → AI 自建表入库</b>。
- * 判定未归零就进清洗阶段是错的,会把 AI 命中的表漏掉。
+ * <h3>本类负责什么</h3>
+ * <b>判定(并发) → 按降级顺序串行的静默 doClean(1级→2级) → 3级 AI 自建表入库 →
+ * SSE 收口推送</b>。判定未归零就进清洗阶段是错的,会把 AI 命中的表漏掉;
+ * 收口消息(统计/终态快照/治理宣告)统一在最后推送,保证顺序正确。
  *
  * <h3>与手动清洗不重叠</h3>
  * 只接管 {@code templateId == null} 的 sheet;既有能匹配的绝不插手。
@@ -64,6 +68,15 @@ public class AiSmartCleanService {
 
     private final DmService dmService;
 
+    /** 进度与终态全部走 SSE 推送(cmd 103),前端不再轮询状态接口 */
+    private final SseService sseService;
+
+    private final FileInfoMapper fileInfoMapper;
+
+    private final AiCleanJobMapper jobMapper;
+
+    private final GovernConfService governConfService;
+
     @Value("${zsjz.ai-clean.max-concurrent:3}")
     private int maxConcurrent;
 
@@ -130,7 +143,7 @@ public class AiSmartCleanService {
         Thread.startVirtualThread(body);
     }
 
-    /** 前端轮询 */
+    /** 前端轮询(兼容旧入口;实时进度已改为 SSE 推送,见 {@link #pushProgress}) */
     public SmartCleanStatusVO status(Long caseId, Long batchId) {
         AiCleanBatch batch = batchMapper.selectOne(Wrappers.<AiCleanBatch>lambdaQuery()
                 .eq(AiCleanBatch::getCaseId, caseId)
@@ -144,6 +157,74 @@ public class AiSmartCleanService {
                 batch.getAiLoadCount(), batch.getNeedReviewCount(), batch.getFailedCount(), items);
     }
 
+    /**
+     * 推送一次批次状态快照(cmd 103):阶段、计数、待人工明细随 SSE 直达前端,
+     * 前端据此刷新进度环与处理日志 —— <b>不再轮询状态接口</b>(需求 2026-10-11)。
+     *
+     * <p>推送是辅助链路:{@code status} 或发送失败都只记日志不上抛,
+     * 绝不能因为 SSE 断了把清洗主流程带崩。
+     */
+    private void pushProgress(Long caseId, Long batchId) {
+        try {
+            Long userId = CaseContextHolder.getUserId();
+            if (userId == null) {
+                return;
+            }
+            SmartCleanStatusVO vo = status(caseId, batchId);
+            if (vo == null) {
+                return;
+            }
+            sseService.sendSee(userId, new SseDTO(103, 0, "AI 清洗进度", new SeeDataDTO(0, 0, vo)));
+        } catch (Exception e) {
+            log.debug("AI 清洗进度推送失败: batchId={}, err={}", batchId, e.getMessage());
+        }
+    }
+
+    /**
+     * 批次终态的 SSE 收口(口径与 {@code DmService.doClean} 完成时一致):
+     * 统计(cmd 101,100%)→ 状态快照(cmd 103)→ 治理启动宣告(cmd 102,开启治理时)。
+     *
+     * <p>顺序有意:治理关闭时前端收到终态快照会立即收尾并关闭流,统计消息必须先到。
+     * 智能清洗的 doClean 各趟都是静默的(见 {@code dispatchExistingByLevel}),
+     * 因此这一步是全链路唯一的终态出口 —— 纯 AI/无既有清洗的批次也靠它让前端完成跳转。
+     */
+    private void pushTerminal(Long caseId, Long batchId) {
+        try {
+            Long userId = CaseContextHolder.getUserId();
+            if (userId == null) {
+                return;
+            }
+            // 成功条数 = 系统清洗入库行数(dataNum-failNum)+ AI 动态表入库行数
+            int dataNum = 0;
+            int failNum = 0;
+            for (FileInfo row : fileInfoMapper.selectList(Wrappers.lambdaQuery(FileInfo.class)
+                    .eq(FileInfo::getBatchId, batchId)
+                    .isNull(FileInfo::getFileType))) {
+                dataNum += java.util.Objects.requireNonNullElse(row.getDataNum(), 0);
+                failNum += java.util.Objects.requireNonNullElse(row.getFailNum(), 0);
+            }
+            long aiRows = 0;
+            for (AiCleanJob job : jobMapper.selectList(Wrappers.lambdaQuery(AiCleanJob.class)
+                    .eq(AiCleanJob::getBatchId, batchId))) {
+                aiRows += java.util.Objects.requireNonNullElse(job.getRowLoaded(), 0);
+            }
+            int completed = (int) Math.max(0, dataNum - failNum + aiRows);
+            sseService.sendSee(userId, new SseDTO(101, 100, "[数据清洗]所有文件清洗完成!",
+                    new SeeDataDTO(completed, failNum)));
+            SmartCleanStatusVO vo = status(caseId, batchId);
+            if (vo != null) {
+                sseService.sendSee(userId, new SseDTO(103, 100, "AI 清洗流程完成",
+                        new SeeDataDTO(0, 0, vo)));
+            }
+            if (governConfService.hasGovern()) {
+                sseService.sendSee(userId, new SseDTO(102, 100, "[数据治理]开始数据治理!",
+                        new SeeDataDTO(0, 0)));
+            }
+        } catch (Exception e) {
+            log.warn("智能清洗终态推送失败: batchId={}, err={}", batchId, e.getMessage());
+        }
+    }
+
     // ==================== 后台主流程 ====================
 
     /** 后台主流程:判定(并发)→ 一次 doClean → AI 自建表入库 → 终态 */
@@ -168,6 +249,8 @@ public class AiSmartCleanService {
                     }
                 }
             }
+            // 判定开始即推一次快照:前端据此建立初始进度(此后全靠 SSE 推送,不再轮询)
+            pushProgress(caseId, batchId);
             // 判定并发:闸门 + 虚拟线程,结果按批次聚合。
             // ★ 判定任务跑在独立虚拟线程上,ScopedValue 绑定不会跨线程传播,
             // 必须用 runWith 显式带上案件上下文,否则 judge 里的 slave 查询
@@ -187,6 +270,7 @@ public class AiSmartCleanService {
                             st.needReview.incrementAndGet();
                             bump(batchId, "needReview");
                             bump(batchId, "aiJudged");
+                            pushProgress(caseId, batchId);
                             return;
                         }
                         // acquire 必须在 try 之外:acquire 本身被中断时 finally 会 release 一个没拿到的许可,闸门被放水
@@ -207,28 +291,36 @@ public class AiSmartCleanService {
                             gate.release();
                             // 三种结论都算「判完」,异常也算:否则前端进度会永远差几条卡在 99%
                             bump(batchId, "aiJudged");
+                            // 每判完一张表推一次快照:进度环与无法清洗明细实时更新
+                            pushProgress(caseId, batchId);
                         }
                     }), executor)).toList();
             CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
 
             // 判定全部归零之后才能进清洗阶段,否则会漏掉 AI 命中的表
             stage(batchId, AiCleanStageEnum.CLEANING_EXISTING);
+            pushProgress(caseId, batchId);
             // ★ 按降级顺序串行清洗(需求 2026-10-11):1级(上传已匹配模板)先洗并等它
             // 全部落库,再洗 2级(AI 命中既有模板),然后才进 3级(AI 自建表入库)。
             // 4级(无法清洗)在判定阶段就只标记、不产生清洗任务。
             int toExisting = dispatchExistingByLevel(roots, existingHits);
 
             stage(batchId, AiCleanStageEnum.LOADING_AI);
+            pushProgress(caseId, batchId);
             for (PendingAi p : pending) {
                 AiJudgeResult.AiPlan plan = plans.get(p.job().getId());
                 if (plan == null) {
                     continue;
                 }
                 loadPlan(batchId, p, plan, st);
+                pushProgress(caseId, batchId);
             }
             // 终态看本地计数,不回读 DB:并发写的行刚被别人累加过,读回来的值不代表这一轮
             AiCleanStageEnum finalStage = st.failed.get() > 0 ? AiCleanStageEnum.PARTIAL : AiCleanStageEnum.DONE;
             stage(batchId, finalStage);
+            // 终态收口(全 SSE):统计 → 状态快照 → 治理宣告;纯 AI/无既有清洗的批次
+            // 没有 doClean 的完成回调,前端的「完成并跳转统计页」完全依赖这一推
+            pushTerminal(caseId, batchId);
             log.info("智能清洗批次完成: batchId={}, 阶段={}, 判定={} 条, 交既有清洗={} 条, AI 入库={} 条, "
                             + "待人工={} 条, 失败={} 条, 耗时={}ms",
                     batchId, finalStage, pending.size(), toExisting, st.aiLoad.get(),
@@ -291,8 +383,9 @@ public class AiSmartCleanService {
      * 2级(AI 命中既有模板的表)。两级各自的根树只放本级的 sheet ——
      * NEED_REVIEW / Skip 的表不进任何一趟,doClean 不再为它们记「未配置模板」噪音日志。
      *
-     * <p>首趟静默(不推终态、不关 SSE 会话、不触发同时治理),由第二趟收口;趟间用
-     * {@link DmService#doClean(DoCleanDTO, boolean)} 返回的 Latch 严格排队。
+     * <p>趟间用 {@link DmService#doClean(DoCleanDTO, boolean)} 返回的 Latch 严格排队。
+     * 两趟都静默:收口消息(统计/终态快照/治理宣告)由本类在 3级入库完成后统一推送
+     * (见 {@link #pushTerminal}),保证「治理启动」出现在全部清洗动作之后。
      *
      * @return 并入既有链路的 AI 命中表数(仅日志用)
      */
@@ -334,11 +427,10 @@ public class AiSmartCleanService {
         log.info("智能清洗:按降级顺序串行清洗 1级表={} 个 → 2级表={} 个",
                 l1ByRoot.values().stream().mapToInt(List::size).sum(),
                 l2ByRoot.values().stream().mapToInt(List::size).sum());
-        // 1级:若后面还有 2级那趟则静默(终态与关会话留给最后一趟收口),
-        // 只有 1级时就由它自己收口;2级:等 1级 Latch 归零后才开始
-        boolean l2HasWork = l2ByRoot.values().stream().anyMatch(l -> !l.isEmpty());
-        runCleanPass(roots, l1ByRoot, l2HasWork);
-        runCleanPass(roots, l2ByRoot, false);
+        // 两趟都静默:收口消息由本类在 3级入库完成后统一推送(run → pushTerminal),
+        // 保证「所有文件清洗完成 / 开始数据治理」出现在全部清洗动作之后
+        runCleanPass(roots, l1ByRoot, true);
+        runCleanPass(roots, l2ByRoot, true);
         return applied;
     }
 

+ 10 - 9
ai-server/src/main/java/com/zsjz/ai/module/dm/service/DmService.java

@@ -619,11 +619,10 @@ public class DmService {
                     }
                     int completedCount = dataNum - failNum;
                     if (silent) {
-                        // 静默趟:后面还有下一级清洗,终态消息/关会话/同时治理都留给最后一趟
-                        log.info("静默趟清洗完成(batchId={}),等待后续趟次", batchId);
-                        return;
-                    }
-                    if (governConfService.hasGovern()) {
+                        // 静默趟:收口消息(终态/关会话/同时治理)由智能清洗批次统一决定时机,
+                        // 但事实性清理(全文检索刷盘/临时文件)与统计照常执行
+                        log.info("静默趟清洗完成(batchId={}),收口消息留给智能清洗批次", batchId);
+                    } else if (governConfService.hasGovern()) {
                         log.info("已开启同时治理数据!");
                         sseService.sendSee(new SseDTO(101, slowDownProgress.calculateNextProgress(), "[数据清洗]所有文件清洗完成!", new SeeDataDTO(completedCount, failNum)));
                         sseService.sendSee(new SseDTO(102, slowDownProgress.calculateNextProgress(), "[数据治理]开始数据治理!", new SeeDataDTO(0, 0)));
@@ -669,11 +668,13 @@ public class DmService {
     /**
      * 执行数据清洗任务(可静默趟)。
      *
-     * <p>智能清洗按降级顺序 1级→2级 串行多趟复用本方法:前一趟静默 —— 任务级进度照常推送,
-     * 但完成时<b>不发</b>「所有文件清洗完成」终态、<b>不关</b> SSE 会话、<b>不触发</b>同时治理,
-     * 由最后一趟收口;调用方用返回的 Latch 等上一趟全部落库后再开下一趟。
+     * <p>智能清洗按降级顺序 1级→2级 串行多趟复用本方法,<b>各趟都静默</b>:
+     * 任务级进度消息照常推送、清理副作用(缓存刷新/人员库/全文检索/临时文件)照常执行,
+     * 但不发「所有文件清洗完成」终态、不关 SSE 会话、不宣告同时治理 ——
+     * 收口(统计/终态快照/治理宣告)由智能清洗批次在 3级入库完成后统一推送,
+     * 否则「治理启动」会早于 AI 自建表入库。调用方用返回的 Latch 排队各趟。
      *
-     * @param silent true=静默趟(不推终态/不关会话/不触发治理)
+     * @param silent true=静默趟(跳过用户可见的收口消息与关会话)
      * @return 全部清洗任务完成后计数归零的 Latch;无可清洗任务时返回已归零的 Latch
      */
     public CountDownLatch doClean(DoCleanDTO dto, boolean silent) {

+ 32 - 19
ai-server/src/test/java/com/zsjz/ai/module/aiclean/service/AiSmartCleanServiceTest.java

@@ -12,7 +12,11 @@ import com.zsjz.ai.module.aiclean.entity.AiCleanJob;
 import com.zsjz.ai.module.aiclean.enums.AiCleanStageEnum;
 import com.zsjz.ai.module.aiclean.enums.AiCleanStatusEnum;
 import com.zsjz.ai.module.aiclean.mapper.AiCleanBatchMapper;
+import com.zsjz.ai.module.aiclean.mapper.AiCleanJobMapper;
+import com.zsjz.ai.module.dm.mapper.FileInfoMapper;
 import com.zsjz.ai.module.dm.service.DmService;
+import com.zsjz.ai.module.govern.serivce.GovernConfService;
+import com.zsjz.ai.module.plat.service.SseService;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Test;
@@ -75,6 +79,10 @@ class AiSmartCleanServiceTest {
     private AiCleanBatchMapper batchMapper;
     private AiCleanService aiCleanService;
     private DmService dmService;
+    private SseService sseService;
+    private FileInfoMapper fileInfoMapper;
+    private AiCleanJobMapper jobMapper;
+    private GovernConfService governConfService;
     private AiSmartCleanService service;
 
     @BeforeEach
@@ -82,9 +90,17 @@ class AiSmartCleanServiceTest {
         batchMapper = mock(AiCleanBatchMapper.class);
         aiCleanService = mock(AiCleanService.class);
         dmService = mock(DmService.class);
+        sseService = mock(SseService.class);
+        fileInfoMapper = mock(FileInfoMapper.class);
+        jobMapper = mock(AiCleanJobMapper.class);
+        governConfService = mock(GovernConfService.class);
         // 按级串行清洗走 2 参 doClean(返回完成 Latch):测试里直接给已归零的 Latch
         when(dmService.doClean(any(DoCleanDTO.class), anyBoolean())).thenReturn(new CountDownLatch(0));
-        service = new AiSmartCleanService(batchMapper, aiCleanService, dmService);
+        service = newService(batchMapper, aiCleanService, dmService);
+    }
+
+    private AiSmartCleanService newService(AiCleanBatchMapper b, AiCleanService a, DmService d) {
+        return new AiSmartCleanService(b, a, d, sseService, fileInfoMapper, jobMapper, governConfService);
     }
 
     // ==================== 造数 ====================
@@ -178,20 +194,19 @@ class AiSmartCleanServiceTest {
 
         service.run(9L, 1L, List.of(rootA, rootB));
 
-        // 两趟:先 1级(静默,只含前端匹配的 s1-s3),再 2级(收口,只含 AI 命中的 s4/s5)
-        ArgumentCaptor<DoCleanDTO> l1 = ArgumentCaptor.forClass(DoCleanDTO.class);
-        ArgumentCaptor<DoCleanDTO> l2 = ArgumentCaptor.forClass(DoCleanDTO.class);
+        // 两趟:先 1级(只含前端匹配的 s1-s3),再 2级(只含 AI 命中的 s4/s5);都静默,
+        // 收口消息(统计/终态/治理宣告)由 AiSmartCleanService 在 3级入库后统一推
+        ArgumentCaptor<DoCleanDTO> passes = ArgumentCaptor.forClass(DoCleanDTO.class);
         InOrder inOrder = inOrder(dmService);
-        inOrder.verify(dmService).doClean(l1.capture(), eq(true));
-        inOrder.verify(dmService).doClean(l2.capture(), eq(false));
-        verify(dmService, times(2)).doClean(any(DoCleanDTO.class), anyBoolean());
+        inOrder.verify(dmService, times(2)).doClean(passes.capture(), eq(true));
+        List<DoCleanDTO> twoPasses = passes.getAllValues();
 
-        List<FileInfo> l1Sheets = l1.getValue().getFileInfos().stream()
+        List<FileInfo> l1Sheets = twoPasses.get(0).getFileInfos().stream()
                 .flatMap(r -> r.getChildren().stream()).toList();
         assertEquals(3, l1Sheets.size(), "1级趟只洗前端已匹配的表");
         assertTrue(l1Sheets.stream().allMatch(s -> s.getTemplateId() != null));
 
-        List<FileInfo> l2Sheets = l2.getValue().getFileInfos().stream()
+        List<FileInfo> l2Sheets = twoPasses.get(1).getFileInfos().stream()
                 .flatMap(r -> r.getChildren().stream()).toList();
         assertEquals(2, l2Sheets.size(), "2级趟只洗 AI 命中的表");
         assertTrue(l2Sheets.stream().allMatch(s -> s.getTemplateId() != null));
@@ -213,8 +228,7 @@ class AiSmartCleanServiceTest {
         // 时序:三条判定全归零 → 1级 → 2级 → 才允许 3级入库
         InOrder order = inOrder(aiCleanService, dmService);
         order.verify(aiCleanService, times(3)).judge(any(AiCleanJob.class));
-        order.verify(dmService).doClean(any(DoCleanDTO.class), eq(true));
-        order.verify(dmService).doClean(any(DoCleanDTO.class), eq(false));
+        order.verify(dmService, times(2)).doClean(any(DoCleanDTO.class), eq(true));
         order.verify(aiCleanService).loadViaAiTemplate(any(), eq(rootB), eq(s6), any(AiJudgeResult.AiPlan.class));
         assertEquals(List.of(AiCleanStageEnum.JUDGING, AiCleanStageEnum.CLEANING_EXISTING,
                 AiCleanStageEnum.LOADING_AI, AiCleanStageEnum.DONE), stages());
@@ -250,15 +264,15 @@ class AiSmartCleanServiceTest {
     }
 
     @Test
-    @DisplayName("只有 1级表(AI 一条都不接)→ 只发一趟非静默 doClean,由它收口会话")
+    @DisplayName("只有 1级表(AI 一条都不接)→ 只发一趟静默 doClean,收口消息由 SSE 统一推")
     void allExistingStillOneDoCleanAndNoJudge() {
         FileInfo s1 = sheet(1, "s1", 11), s2 = sheet(2, "s2", 12);
         given(Map.of());
 
         service.run(9L, 1L, List.of(file(100L, 9L, s1, s2)));
 
-        verify(dmService, times(1)).doClean(any(DoCleanDTO.class), eq(false));
-        verify(dmService, never()).doClean(any(DoCleanDTO.class), eq(true));
+        verify(dmService, times(1)).doClean(any(DoCleanDTO.class), eq(true));
+        verify(dmService, never()).doClean(any(DoCleanDTO.class), eq(false));
         verify(aiCleanService, never()).prepareJob(anyLong(), any(), any());
         verify(aiCleanService, never()).judge(any(AiCleanJob.class));
     }
@@ -315,12 +329,12 @@ class AiSmartCleanServiceTest {
     // ==================== ④ 重复受理 ====================
 
     /** 把「起后台线程」换成「只记不跑」,受理阶段才能被断言 */
-    private static class CapturingService extends AiSmartCleanService {
+    private class CapturingService extends AiSmartCleanService {
         private Runnable body;
         private int starts;
 
         CapturingService(AiCleanBatchMapper b, AiCleanService a, DmService d) {
-            super(b, a, d);
+            super(b, a, d, sseService, fileInfoMapper, jobMapper, governConfService);
         }
 
         @Override
@@ -374,9 +388,8 @@ class AiSmartCleanServiceTest {
         verify(batchMapper).insert(any(AiCleanBatch.class));
 
         s.body.run();   // 同步跑后台那一轮
-        // 1级(s1)静默一趟 → 2级(s4)收口一趟;待人工的 s2 两趟都不进
-        verify(dmService, times(1)).doClean(any(DoCleanDTO.class), eq(true));
-        verify(dmService, times(1)).doClean(any(DoCleanDTO.class), eq(false));
+        // 1级(s1) → 2级(s4) 两趟静默 doClean;待人工的 s2 两趟都不进
+        verify(dmService, times(2)).doClean(any(DoCleanDTO.class), eq(true));
         verify(aiCleanService, times(2)).judge(any(AiCleanJob.class));
     }
 

+ 27 - 0
smart-clean-test-cases.md

@@ -153,3 +153,30 @@
   failNum=0 数据完整入库)——既有 34 策略的函数配置问题,非本轮引入,建议后续核查对方号码列的清洗函数装配。
 - 治理任务全量跑一遍(验证 `truncate govern_tree` 重建后 AI 节点补挂的实测闭环)未执行,由代码走查
   `GovernService:380` 与 calcAiTree 幂等性保障;日常治理后可观察树上 AI 表节点确认。
+
+---
+
+## 完成跳转改造:进度全 SSE 推送,去轮询(2026-10-11 下午)
+
+### 问题
+清洗完成后没有跳转到结果统计页。根因:完成判定 `isComplete` 只看 SSE 清洗进度 `progress`,
+而**纯 AI/4级批次没有 doClean,SSE 进度永远不会到 100%**(进度环显示的 100% 是另一个合成值);
+此前靠「查看导入结果」按钮兜底,按钮移除后即卡死在「任务执行中」。
+
+### 改造(按需求「不要轮询,全部 SSE 推送」)
+- **后端**:`AiSmartCleanService` 每判定一张表、每个阶段切换、每张 AI 表入库、批次终态都推送
+  状态快照(cmd 103,`SeeDataDTO.payload` 携带 `SmartCleanStatusVO`);终态推送顺序为
+  统计(cmd 101,100%)→ 快照(cmd 103)→ 治理宣告(cmd 102,开启治理时),
+  保证治理启动出现在全部清洗动作之后。`DmService` 各趟 doClean 全静默(收口消息统一由终态推送发出,
+  清理副作用照常执行)。
+- **前端**:删除 2 秒轮询;cmd 103 驱动进度环/日志/完成判定;打开页面(含刷新恢复)
+  仅做**一次性**状态同步对齐初始值;SSE 断连且批次未终态时给出「改为手动清洗」出口。
+- 修复过程中发现并修复:完成判定/自动跳转 watch 在 setup 早期急切求值 `aiStageFinished`(TDZ)导致页面白屏。
+
+### 验证(浏览器实测,无轮询)
+- **纯 AI 批次**(D-人员信息含噪声列):提交后进度页 SSE 推进 → 判定完成约 130s →
+  页面**未刷新**自动跳转到 `/data/index?tab=files&mode=smart`,列表显示「导入成功 16 条」。✅
+- **刷新恢复**:停留在已完成的进度页刷新 → 一次性同步拿到终态 → 自动跳转。✅
+- **秒完批次**(C+E3,均为头部指纹直通复用 AI 模板):提交 → 进度页 → 自动跳回统计页。✅
+- **指纹复用跨批次**:C 与 G 同表头,G 批次建的 AI 模板被 C 秒级复用(HEADER_CACHE,零模型调用)。✅
+- 回归:aiclean 模块 76 个单测全绿(静默趟断言更新为「两趟全静默」)。