Pārlūkot izejas kodu

feat(clean): 统一清洗进度推送消息的格式与流程

- 消息统一为「[阶段] 动作 · 对象」,对象带文件名(去雪花 ID 前缀)与 sheet 名;
  阶段只用 [清洗]/[入库]/[治理],一条日志能独立看懂在做什么
- 新增 SSE cmd 104 纯日志通道:只追加到处理日志,不参与进度环与终态判定
- AI 清洗逐 sheet 推送:开始处理(文件/sheet 数)、判定结果(命中既有/AI 模板、
  命中来源与置信度、未匹配将新建表、需人工确认/跳过/失败)、开始入库、写入行数
- 既有模板清洗消息补齐进度与统计:完成 i/N、合并人员库/创建对象、
  全部完成(成功行数/失败行数)
- 前端去掉自己补的「清洗入库完成」,终态只保留后端一条汇总消息
cc 6 dienas atpakaļ
vecāks
revīzija
03fbc4b778

+ 9 - 9
ai-electron/frontend/src/case/views/data/cleanProgress.vue

@@ -745,14 +745,15 @@
       ) {
         return;
       }
-      // 任何任务事件(清洗 101 / 治理 102 / AI 状态 103)都算「任务在推进」:兜底看门狗据此续期;
-      // 999 心跳不算,避免 SSE 还连着但任务早已停摆时被无限续期
-      if (payload.cmd === 101 || payload.cmd === 102 || payload.cmd === 103) {
+      // 任何任务事件(清洗 101 / 治理 102 / AI 状态 103 / 清洗日志 104)都算「任务在推进」:
+      // 兜底看门狗据此续期;999 心跳不算,避免 SSE 还连着但任务早已停摆时被无限续期
+      if (payload.cmd === 101 || payload.cmd === 102 || payload.cmd === 103 || payload.cmd === 104) {
         lastTaskActivityAt = Date.now();
       }
       const value = normalizeSseProcess(payload.process);
-      // 清洗 / 治理消息原样进任务日志;AI 状态(103)由 AI 卡片单独展示,不重复刷日志
-      if (payload.cmd === 101 || payload.cmd === 102) {
+      // 清洗 / 治理 / 清洗日志(104)原样进任务日志;AI 状态(103)由 AI 卡片单独展示,不重复刷日志。
+      // 104 只追加日志,不参与进度与终态判定(后端逐 sheet 推「识别/入库」流水账)。
+      if (payload.cmd === 101 || payload.cmd === 102 || payload.cmd === 104) {
         const logText = resolveTaskLogText(payload);
         if (logText) addTaskLog(logText);
       }
@@ -770,9 +771,8 @@
         //   void handleCleanFinished();
         // }
         if (value >= 100) {
-          if (!cleanTaskFinished.value) {
-            addTaskLog('[数据清洗]清洗入库完成');
-          }
+          // 终态文案由后端统一推送(「[清洗] 全部完成:成功 N 行,失败 M 行」),
+          // 这里不再补一条重复的「清洗入库完成」
           cleanTaskFinished.value = true;
         }
       } else if (payload.cmd === 102) {
@@ -805,8 +805,8 @@
         if (vo && vo.stage) {
           applyAiStage(vo);
           if (aiStageFinished.value && !cleanTaskFinished.value) {
+            // 终态文案由后端 cmd 101 统一推送,这里只落状态,不重复写日志
             cleanTaskFinished.value = true;
-            addTaskLog('[数据清洗]清洗入库完成');
           }
         }
       } else {

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

@@ -3,7 +3,8 @@ package com.zsjz.ai.common.model.plat.dto;
 /**
  * sse消息内容
  *
- * @param cmd     指令 999:心跳, 101:提取 ,102: 治理
+ * @param cmd     指令 999:心跳, 101:提取进度,102:治理,103:AI 清洗状态快照,
+ *                104:清洗日志(只追加到处理日志,不参与进度/统计)
  * @param message 状态信息
  * @param process 进度信息
  * @param caseId  产生该进度的案件;前端据此丢弃其他案件的进度帧

+ 83 - 5
ai-server/src/main/java/com/zsjz/ai/module/aiclean/service/AiSmartCleanService.java

@@ -190,6 +190,60 @@ public class AiSmartCleanService {
         }
     }
 
+    /**
+     * 推一条「清洗日志」(cmd 104):只做流水账,不携带进度/统计,前端直接追加到处理日志。
+     *
+     * <p>与 cmd 101(进度)/103(状态快照)分开:日志行不参与进度环与终态判定,
+     * 避免「一行日志把进度跳到 0」这类副作用。消息统一按
+     * {@code [阶段] 动作 · 对象} 组织,对象带上文件名与 sheet 名,保证一条日志能独立看懂。
+     */
+    private void pushLog(String message) {
+        try {
+            Long userId = CaseContextHolder.getUserId();
+            if (userId == null) {
+                return;
+            }
+            sseService.sendSee(userId, new SseDTO(104, 0, message, new SeeDataDTO(0, 0)));
+        } catch (Exception e) {
+            log.debug("清洗日志推送失败: {}", e.getMessage());
+        }
+    }
+
+    /** 文件名显示口径:去掉落盘时的雪花 ID 前缀(与 AiCleanService 的建表命名规则一致) */
+    private static String fileLabel(FileInfo root) {
+        String name = root == null ? null : root.getFileName();
+        return StrUtil.nullToEmpty(name).replaceFirst("^\\d{15,}_", "");
+    }
+
+    /** 日志里的对象标签:`「文件名」·sheet 名`,便于把并发判定的日志与具体表对上 */
+    private static String sheetLabel(PendingAi p) {
+        String file = fileLabel(p.root());
+        String sheetName = p.sheet() == null ? null : p.sheet().getSheetName();
+        if (StrUtil.isBlank(file)) {
+            return StrUtil.isBlank(sheetName) ? "" : "「" + sheetName + "」";
+        }
+        return StrUtil.isBlank(sheetName) ? "「" + file + "」" : "「" + file + "」·" + sheetName;
+    }
+
+    /** 命中途径的一句话说明:同表头指纹 / 表头打分 / 模型判定(带自评置信度) */
+    private static String matchReason(AiCleanJob job) {
+        String by = StrUtil.nullToEmpty(job.getMatchedBy());
+        String how = switch (by) {
+            case "HEADER_CACHE" -> "同表头指纹";
+            case "SCORE" -> "表头打分";
+            case "LLM" -> "模型判定";
+            case "SELF_HEADER" -> "按表头自建";
+            default -> null;
+        };
+        if (how == null) {
+            return "";
+        }
+        java.math.BigDecimal conf = job.getConfidence();
+        return conf != null && "模型判定".equals(how)
+                ? "(" + how + " " + conf.setScale(2, java.math.RoundingMode.HALF_UP).toPlainString() + ")"
+                : "(" + how + ")";
+    }
+
     /**
      * 批次终态的 SSE 收口(口径与 {@code DmService.doClean} 完成时一致):
      * 统计(cmd 101,100%)→ 状态快照(cmd 103)→ 治理启动宣告(cmd 102,开启治理时)。
@@ -279,7 +333,8 @@ public class AiSmartCleanService {
             long failed = Math.max(0, existingFailed + aiRejectedRows);
             long unprocessed = Math.max(0, aiUnprocessedRows);
             log.info("AI 清洗行数统计: batchId={}, 成功={}, 失败={}, 未处理={}", batchId, completed, failed, unprocessed);
-            sseService.sendSee(userId, new SseDTO(101, 100, "[数据清洗]所有文件清洗完成!",
+            sseService.sendSee(userId, new SseDTO(101, 100,
+                    "[清洗] 全部完成:成功 " + completed + " 行,失败 " + failed + " 行",
                     new SeeDataDTO((int) Math.min(completed, Integer.MAX_VALUE),
                             (int) Math.min(failed, Integer.MAX_VALUE))));
             SmartCleanStatusVO vo = status(caseId, batchId);
@@ -306,6 +361,9 @@ public class AiSmartCleanService {
         Stats st = new Stats();
         try {
             stage(batchId, AiCleanStageEnum.JUDGING);
+            int batchSheetTotal = roots.stream()
+                    .mapToInt(r -> r.getChildren() == null ? 0 : r.getChildren().size()).sum();
+            pushLog("[清洗] 开始处理:" + roots.size() + " 个文件 / " + batchSheetTotal + " 个 sheet");
             // (root, sheetNode, job) 三元组:判定阶段只收集,不动数据
             List<PendingAi> pending = new ArrayList<>();
             for (FileInfo root : roots) {
@@ -381,6 +439,10 @@ public class AiSmartCleanService {
 
             stage(batchId, AiCleanStageEnum.LOADING_AI);
             pushProgress(caseId, batchId);
+            int aiPlanCount = (int) pending.stream().filter(p -> plans.containsKey(p.job().getId())).count();
+            if (aiPlanCount > 0) {
+                pushLog("[入库] 开始 AI 动态表入库:" + aiPlanCount + " 个 sheet");
+            }
             for (PendingAi p : pending) {
                 AiJudgeResult.AiPlan plan = plans.get(p.job().getId());
                 if (plan == null) {
@@ -438,8 +500,14 @@ public class AiSmartCleanService {
                 p.job().setExitCode(com.zsjz.ai.module.aiclean.enums.AiCleanExitEnum.A.name());
                 aiCleanService.finish(p.job(), AiCleanStatusEnum.DISPATCHED_A, null, System.currentTimeMillis());
                 bump(batchId, "existingHit");
+                pushLog(sheetLabel(p) + " → 命中既有模板「" + hit.nameCn() + "」,转入既有清洗");
+            }
+            case AiJudgeResult.AiPlan plan -> {
+                plans.put(p.job().getId(), plan);
+                String target = plan.candidate() == null ? "未匹配到模板,按表头新建表"
+                        : "命中模板「" + plan.candidate().nameCn() + "」" + matchReason(p.job()) + ",准备入库";
+                pushLog(sheetLabel(p) + " → " + target);
             }
-            case AiJudgeResult.AiPlan plan -> plans.put(p.job().getId(), plan);
             case AiJudgeResult.NeedReview r -> {
                 aiCleanService.finish(p.job(), AiCleanStatusEnum.NEED_REVIEW, r.reason(),
                         System.currentTimeMillis());
@@ -447,6 +515,7 @@ public class AiSmartCleanService {
                 st.needReview.incrementAndGet();
                 bump(batchId, "needReview");
                 bumpRows(batchId, 0, 0, nz(p.sheet().getDataNum()));
+                pushLog(sheetLabel(p) + " → 需人工确认:" + StrUtil.maxLength(r.reason(), 120));
             }
             case AiJudgeResult.Skip r -> {
                 aiCleanService.finish(p.job(), AiCleanStatusEnum.SKIP, r.reason(), System.currentTimeMillis());
@@ -456,6 +525,7 @@ public class AiSmartCleanService {
                 st.needReview.incrementAndGet();
                 bump(batchId, "needReview");
                 bumpRows(batchId, 0, 0, nz(p.sheet().getDataNum()));
+                pushLog(sheetLabel(p) + " → 已跳过:" + StrUtil.maxLength(r.reason(), 120));
             }
             case AiJudgeResult.Failed r -> fail(batchId, p, r.reason(), st);
             case null -> fail(batchId, p, "AI 判定无结论", st);
@@ -470,6 +540,7 @@ public class AiSmartCleanService {
         st.failed.incrementAndGet();
         bump(batchId, "failed");
         bumpRows(batchId, 0, 0, nz(p.sheet().getDataNum()));
+        pushLog(sheetLabel(p) + " → 失败:" + StrUtil.maxLength(reason, 120));
     }
 
     /**
@@ -518,9 +589,12 @@ public class AiSmartCleanService {
                 applied++;
             }
         }
-        log.info("智能清洗:按降级顺序串行清洗 1级表={} 个 → 2级表={} 个",
-                l1ByRoot.values().stream().mapToInt(List::size).sum(),
-                l2ByRoot.values().stream().mapToInt(List::size).sum());
+        int l1Count = l1ByRoot.values().stream().mapToInt(List::size).sum();
+        int l2Count = l2ByRoot.values().stream().mapToInt(List::size).sum();
+        log.info("智能清洗:按降级顺序串行清洗 1级表={} 个 → 2级表={} 个", l1Count, l2Count);
+        if (l1Count + l2Count > 0) {
+            pushLog("[清洗] 开始既有模板清洗:" + l1Count + " 个已匹配 sheet + " + l2Count + " 个 AI 命中 sheet");
+        }
         // 两趟都静默:收口消息由本类在 3级入库完成后统一推送(run → pushTerminal),
         // 保证「所有文件清洗完成 / 开始数据治理」出现在全部清洗动作之后
         runCleanPass(roots, l1ByRoot, true);
@@ -565,6 +639,7 @@ public class AiSmartCleanService {
 
     private void loadPlan(Long batchId, PendingAi p, AiJudgeResult.AiPlan plan, Stats st) {
         long start = System.currentTimeMillis();
+        String target = plan.candidate() == null ? "新建表" : "表「" + plan.candidate().nameCn() + "」";
         try {
             long written = aiCleanService.loadViaAiTemplate(p.job(), p.root(), p.sheet(), plan);
             if (written < 0) {
@@ -572,6 +647,7 @@ public class AiSmartCleanService {
                 st.failed.incrementAndGet();
                 bump(batchId, "failed");
                 bumpRows(batchId, 0, nz(p.job().getRowRejected()), nz(p.job().getRowUnprocessed()));
+                pushLog(sheetLabel(p) + " → 入库失败:" + target);
                 return;
             }
             aiCleanService.finish(p.job(), AiCleanStatusEnum.SUCCESS, null, start);
@@ -581,12 +657,14 @@ public class AiSmartCleanService {
                 bump(batchId, "selfBuilt");
             }
             bumpRows(batchId, nz(p.job().getRowLoaded()), nz(p.job().getRowRejected()), 0);
+            pushLog(sheetLabel(p) + " → 已写入 " + written + " 行(" + target + ")");
         } catch (Exception e) {
             log.error("AI 入库异常: jobId={}", p.job().getId(), e);
             aiCleanService.finish(p.job(), AiCleanStatusEnum.FAIL, StrUtil.maxLength(e.getMessage(), 900), start);
             st.failed.incrementAndGet();
             bump(batchId, "failed");
             bumpRows(batchId, 0, nz(p.job().getRowRejected()), nz(p.job().getRowUnprocessed()));
+            pushLog(sheetLabel(p) + " → 入库异常:" + StrUtil.maxLength(e.getMessage(), 120));
         }
     }
 

+ 23 - 15
ai-server/src/main/java/com/zsjz/ai/module/dm/service/DmService.java

@@ -620,9 +620,9 @@ public class DmService {
                     // 按 caseId 原子取走本次清洗缓冲后再落库:
                     // 缓冲已按案件分桶,不会把其他用户案件累积的人员冲进当前案件库
                     CleanCache.CleanSnapshot snapshot = CleanCache.drain();
-                    sseService.sendSee(new SseDTO(101, slowDownProgress.calculateNextProgress(), "[数据清洗]正在刷新人员库!", new SeeDataDTO(0, 0)));
+                    sseService.sendSee(new SseDTO(101, slowDownProgress.calculateNextProgress(), "[清洗] 正在合并人员库", new SeeDataDTO(0, 0)));
                     personLibNoService.flushPerLib(snapshot.personLibNos());
-                    sseService.sendSee(new SseDTO(101, slowDownProgress.calculateNextProgress(), "[数据清洗]正在创建对象!", new SeeDataDTO(0, 0)));
+                    sseService.sendSee(new SseDTO(101, slowDownProgress.calculateNextProgress(), "[清洗] 正在创建对象", new SeeDataDTO(0, 0)));
                     personBasicInfoService.flushPerson(snapshot.persons());
                     List<FileInfo> fileInfoList = fileInfoMapper.selectList(Wrappers.lambdaQuery(FileInfo.class)
                             .eq(FileInfo::getBatchId, batchId)
@@ -653,11 +653,15 @@ public class DmService {
                         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)));
+                        sseService.sendSee(new SseDTO(101, slowDownProgress.calculateNextProgress(),
+                                "[清洗] 全部完成:成功 " + completedCount + " 行,失败 " + failNum + " 行",
+                                new SeeDataDTO(completedCount, failNum)));
+                        sseService.sendSee(new SseDTO(102, slowDownProgress.calculateNextProgress(), "[治理] 开始数据治理", new SeeDataDTO(0, 0)));
                     } else {
                         log.info("未开启同时治理数据!");
-                        sseService.sendSee(new SseDTO(101, 100, "[数据清洗]所有文件清洗完成!", new SeeDataDTO(completedCount, failNum)));
+                        sseService.sendSee(new SseDTO(101, 100,
+                                "[清洗] 全部完成:成功 " + completedCount + " 行,失败 " + failNum + " 行",
+                                new SeeDataDTO(completedCount, failNum)));
                         sseService.closeSee();
                     }
                     // 这里必须显式绑定上下文:全文检索按 caseId 隔离,
@@ -709,14 +713,11 @@ public class DmService {
     public CountDownLatch doClean(DoCleanDTO dto, boolean silent) {
         CountDownLatch latch = new CountDownLatch(1);
         SlowDownProgress slowDownProgress = new SlowDownProgress();
-        if (!silent) {
-            sseService.sendSee(new SseDTO(101, slowDownProgress.calculateNextProgress(), "[数据清洗]开始读取文件!", new SeeDataDTO(0, 0)));
-        }
         List<CleanJob> cleanJobs = buildCleanJobs(dto.getFileInfos());
         if (CollUtil.isEmpty(cleanJobs)) {
             log.info("未找到根节点文件,无需清洗");
             if (!silent) {
-                sseService.sendSee(new SseDTO(101, 100, "[数据清洗]无有效文件(仅支持:csv、xls、xlsx )!", new SeeDataDTO(0, 0)));
+                sseService.sendSee(new SseDTO(101, 100, "[清洗] 没有可清洗的文件(仅支持 csv / xls / xlsx)", new SeeDataDTO(0, 0)));
                 sseService.closeSee();
             }
             return new CountDownLatch(0);
@@ -729,8 +730,8 @@ public class DmService {
                 silent, latch);
 
         if (!silent) {
-            sseService.sendSee(new SseDTO(101, slowDownProgress.calculateNextProgress(), "[数据清洗]文件读取完成!", new SeeDataDTO(0, 0)));
-            sseService.sendSee(new SseDTO(101, slowDownProgress.calculateNextProgress(), "[数据清洗]开始数据清洗!", new SeeDataDTO(0, 0)));
+            sseService.sendSee(new SseDTO(101, slowDownProgress.calculateNextProgress(),
+                    "[清洗] 开始清洗 " + totalTaskCount + " 个 sheet", new SeeDataDTO(0, 0)));
         }
         log.info("启动后台数据清洗任务,总数: {}, 并发度: {}, silent: {}", totalTaskCount, maxConcurrency, silent);
         //重新清洗:必须先删旧数据、后启动清洗线程。原先"先启后删"会让新清洗的写入
@@ -793,15 +794,22 @@ public class DmService {
             if (acquired) {
                 semaphore.release();
             }
+            int done = completedTaskCount.incrementAndGet();
+            String displayName = displayFileName(fileInfo.getFileName());
             String msg = switch (taskStatus) {
-                case 0 -> "[数据清洗]系统限流被跳过:" + fileInfo.getFileName();
-                case 2 -> "[数据清洗]清洗失败:" + fileInfo.getFileName();
-                default -> "[数据清洗]清洗完成:" + fileInfo.getFileName();
+                case 0 -> "[清洗] 跳过 " + done + "/" + totalTaskCount + "(系统限流):" + displayName;
+                case 2 -> "[清洗] 失败 " + done + "/" + totalTaskCount + ":" + displayName;
+                default -> "[清洗] 完成 " + done + "/" + totalTaskCount + ":" + displayName;
             };
-            callback.onComplete(totalTaskCount, completedTaskCount.incrementAndGet(), msg);
+            callback.onComplete(totalTaskCount, done, msg);
         }
     }
 
+    /** 文件名显示口径:去掉落盘时的雪花 ID 前缀,日志里只留用户看得懂的名字 */
+    private static String displayFileName(String fileName) {
+        return StrUtil.nullToEmpty(fileName).replaceFirst("^\\d{15,}_", "");
+    }
+
     private List<CleanJob> buildCleanJobs(List<FileInfo> fileInfos) {
         if (CollUtil.isEmpty(fileInfos)) {
             return List.of();