Browse Source

fix: 智能清洗4级降级修复与按级串行改造(浏览器E2E+76单测验证)

- 出口A落库断链(P0): ExistingHit 携带表头行号回填 lineNo,修复 AI 命中既有模板后 StreamingEtlListener 首行 NPE 零落库
- 完全匹配不到模板: 按需求改为按文件自身表头新建兜底动态表(全TEXT),4级只留给结构归一/置信不足等硬失败;needsStructure 提前判定
- 同表头指纹复用: 命中 AI 模板库时读回存档规则零模型调用,并入已有 ai_t 表,修复 uk_ai_clean_tpl_md5 唯一键冲突
- 按降级顺序串行(需求): 1级→Latch等待→2级→3级,首趟静默不关SSE会话;NEED_REVIEW表不再进 doClean(消除未配置模板噪音日志)
- 进度页移除「查看导入结果」按钮(需求)
- 新增 SelfHeaderPlanTest;测试数据文件入 public/__test__;测试用例文档新增4级降级专项验证章节
cc 3 hours ago
parent
commit
70046bc374

BIN
ai-electron/frontend/public/__test__/C-短信表头同义改写.xlsx


BIN
ai-electron/frontend/public/__test__/D-人员信息含噪声列.xlsx


BIN
ai-electron/frontend/public/__test__/E-星际航运清单.xlsx


BIN
ai-electron/frontend/public/__test__/E2-星际航运清单续.xlsx


BIN
ai-electron/frontend/public/__test__/E3-星际航运清单三.xlsx


BIN
ai-electron/frontend/public/__test__/F-合并单元格表头.xlsx


BIN
ai-electron/frontend/public/__test__/G-短信必填空值.xlsx


BIN
ai-electron/frontend/public/__test__/周海--车辆登记信息.xls


BIN
ai-electron/frontend/public/__test__/周海--通信18905810269.xls


+ 7 - 3
ai-server/src/main/java/com/zsjz/ai/module/aiclean/exec/ExistingTemplateDispatcher.java

@@ -50,8 +50,11 @@ public class ExistingTemplateDispatcher {
      * @param sheetIds    本次要清洗的 sheet 记录 id
      * @param templateId    命中的既有模板 id
      * @param rules        AI 生成的列绑定(含 fileColIndex 与 funList)
+     * @param headerRow    表头行号(1 基)。出口 A 的表上传时未匹配模板、line_no 为空,
+     *                     既有链路按它切表头/数据行,缺了在首行就 NPE
      */
-    public void dispatch(Long fileId, List<Long> sheetIds, Integer templateId, List<TableRuleDTO> rules) {
+    public void dispatch(Long fileId, List<Long> sheetIds, Integer templateId,
+                         List<TableRuleDTO> rules, Integer headerRow) {
         TableInfo tpl = GlobalCache.templateTableMap().get(templateId);
         if (tpl == null || tpl.getMainId() == null) {
             log.info("出口 A:模板 {} 不在缓存或无 main_id,放弃交既有链路", templateId);
@@ -70,6 +73,7 @@ public class ExistingTemplateDispatcher {
             sheet.setFuncRegx(tpl.getFuncRegx());
             sheet.setTableNameEn(tpl.getTableNameEn());
             sheet.setTableRuleList(rules);
+            sheet.setLineNo(headerRow);
         }
         root.setChildren(sheets);
 
@@ -77,8 +81,8 @@ public class ExistingTemplateDispatcher {
         dto.setFileInfos(List.of(root));
         // 与前端一致:非重清洗场景不动既有数据
         dto.setReClean(0);
-        log.info("出口 A:交既有链路清洗 fileId={}, templateId={}, sheets={}",
-                fileId, templateId, sheets.size());
+        log.info("出口 A:交既有链路清洗 fileId={}, templateId={}, headerRow={}, sheets={}",
+                fileId, templateId, headerRow, sheets.size());
         dmService.doClean(dto);
     }
 }

+ 108 - 39
ai-server/src/main/java/com/zsjz/ai/module/aiclean/service/AiCleanService.java

@@ -244,13 +244,30 @@ public class AiCleanService {
             job.setTokenOut(llm.outputTokens());
         }
         job.setLlmCalls(llmCalls);
-        if (outcome.candidate() == null) {
-            return new AiJudgeResult.NeedReview("没有合适的模板(含 AI 模板库)");
-        }
         if (needsStructure(ev)) {
-            // 见类注释:本版本不执行结构归一,硬洗会写进错位数据
+            // 见类注释:本版本不执行结构归一,硬洗会写进错位数据。
+            // 结构硬失败优先于「有没有候选」判定:合并单元格的表无论匹配到什么都洗不对
             return new AiJudgeResult.NeedReview("该表块需要结构归一(合并单元格/多级表头/整格多字段),暂交人工处理");
         }
+        if (outcome.candidate() == null) {
+            // 需求(2026-10-11):完全匹配不到任何模板(含 AI 模板库)时不再转人工,
+            // 按当前文件自身表头新建动态表兜底(全 TEXT、无清洗函数,列名即语义);
+            // 「无法清洗」只留给结构归一、置信度不过线等硬失败
+            List<RuleBind> selfBinds = selfHeaderBinds(headers);
+            if (selfBinds.isEmpty()) {
+                return new AiJudgeResult.NeedReview("表头为空,无法按文件自建表");
+            }
+            return new AiJudgeResult.AiPlan(ev, selfBinds, null, null, headers);
+        }
+        // 指纹/打分命中 AI 模板库的表:复用存档规则与已有动态表,零模型调用 ——
+        // header_md5 是唯一键,再建同指纹模板会撞 uk_ai_clean_tpl_md5(2026-10-11 实测)
+        if (!outcome.exit().writesExistingTable() && outcome.candidate().aiTemplateId() != null) {
+            List<RuleBind> stored = store.loadBinds(outcome.candidate().aiTemplateId());
+            if (!stored.isEmpty()) {
+                return new AiJudgeResult.AiPlan(ev, stored, outcome.candidate(), null, headers);
+            }
+            // 存档规则缺失(历史异常数据)→ 落回原路:按本次表头问模型补绑定
+        }
         // 选模板那次调用已经把列绑定一起返回了,复用它:单次模型调用实测就要几十秒,
         // 为了拿 binds 再打一次等于把延迟和 token 直接翻倍
         AiTemplateMatcher.LlmAnswer answer = outcome.draft() != null
@@ -296,14 +313,15 @@ public class AiCleanService {
                     StrUtil.format("置信度未达出口 {} 的放行线: {}", exit, report.confidence()));
         }
         if (exit.writesExistingTable()) {
-            // main_id / func_regex 都是 file_info 上的瞬态字段,既有链路靠它们选策略,
+            // main_id / func_regex / line_no 都是既有链路要用的字段(瞬态或上传时未落库),
             // 少带一个就会在 StreamingEtlListener 里 NPE 或 fromCode(null) 抛异常
             TableInfo hit = GlobalCache.templateTableMap().get(outcome.candidate().templateId());
             return new AiJudgeResult.ExistingHit(job.getSheetId(), outcome.candidate().templateId(),
                     hit == null ? outcome.candidate().mainId() : hit.getMainId(),
                     hit == null ? null : hit.getFuncRegx(),
                     AiRulePlan.forExisting(binds, outcome.candidate(), headers),
-                    outcome.candidate().nameCn());
+                    outcome.candidate().nameCn(),
+                    ev.primaryBlock().headerRow());
         }
         return new AiJudgeResult.AiPlan(ev, binds, outcome.candidate(), draft, headers);
     }
@@ -315,7 +333,8 @@ public class AiCleanService {
         return switch (result) {
             case AiJudgeResult.ExistingHit hit -> {
                 if (root != null) {
-                    dispatcher.dispatch(root.getId(), List.of(job.getSheetId()), hit.templateId(), hit.rules());
+                    dispatcher.dispatch(root.getId(), List.of(job.getSheetId()), hit.templateId(),
+                            hit.rules(), hit.headerRow());
                 }
                 job.setRefTemplateId(hit.templateId());
                 job.setExitCode(AiCleanExitEnum.A.name());
@@ -358,37 +377,58 @@ public class AiCleanService {
         List<RuleBind> binds = plan.binds();
         List<String> headers = plan.headers();
         AiRulePlan.Plan aiPlan = AiRulePlan.forAi(binds, headers, "ai_t");
-        // 模板 id 先行:物理表名依赖它;元数据登记挪到写数成功之后
-        AiCleanTemplate tpl = new AiCleanTemplate();
-        tpl.setId(IdUtil.getSnowflakeNextId());
-        tpl.setTableNameEn("ai_t" + tpl.getId());
-        tpl.setTableNameCn(StrUtil.blankToDefault(plan.draft().getReason(),
-                plan.candidate() == null ? "AI 识别表" : plan.candidate().nameCn()));
-        tpl.setHeaders(String.join(",", headers));
-        tpl.setHeaderMd5(ev.headerMd5());
-        tpl.setHeaderLineNo(ev.primaryBlock().headerRow());
-        tpl.setBlockNo(ev.primaryBlock().blockNo());
-        tpl.setCategory(FileCategoryEnum.parse(plan.draft().getCategory()).name());
-        tpl.setMatchTier(job.getMatchTier());
-        tpl.setVer(1);
-        tpl.setNeedsStruct(0);
-        tpl.setCaseIdSrc(job.getCaseId());
-        tpl.setConfidence(job.getConfidence());
-        tpl.setCreateTime(LocalDateTime.now());
+        // 同指纹复用:候选来自 AI 模板库时数据并入已有 ai_t 表,不再新建模板 ——
+        // header_md5 唯一键下重复登记会直接撞库(2026-10-11 E2 实测 FAIL)
+        AiCleanTemplate tpl = null;
+        boolean reuse = plan.candidate() != null && plan.candidate().aiTemplateId() != null;
+        if (reuse) {
+            tpl = templateMapper.selectById(plan.candidate().aiTemplateId());
+            reuse = tpl != null && "ONLINE".equals(tpl.getStatus());
+            if (!reuse) {
+                tpl = null;
+            }
+        }
         List<AiCleanRule> ruleRows = new ArrayList<>();
-        for (RuleBind b : binds) {
-            AiCleanRule r = new AiCleanRule();
-            r.setColIndex(b.getFileColIndex());
-            r.setFieldNameEn(b.getFieldNameEn());
-            r.setHints(AiCleanStore.hintsJson(b.getHints()));
-            r.setVer(1);
-            ruleRows.add(r);
+        if (reuse) {
+            log.info("AI 表指纹命中,复用已有动态表: table={}, templateId={}", tpl.getTableNameEn(), tpl.getId());
+        } else {
+            // 模板 id 先行:物理表名依赖它;元数据登记挪到写数成功之后
+            tpl = new AiCleanTemplate();
+            tpl.setId(IdUtil.getSnowflakeNextId());
+            tpl.setTableNameEn("ai_t" + tpl.getId());
+            // draft 可为空(按文件自身表头自建表的兜底路径没有模型草稿),命名逐级回落
+            String draftReason = plan.draft() == null ? null : plan.draft().getReason();
+            tpl.setTableNameCn(StrUtil.blankToDefault(draftReason,
+                    plan.candidate() == null ? "AI 识别表" : plan.candidate().nameCn()));
+            tpl.setHeaders(String.join(",", headers));
+            tpl.setHeaderMd5(ev.headerMd5());
+            tpl.setHeaderLineNo(ev.primaryBlock().headerRow());
+            tpl.setBlockNo(ev.primaryBlock().blockNo());
+            tpl.setCategory(FileCategoryEnum.parse(
+                    plan.draft() == null ? null : plan.draft().getCategory()).name());
+            tpl.setMatchTier(job.getMatchTier());
+            tpl.setVer(1);
+            tpl.setNeedsStruct(0);
+            tpl.setCaseIdSrc(job.getCaseId());
+            tpl.setConfidence(job.getConfidence());
+            tpl.setCreateTime(LocalDateTime.now());
+            for (RuleBind b : binds) {
+                AiCleanRule r = new AiCleanRule();
+                r.setColIndex(b.getFileColIndex());
+                r.setFieldNameEn(b.getFieldNameEn());
+                r.setHints(AiCleanStore.hintsJson(b.getHints()));
+                r.setVer(1);
+                ruleRows.add(r);
+            }
         }
-        String err = AiDuckDb.createAiTable(tpl.getTableNameEn(), aiPlan.fields());
+        String err = reuse ? null : AiDuckDb.createAiTable(tpl.getTableNameEn(), aiPlan.fields());
         if (err != null) {
             log.warn("AI 动态表建表失败,跳过写入: table={}, err={}", tpl.getTableNameEn(), err);
             return -1L;
         }
+        // lambda 要求事实最终变量:tpl/reuse 在上面按复用/新建二选一赋值后不再变
+        final AiCleanTemplate tplF = tpl;
+        final boolean reuseF = reuse;
         List<String> cols = aiPlan.fields().stream().map(AiCleanField::getColumnName).toList();
         long written;
         java.util.concurrent.atomic.AtomicLong total = new java.util.concurrent.atomic.AtomicLong();
@@ -409,8 +449,8 @@ public class AiCleanService {
                         // 旧实现用「清洗后序号 + 表头行」近似,sheet 里有空行时对不上号
                         cleaned.put("row_no", lineNo);
                         cleaned.put("ai_job_id", job.getId());
-                        cleaned.put("ai_rule_ver", tpl.getVer() == null ? 1 : tpl.getVer());
-                        cleaned.put("category", tpl.getCategory());
+                        cleaned.put("ai_rule_ver", tplF.getVer() == null ? 1 : tplF.getVer());
+                        cleaned.put("category", tplF.getCategory());
                         // 写失败会在这里抛出,中断流式读取 —— 半截数据绝不能被当成成功
                         writer.add(cleaned);
                         total.incrementAndGet();
@@ -422,13 +462,19 @@ public class AiCleanService {
         }
         if (written <= 0) {
             // 一行没落库不能算成功(含「读到 0 行」):全自动链路没有人看日志,
-            // 写成 0 行会被上层当成清洗完成。空表删掉,模板不登记 —— 不留幽灵
-            AiDuckDb.dropTable(tpl.getTableNameEn());
+            // 写成 0 行会被上层当成清洗完成。新建表删掉不留幽灵;复用表里还有
+            // 此前批次的数据,绝不能连带删
+            if (!reuse) {
+                AiDuckDb.dropTable(tpl.getTableNameEn());
+            }
             return -1L;
         }
-        // 数据已落盘,现在才登记元数据为 ONLINE 并刷新治理节点(写序收口)
-        tpl.setStatus("ONLINE");
-        store.register(tpl, aiPlan.fields(), ruleRows);
+        // 数据已落盘,现在才登记元数据为 ONLINE 并刷新治理节点(写序收口);
+        // 复用模板只刷计数,不重复登记
+        if (!reuse) {
+            tpl.setStatus("ONLINE");
+            store.register(tpl, aiPlan.fields(), ruleRows);
+        }
         store.refreshTree();
         job.setAiTemplateId(tpl.getId());
         job.setExitCode(AiCleanExitEnum.B.name());
@@ -475,6 +521,29 @@ public class AiCleanService {
         return ev.primaryMergeCount() > 0 || ev.primaryBlock().headerRow() > 1;
     }
 
+    /**
+     * 无候选时的兜底绑定:每个非空表头列自绑自身,全 TEXT、无清洗函数 —— 列名即语义。
+     *
+     * <p>需求(2026-10-11):AI 连 AI 模板库都匹配不上时,不再把整张表打入人工,
+     * 而是按当前文件/sheet 原样建表入库;等后续有人工或模型升级再晋升成正式模板。
+     */
+    static List<RuleBind> selfHeaderBinds(List<String> headers) {
+        List<RuleBind> out = new java.util.ArrayList<>();
+        for (int i = 0; i < headers.size(); i++) {
+            String h = headers.get(i);
+            if (StrUtil.isBlank(h)) {
+                continue;
+            }
+            RuleBind b = new RuleBind();
+            b.setFileColIndex(i);
+            b.setFieldNameEn(h.trim());
+            b.setHints(List.of());
+            b.setRequired(false);
+            out.add(b);
+        }
+        return out;
+    }
+
     private List<AiTemplateMatcher.AiTemplateWithFields> loadOnlineTemplates() {
         List<AiCleanTemplate> templates = templateMapper.selectList(Wrappers.<AiCleanTemplate>lambdaQuery()
                 .eq(AiCleanTemplate::getStatus, "ONLINE"));

+ 3 - 1
ai-server/src/main/java/com/zsjz/ai/module/aiclean/service/AiJudgeResult.java

@@ -32,9 +32,11 @@ public sealed interface AiJudgeResult {
      * @param funcRegex  表头外提姓名/卡号正则,与 {@code mainId} 一样是瞬态字段,必须一起传
      * @param rules      AI 生成的列绑定(含 {@code fileColIndex} 与函数链)
      * @param nameCn     模板中文名,仅用于日志与前端展示
+     * @param headerRow  表头行号(1 基)。出口 A 的表在上传时未匹配模板,file_info 里没有
+     *                   {@code line_no},而既有链路按它切「表头行/数据行」—— 不带就会在首行 NPE
      */
     record ExistingHit(Long sheetId, Integer templateId, Integer mainId, String funcRegex,
-                       List<TableRuleDTO> rules, String nameCn) implements AiJudgeResult {
+                       List<TableRuleDTO> rules, String nameCn, Integer headerRow) implements AiJudgeResult {
     }
 
     /**

+ 70 - 23
ai-server/src/main/java/com/zsjz/ai/module/aiclean/service/AiSmartCleanService.java

@@ -21,10 +21,12 @@ import org.springframework.stereotype.Service;
 
 import java.time.LocalDateTime;
 import java.util.ArrayList;
+import java.util.IdentityHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.Semaphore;
@@ -211,7 +213,10 @@ public class AiSmartCleanService {
 
             // 判定全部归零之后才能进清洗阶段,否则会漏掉 AI 命中的表
             stage(batchId, AiCleanStageEnum.CLEANING_EXISTING);
-            int toExisting = dispatchExisting(roots, existingHits);
+            // ★ 按降级顺序串行清洗(需求 2026-10-11):1级(上传已匹配模板)先洗并等它
+            // 全部落库,再洗 2级(AI 命中既有模板),然后才进 3级(AI 自建表入库)。
+            // 4级(无法清洗)在判定阶段就只标记、不产生清洗任务。
+            int toExisting = dispatchExistingByLevel(roots, existingHits);
 
             stage(batchId, AiCleanStageEnum.LOADING_AI);
             for (PendingAi p : pending) {
@@ -281,16 +286,32 @@ public class AiSmartCleanService {
         bump(batchId, "failed");
     }
 
-    /** @return 并入既有链路的 AI 命中表数(仅日志用) */
-    private int dispatchExisting(List<FileInfo> roots, List<AiJudgeResult.ExistingHit> hits) {
-        // 按 sheetId 把规则挂回前端传来的树,保证 children 结构与前端自己提交时一致
+    /**
+     * 按降级顺序串行执行既有链路清洗:先 1级(前端已匹配模板的表),等它全部落库后再
+     * 2级(AI 命中既有模板的表)。两级各自的根树只放本级的 sheet ——
+     * NEED_REVIEW / Skip 的表不进任何一趟,doClean 不再为它们记「未配置模板」噪音日志。
+     *
+     * <p>首趟静默(不推终态、不关 SSE 会话、不触发同时治理),由第二趟收口;趟间用
+     * {@link DmService#doClean(DoCleanDTO, boolean)} 返回的 Latch 严格排队。
+     *
+     * @return 并入既有链路的 AI 命中表数(仅日志用)
+     */
+    private int dispatchExistingByLevel(List<FileInfo> roots, List<AiJudgeResult.ExistingHit> hits) {
         int applied = 0;
+        // 先在原树上把 1级 / 2级 sheet 分组:templateId 非空的是前端已匹配(1级),
+        // 空的按命中结果补挂模板并归入 2级;都没命中的(3/4级)不进既有链路
+        Map<FileInfo, List<FileInfo>> l1ByRoot = new IdentityHashMap<>();
+        Map<FileInfo, List<FileInfo>> l2ByRoot = new IdentityHashMap<>();
         for (FileInfo root : roots) {
-            if (root.getChildren() == null) {
+            if (root == null || root.getChildren() == null) {
                 continue;
             }
             for (FileInfo sheet : root.getChildren()) {
-                if (sheet == null || sheet.getTemplateId() != null) {
+                if (sheet == null) {
+                    continue;
+                }
+                if (sheet.getTemplateId() != null) {
+                    l1ByRoot.computeIfAbsent(root, _ -> new ArrayList<>()).add(sheet);
                     continue;
                 }
                 AiJudgeResult.ExistingHit hit = hits.stream()
@@ -303,29 +324,55 @@ public class AiSmartCleanService {
                 sheet.setMainId(hit.mainId());
                 sheet.setFuncRegx(hit.funcRegex());
                 sheet.setTableRuleList(hit.rules());
+                // 出口 A 的表上传时未匹配模板,file_info 里没有表头行号;既有链路按它
+                // 切「表头行/数据行」,缺了首行就 NPE(2026-10-11 实测零落库事故)
+                sheet.setLineNo(hit.headerRow());
+                l2ByRoot.computeIfAbsent(root, _ -> new ArrayList<>()).add(sheet);
                 applied++;
             }
         }
-        if (!hasExistingWork(roots)) {
-            // 空批次不要发 doClean:buildCleanJobs 不看 templateId,仍会起一轮清洗并 closeSee,
-            // 白关一次会话还会让进度页收到「清洗完成」的假信号
-            log.info("智能清洗:没有可交既有链路的表(AI 命中 {} 个),跳过 doClean", applied);
-            return applied;
-        }
-        DoCleanDTO dto = new DoCleanDTO();
-        dto.setFileInfos(roots);
-        dto.setReClean(0);
-        log.info("智能清洗:合并 {} 个 AI 命中的表与既有匹配的表,doClean 只调一次", applied);
-        dmService.doClean(dto);
+        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);
         return applied;
     }
 
-    /** A 组是否有活要干:前端已匹配的、或 AI 刚补上 templateId 的 sheet */
-    private static boolean hasExistingWork(List<FileInfo> roots) {
-        return roots.stream()
-                .filter(r -> r != null && r.getChildren() != null)
-                .flatMap(r -> r.getChildren().stream())
-                .anyMatch(s -> s != null && s.getTemplateId() != null);
+    /** 把本级的 sheet 挂到根节点<b>浅拷贝</b>上发一趟既有清洗,并等它全部落库 */
+    private void runCleanPass(List<FileInfo> roots, Map<FileInfo, List<FileInfo>> sheetsByRoot, boolean silent) {
+        // 根节点用浅拷贝:不改动前端提交的原始树;两趟各持各的根,互不覆盖
+        // (sheet 对象本身仍是同一批引用,瞬态字段只在分组时写一次)
+        List<FileInfo> passRoots = new ArrayList<>();
+        for (FileInfo root : roots) {
+            if (root == null) {
+                continue;
+            }
+            List<FileInfo> sheets = sheetsByRoot.getOrDefault(root, List.of());
+            if (sheets.isEmpty()) {
+                continue;
+            }
+            FileInfo copy = new FileInfo();
+            cn.hutool.core.bean.BeanUtil.copyProperties(root, copy, "children");
+            copy.setChildren(sheets);
+            passRoots.add(copy);
+        }
+        if (passRoots.isEmpty()) {
+            return;
+        }
+        DoCleanDTO dto = new DoCleanDTO();
+        dto.setFileInfos(passRoots);
+        dto.setReClean(0);
+        CountDownLatch latch = dmService.doClean(dto, silent);
+        try {
+            latch.await();
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            log.warn("等待上一级清洗完成时被中断");
+        }
     }
 
     private void loadPlan(Long batchId, PendingAi p, AiJudgeResult.AiPlan plan, Stats st) {

+ 27 - 0
ai-server/src/main/java/com/zsjz/ai/module/aiclean/store/AiCleanStore.java

@@ -1,6 +1,8 @@
 package com.zsjz.ai.module.aiclean.store;
 
 import cn.hutool.core.util.IdUtil;
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
 import com.zsjz.ai.common.utils.Json;
 import com.zsjz.ai.module.aiclean.entity.AiCleanField;
 import com.zsjz.ai.module.aiclean.entity.AiCleanRule;
@@ -71,6 +73,31 @@ public class AiCleanStore {
         return tpl.getId();
     }
 
+    /**
+     * 读回一份 AI 模板的列绑定(同表头文件复用动态表时直接用存档规则,零模型调用)。
+     *
+     * <p>表头指纹按位置对齐,同 md5 即同列序 —— 存档的 {@code colIndex} 对新文件依然成立。
+     * 模板没有登记过规则(异常/历史数据)时返回空列表,调用方回退到模型补绑定。
+     */
+    public List<com.zsjz.ai.module.aiclean.model.RuleBind> loadBinds(Long aiTemplateId) {
+        List<AiCleanRule> rules = ruleMapper.selectList(
+                Wrappers.<AiCleanRule>lambdaQuery().eq(AiCleanRule::getAiTemplateId, aiTemplateId));
+        List<com.zsjz.ai.module.aiclean.model.RuleBind> out = new java.util.ArrayList<>();
+        for (AiCleanRule r : rules) {
+            if (r.getColIndex() == null || StrUtil.isBlank(r.getFieldNameEn())) {
+                continue;
+            }
+            com.zsjz.ai.module.aiclean.model.RuleBind b = new com.zsjz.ai.module.aiclean.model.RuleBind();
+            b.setFileColIndex(r.getColIndex());
+            b.setFieldNameEn(r.getFieldNameEn());
+            b.setHints(StrUtil.isBlank(r.getHints()) ? List.of() : Json.toCls(r.getHints(),
+                    new com.fasterxml.jackson.core.type.TypeReference<List<String>>() { }));
+            b.setRequired(false);
+            out.add(b);
+        }
+        return out;
+    }
+
     /**
      * 刷新 AI 治理节点:数据落库成功、模板登记 ONLINE 之后由入库链路调用 ——
      * 数据一落盘就补节点,用户不必等下一轮治理才能在树上看到。

+ 85 - 53
ai-server/src/main/java/com/zsjz/ai/module/dm/service/DmService.java

@@ -590,57 +590,67 @@ public class DmService {
                 .toList();
     }
 
-    private TaskCompleteCallback createDefaultCallback(SlowDownProgress slowDownProgress, Long batchId) {
+    private TaskCompleteCallback createDefaultCallback(SlowDownProgress slowDownProgress, Long batchId,
+                                                       boolean silent, CountDownLatch latch) {
         return (totalTaskCount, completedTaskCount, message) -> {
             log.info("任务进度:已完成{} / {}({}%)",
                     completedTaskCount, totalTaskCount, Math.round((double) completedTaskCount / totalTaskCount * 100));
             sseService.sendSee(new SseDTO(101, slowDownProgress.calculateNextProgress(), message, new SeeDataDTO(0, 0)));
             if (completedTaskCount == totalTaskCount) {
-                GlobalCache.initCache();
-                log.info("所有任务完成!");
-                log.info("执行刷新缓存数据入库!");
-                // 按 caseId 原子取走本次清洗缓冲后再落库:
-                // 缓冲已按案件分桶,不会把其他用户案件累积的人员冲进当前案件库
-                CleanCache.CleanSnapshot snapshot = CleanCache.drain();
-                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)));
-                personBasicInfoService.flushPerson(snapshot.persons());
-                List<FileInfo> fileInfoList = fileInfoMapper.selectList(Wrappers.lambdaQuery(FileInfo.class)
-                        .eq(FileInfo::getBatchId, batchId)
-                        .isNull(FileInfo::getFileType));
-                int dataNum = 0;
-                int failNum = 0;
-                for (FileInfo child : fileInfoList) {
-                    dataNum += Objects.requireNonNullElse(child.getDataNum(), 0);
-                    failNum += Objects.requireNonNullElse(child.getFailNum(), 0);
-                }
-                int completedCount = dataNum - failNum;
-                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)));
-                } else {
-                    log.info("未开启同时治理数据!");
-                    sseService.sendSee(new SseDTO(101, 100, "[数据清洗]所有文件清洗完成!", new SeeDataDTO(completedCount, failNum)));
-                    sseService.closeSee();
-                }
-                // ★ 这里必须显式绑定上下文:全文检索按 caseId 隔离,
-                // 裸起虚拟线程会让 flush() 抛「缺少案件上下文」。
-                Long mergeCaseId = CaseContextHolder.get();
-                Long mergeUserId = CaseContextHolder.getUserId();
-                Thread.startVirtualThread(() -> CaseContextHolder.runWith(mergeCaseId, mergeUserId, () -> {
-                    log.info("提交全文检索尾批并刷新索引");
-                    try {
-                        searchDocIndexService.flush();
-                    } catch (Exception e) {
-                        log.error("提交全文检索数据失败", e);
+                try {
+                    GlobalCache.initCache();
+                    log.info("所有任务完成!");
+                    log.info("执行刷新缓存数据入库!");
+                    // 按 caseId 原子取走本次清洗缓冲后再落库:
+                    // 缓冲已按案件分桶,不会把其他用户案件累积的人员冲进当前案件库
+                    CleanCache.CleanSnapshot snapshot = CleanCache.drain();
+                    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)));
+                    personBasicInfoService.flushPerson(snapshot.persons());
+                    List<FileInfo> fileInfoList = fileInfoMapper.selectList(Wrappers.lambdaQuery(FileInfo.class)
+                            .eq(FileInfo::getBatchId, batchId)
+                            .isNull(FileInfo::getFileType));
+                    int dataNum = 0;
+                    int failNum = 0;
+                    for (FileInfo child : fileInfoList) {
+                        dataNum += Objects.requireNonNullElse(child.getDataNum(), 0);
+                        failNum += Objects.requireNonNullElse(child.getFailNum(), 0);
+                    }
+                    int completedCount = dataNum - failNum;
+                    if (silent) {
+                        // 静默趟:后面还有下一级清洗,终态消息/关会话/同时治理都留给最后一趟
+                        log.info("静默趟清洗完成(batchId={}),等待后续趟次", batchId);
+                        return;
                     }
-                    //删除临时文件:只删本批次目录,绝不动全局 tmp 根——
-                    //根目录下还有其他用户/批次的在途文件,整树删除会把别人的上传与解析中间产物删掉
-                    log.info("删除临时文件");
-                    FileUtil.del(PathConst.TMP_PATH.resolve(String.valueOf(batchId)));
-                }));
+                    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)));
+                    } else {
+                        log.info("未开启同时治理数据!");
+                        sseService.sendSee(new SseDTO(101, 100, "[数据清洗]所有文件清洗完成!", new SeeDataDTO(completedCount, failNum)));
+                        sseService.closeSee();
+                    }
+                    // ★ 这里必须显式绑定上下文:全文检索按 caseId 隔离,
+                    // 裸起虚拟线程会让 flush() 抛「缺少案件上下文」。
+                    Long mergeCaseId = CaseContextHolder.get();
+                    Long mergeUserId = CaseContextHolder.getUserId();
+                    Thread.startVirtualThread(() -> CaseContextHolder.runWith(mergeCaseId, mergeUserId, () -> {
+                        log.info("提交全文检索尾批并刷新索引");
+                        try {
+                            searchDocIndexService.flush();
+                        } catch (Exception e) {
+                            log.error("提交全文检索数据失败", e);
+                        }
+                        //删除临时文件:只删本批次目录,绝不动全局 tmp 根——
+                        //根目录下还有其他用户/批次的在途文件,整树删除会把别人的上传与解析中间产物删掉
+                        log.info("删除临时文件");
+                        FileUtil.del(PathConst.TMP_PATH.resolve(String.valueOf(batchId)));
+                    }));
+                } finally {
+                    latch.countDown();
+                }
             }
         };
     }
@@ -653,24 +663,45 @@ public class DmService {
      * @param dto 数据清洗请求参数,包含文件信息列表、清洗规则、目标表等配置
      */
     public void doClean(DoCleanDTO dto) {
+        doClean(dto, false);
+    }
+
+    /**
+     * 执行数据清洗任务(可静默趟)。
+     *
+     * <p>智能清洗按降级顺序 1级→2级 串行多趟复用本方法:前一趟静默 —— 任务级进度照常推送,
+     * 但完成时<b>不发</b>「所有文件清洗完成」终态、<b>不关</b> SSE 会话、<b>不触发</b>同时治理,
+     * 由最后一趟收口;调用方用返回的 Latch 等上一趟全部落库后再开下一趟。
+     *
+     * @param silent true=静默趟(不推终态/不关会话/不触发治理)
+     * @return 全部清洗任务完成后计数归零的 Latch;无可清洗任务时返回已归零的 Latch
+     */
+    public CountDownLatch doClean(DoCleanDTO dto, boolean silent) {
+        CountDownLatch latch = new CountDownLatch(1);
         SlowDownProgress slowDownProgress = new SlowDownProgress();
-        sseService.sendSee(new SseDTO(101, slowDownProgress.calculateNextProgress(), "[数据清洗]开始读取文件!", new SeeDataDTO(0, 0)));
+        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("未找到根节点文件,无需清洗");
-            sseService.sendSee(new SseDTO(101, 100, "[数据清洗]无有效文件(仅支持:csv、xls、xlsx )!", new SeeDataDTO(0, 0)));
-            sseService.closeSee();
-            return;
+            if (!silent) {
+                sseService.sendSee(new SseDTO(101, 100, "[数据清洗]无有效文件(仅支持:csv、xls、xlsx )!", new SeeDataDTO(0, 0)));
+                sseService.closeSee();
+            }
+            return new CountDownLatch(0);
         }
         int totalTaskCount = cleanJobs.size();
         int maxConcurrency = Math.min(10, totalTaskCount);
         AtomicInteger completedTaskCount = new AtomicInteger(0);
 
-        TaskCompleteCallback callback = createDefaultCallback(slowDownProgress, cleanJobs.getFirst().fileInfo.getBatchId());
+        TaskCompleteCallback callback = createDefaultCallback(slowDownProgress, cleanJobs.getFirst().fileInfo.getBatchId(), silent, latch);
 
-        sseService.sendSee(new SseDTO(101, slowDownProgress.calculateNextProgress(), "[数据清洗]文件读取完成!", new SeeDataDTO(0, 0)));
-        sseService.sendSee(new SseDTO(101, slowDownProgress.calculateNextProgress(), "[数据清洗]开始数据清洗!", new SeeDataDTO(0, 0)));
-        log.info("启动后台数据清洗任务,总数: {}, 并发度: {}", totalTaskCount, maxConcurrency);
+        if (!silent) {
+            sseService.sendSee(new SseDTO(101, slowDownProgress.calculateNextProgress(), "[数据清洗]文件读取完成!", new SeeDataDTO(0, 0)));
+            sseService.sendSee(new SseDTO(101, slowDownProgress.calculateNextProgress(), "[数据清洗]开始数据清洗!", new SeeDataDTO(0, 0)));
+        }
+        log.info("启动后台数据清洗任务,总数: {}, 并发度: {}, silent: {}", totalTaskCount, maxConcurrency, silent);
         //重新清洗:必须先删旧数据、后启动清洗线程。原先"先启后删"会让新清洗的写入
         //与删旧数据并发交错(存在删掉新写数据的窗口)
         if (dto.getReClean() != null && dto.getReClean() == 1 && dto.getFileId() != null) {
@@ -683,6 +714,7 @@ public class DmService {
         Long cleanUserId = CaseContextHolder.getUserId();
         Thread.startVirtualThread(() -> CaseContextHolder.runWith(cleanCaseId, cleanUserId,
                 () -> executeCleanJobs(cleanJobs, totalTaskCount, maxConcurrency, completedTaskCount, callback)));
+        return latch;
     }
 
     private void executeCleanJobs(List<CleanJob> cleanJobs, int totalTaskCount, int maxConcurrency,

+ 41 - 21
ai-server/src/test/java/com/zsjz/ai/module/aiclean/service/AiSmartCleanServiceTest.java

@@ -22,12 +22,14 @@ import org.mockito.InOrder;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.concurrent.CountDownLatch;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
 import static org.mockito.ArgumentMatchers.anyLong;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.ArgumentMatchers.isNull;
@@ -80,6 +82,8 @@ class AiSmartCleanServiceTest {
         batchMapper = mock(AiCleanBatchMapper.class);
         aiCleanService = mock(AiCleanService.class);
         dmService = mock(DmService.class);
+        // 按级串行清洗走 2 参 doClean(返回完成 Latch):测试里直接给已归零的 Latch
+        when(dmService.doClean(any(DoCleanDTO.class), anyBoolean())).thenReturn(new CountDownLatch(0));
         service = new AiSmartCleanService(batchMapper, aiCleanService, dmService);
     }
 
@@ -108,7 +112,7 @@ class AiSmartCleanServiceTest {
     private static AiJudgeResult.ExistingHit hit(long sheetId, int templateId) {
         TableRuleDTO rule = new TableRuleDTO();
         rule.setFieldNameEn("user_name");
-        return new AiJudgeResult.ExistingHit(sheetId, templateId, 7, "(姓名)", List.of(rule), "人员信息");
+        return new AiJudgeResult.ExistingHit(sheetId, templateId, 7, "(姓名)", List.of(rule), "人员信息", 2);
     }
 
     private static AiJudgeResult.AiPlan aiPlan() {
@@ -161,11 +165,11 @@ class AiSmartCleanServiceTest {
                 .toList();
     }
 
-    // ==================== ① 一次 doClean ====================
+    // ==================== ① 按级串行 doClean ====================
 
     @Test
-    @DisplayName("既有匹配的 + AI 命中既有模板的:合并成一次 doClean,且判定全部归零之后才发")
-    void mergesExistingAndAiHitsIntoSingleDoClean() {
+    @DisplayName("1级(前端已匹配)先洗并等落库,2级(AI 命中)后洗;3级不进既有链路")
+    void cleansByLevelInOrder() {
         FileInfo s1 = sheet(1, "s1", 11), s2 = sheet(2, "s2", 12), s3 = sheet(3, "s3", 13);
         FileInfo s4 = sheet(4, "s4", null), s5 = sheet(5, "s5", null), s6 = sheet(6, "s6", null);
         FileInfo rootA = file(100L, 9L, s1, s2, s3);
@@ -174,18 +178,29 @@ class AiSmartCleanServiceTest {
 
         service.run(9L, 1L, List.of(rootA, rootB));
 
-        ArgumentCaptor<DoCleanDTO> dto = ArgumentCaptor.forClass(DoCleanDTO.class);
-        verify(dmService, times(1)).doClean(dto.capture());
-        List<FileInfo> roots = dto.getValue().getFileInfos();
-        assertEquals(2, roots.size(), "整棵树原样交给既有链路,前端报文结构不变");
-        long toExisting = roots.stream().flatMap(r -> r.getChildren().stream())
-                .filter(f -> f.getTemplateId() != null).count();
-        assertEquals(5, toExisting, "3 个原本匹配的 + 2 个 AI 命中的,共 5 个进既有清洗");
-
-        // AI 命中的节点要带齐规则与瞬态字段,否则既有策略要么走空规则,要么从 code 反推 mainId 踩坑
+        // 两趟:先 1级(静默,只含前端匹配的 s1-s3),再 2级(收口,只含 AI 命中的 s4/s5)
+        ArgumentCaptor<DoCleanDTO> l1 = ArgumentCaptor.forClass(DoCleanDTO.class);
+        ArgumentCaptor<DoCleanDTO> l2 = 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());
+
+        List<FileInfo> l1Sheets = l1.getValue().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()
+                .flatMap(r -> r.getChildren().stream()).toList();
+        assertEquals(2, l2Sheets.size(), "2级趟只洗 AI 命中的表");
+        assertTrue(l2Sheets.stream().allMatch(s -> s.getTemplateId() != null));
+
+        // AI 命中的节点要带齐规则与瞬态字段(含上传时缺失的表头行号),否则既有链路首行 NPE
         assertEquals(21, s4.getTemplateId());
         assertEquals(7, s4.getMainId(), "mainId 要显式塞进请求,CleanTask 只回填 templateId 和 tableNameEn");
         assertEquals("(姓名)", s4.getFuncRegx());
+        assertEquals(2, s4.getLineNo(), "出口 A 必须补 lineNo,StreamingEtlListener 按它切表头行");
         assertNotNull(s4.getTableRuleList());
         assertEquals(1, s4.getTableRuleList().size());
         assertEquals(22, s5.getTemplateId());
@@ -195,10 +210,12 @@ class AiSmartCleanServiceTest {
         verify(aiCleanService, times(1))
                 .loadViaAiTemplate(any(), eq(rootB), eq(s6), any(AiJudgeResult.AiPlan.class));
 
-        // 时序:三条判定全归零 → 才允许 doClean
+        // 时序:三条判定全归零 → 1级 → 2级 → 才允许 3级入库
         InOrder order = inOrder(aiCleanService, dmService);
         order.verify(aiCleanService, times(3)).judge(any(AiCleanJob.class));
-        order.verify(dmService).doClean(any());
+        order.verify(dmService).doClean(any(DoCleanDTO.class), eq(true));
+        order.verify(dmService).doClean(any(DoCleanDTO.class), eq(false));
+        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());
         assertTrue(sqlSet().stream().anyMatch(s -> s.contains("existing_hit_count")), "AI 命中要计数");
@@ -220,27 +237,28 @@ class AiSmartCleanServiceTest {
     // ==================== ② A 组为空 ====================
 
     @Test
-    @DisplayName("A 组为空(没人匹配上模板)→ doClean 零调用,不白关 SSE")
+    @DisplayName("1、2级都为空(没人匹配上模板)→ doClean 零调用,不白关 SSE")
     void skipDoCleanWhenExistingGroupEmpty() {
         FileInfo s1 = sheet(1, "s1", null), s2 = sheet(2, "s2", null);
         given(Map.of(1L, new AiJudgeResult.NeedReview("结构探测不出表头"), 2L, aiPlan()));
 
         service.run(9L, 1L, List.of(file(100L, 9L, s1, s2)));
 
-        verify(dmService, never()).doClean(any());
+        verify(dmService, never()).doClean(any(DoCleanDTO.class), anyBoolean());
         // 出口 B 照旧入库,不受 A 组为空影响
         verify(aiCleanService, times(1)).loadViaAiTemplate(any(), any(), eq(s2), any());
     }
 
     @Test
-    @DisplayName("全是既有匹配(AI 一条都不接)→ 仍然只发一次 doClean,且不碰模型")
+    @DisplayName("只有 1级表(AI 一条都不接)→ 只发一趟非静默 doClean,由它收口会话")
     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());
+        verify(dmService, times(1)).doClean(any(DoCleanDTO.class), eq(false));
+        verify(dmService, never()).doClean(any(DoCleanDTO.class), eq(true));
         verify(aiCleanService, never()).prepareJob(anyLong(), any(), any());
         verify(aiCleanService, never()).judge(any(AiCleanJob.class));
     }
@@ -341,7 +359,7 @@ class AiSmartCleanServiceTest {
     }
 
     @Test
-    @DisplayName("首次受理:建批次 + 后台一轮跑完,全程一次 doClean")
+    @DisplayName("首次受理:建批次 + 后台一轮跑完,1级/2级按级串行两趟 doClean")
     void firstSubmitBuildsBatchAndRunsOnce() {
         when(batchMapper.selectOne(any(Wrapper.class))).thenReturn(null);
         given(Map.of(4L, hit(4, 21), 5L, new AiJudgeResult.NeedReview("置信度不足")));
@@ -356,7 +374,9 @@ class AiSmartCleanServiceTest {
         verify(batchMapper).insert(any(AiCleanBatch.class));
 
         s.body.run();   // 同步跑后台那一轮
-        verify(dmService, times(1)).doClean(any());
+        // 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));
         verify(aiCleanService, times(2)).judge(any(AiCleanJob.class));
     }
 

+ 58 - 0
ai-server/src/test/java/com/zsjz/ai/module/aiclean/service/SelfHeaderPlanTest.java

@@ -0,0 +1,58 @@
+package com.zsjz.ai.module.aiclean.service;
+
+import com.zsjz.ai.module.aiclean.model.RuleBind;
+import com.zsjz.ai.module.aiclean.rule.AiRulePlan;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 兜底自建表路径(需求 2026-10-11:完全匹配不到模板 → 按文件自身表头新建动态表)的纯函数测试。
+ *
+ * @author cc
+ * @since 2026/10/11
+ */
+class SelfHeaderPlanTest {
+
+    @Test
+    @DisplayName("无候选兜底:每个非空表头自绑自身,列序连续、全不必填、无清洗提示")
+    void selfBindsMapEveryHeader() {
+        List<RuleBind> binds = AiCleanService.selfHeaderBinds(
+                List.of("舰船编号", "舰长姓名", " ", "曲率等级"));
+
+        assertEquals(3, binds.size(), "空白表头列跳过,其余逐列自绑");
+        assertEquals(0, binds.get(0).getFileColIndex());
+        assertEquals("舰船编号", binds.get(0).getFieldNameEn());
+        assertEquals(1, binds.get(1).getFileColIndex());
+        assertEquals("舰长姓名", binds.get(1).getFieldNameEn());
+        assertEquals(3, binds.get(2).getFileColIndex(), "列号跟随原表头位置,不因跳列而压缩");
+        assertEquals("曲率等级", binds.get(2).getFieldNameEn());
+        assertTrue(binds.stream().allMatch(b -> !Boolean.TRUE.equals(b.getRequired())),
+                "兜底表不设必填,避免抽样校验误杀");
+        assertTrue(binds.stream().allMatch(b -> b.getHints().isEmpty()), "无清洗函数");
+    }
+
+    @Test
+    @DisplayName("兜底绑定的执行计划:列名消毒、类型全 TEXT 兜底")
+    void planFallsBackToTextColumns() {
+        List<RuleBind> binds = AiCleanService.selfHeaderBinds(List.of("舰船编号", "出港日期"));
+        AiRulePlan.Plan plan = AiRulePlan.forAi(binds, List.of("舰船编号", "出港日期"), "ai_t");
+
+        assertEquals(2, plan.columns().size());
+        assertTrue(plan.columns().stream().allMatch(c -> "TEXT".equalsIgnoreCase(c.fieldType())),
+                "中文表头猜不出英文类型语义,必须落 TEXT,宁可不转型");
+        assertEquals(2, plan.fields().size());
+        assertEquals("舰船编号", plan.fields().get(0).getFieldNameCn());
+    }
+
+    @Test
+    @DisplayName("全空表头:绑定为空 → 上层据此转「无法清洗」而不是建出零列表")
+    void blankHeadersBindNothing() {
+        List<RuleBind> binds = AiCleanService.selfHeaderBinds(List.of("", "  "));
+        assertTrue(binds.isEmpty());
+    }
+}

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

@@ -59,7 +59,97 @@
 - 上传页行级/整页「手动清洗」、importList 重新清洗等旧入口不受影响。✅
 - MD5 去重还原后重复上传仍被拦截。✅
 
+### 统一展示改造(AI 链路并入主卡,浏览器实测 ✅)
+- 取消独立「AI 识别」卡片:进度环按系统清洗/AI 各自处理的 sheet 数加权合成一个总进度;
+  AI 判定期间步骤条推进到「清洗入库」。
+- AI 事件全部并入处理日志:AI 识别完成并入系统清洗、AI 新建表入库中、逐条「AI 无法清洗:表名(原因)」、
+  阶段完成统计;混合批次实测日志顺序与内容正确。
+- 完成态描述带出「另有 N 个表 AI 无法清洗」提示;超时出口「改为手动清洗」与
+  「查看导入结果」并入主卡展示。
+- 实测混合批次(B 系统清洗 272 条 + A 无法清洗 1 条):单卡从 0% 推进到完成态,全程无独立 AI 卡。
+
 ### 待人工确认逻辑(已按需求移除)
 - 相关代码(清单面板/详情弹窗/重判/resolve 接口/消项钩子/仅清洗当前表模式)已全部移除;
   git 历史中保留可随时恢复。
 - ai_clean_job 仍以 NEED_REVIEW 状态记录「无法清洗」结论(原因可查),仅不再引导人工处理。
+
+---
+
+## 4 级降级专项验证(2026-10-11)
+
+### 逻辑梳理(已逐行核实代码)
+
+**上传(L1 判定)**:`upload.vue` → `/dm/preFileUpload` → `PreDataListener`:每 sheet 建预览表
+`raw_{sheetId}`、默认「模板匹配失败」;前 20 行内 `matchTemplate`(模板全字段包含 + 必填字段包含)→
+命中打 `templateId/mainId/funcRegx/lineNo`,「未命中」留给 AI 链路。
+
+**智能清洗(`AiSmartCleanService.run`,按降级顺序串行)**:只接管 `templateId==null` 的 sheet。
+
+| 级别 | 判定 | 落地 |
+|---|---|---|
+| 1级 | 上传时已匹配模板 | 静默趟 doClean → 业务表 |
+| 2级 | AI 三腿匹配(表头指纹 → 归一化打分≥0.90 → LLM top-12 置信≥0.6)命中**既有模板**且 STRONG | 等待 1级 Latch → 收口趟 doClean → 业务表 |
+| 3级 | 命中 **AI 模板库**(复用已有 ai_t 表,零模型)或完全无候选(按**文件自身表头**建表,全 TEXT)或 WEAK 弱匹配 | `loadViaAiTemplate` 建表/并入 → 流式写数 → ONLINE |
+| 4级 | 硬失败:需结构归一(合并单元格/多级表头)/ 无可用列绑定 / 置信度不过放行线 / 表头为空 | 只标记 NEED_REVIEW,不写任何数据表,原因进 `need_review_detail` + 处理日志 |
+
+- 日志绑定:行级失败/未配模板 → `clean_error_log(fileId+sheetId+batchId)`;每 sheet → `ai_clean_job`;批次 → `ai_clean_batch`。
+- 治理结果:AI 表经 `govern_tree` 负数 id 节点 → `/aiClean/data/page` 分流取数(不写 `table_info/table_field`,
+  `AiCleanStore` 有意为之,防被既有匹配器命中)。
+- AI 表同表头二次清洗:header_md5 唯一键下**复用**已有模板与动态表(存档规则零模型),数据按 job 并入/可撤销。
+
+### 测试数据(`ai-electron/frontend/public/__test__/`)
+
+| 文件 | 构造 | 预期 |
+|---|---|---|
+| C-短信表头同义改写.xlsx | 文件B短信表头改3列同义词、表头在第1行 | 2级:AI 语义命中既有模板 → 出口A |
+| D-人员信息含噪声列.xlsx | 人员信息+3个噪声列 | 观察:LLM 置信≥0.6→出口A,<0.6→出口B |
+| E-星际航运清单.xlsx | 全新表头(星际航运) | 修复前4级无候选 → 修复后3级自建表 |
+| E2/E3-星际航运清单续/三.xlsx | 同表头不同内容 | 3级:指纹直通复用已有 ai_t 表 |
+| F-合并单元格表头.xlsx | 合并单元格标题+第2行表头 | 4级:需结构归一 |
+| G-短信必填空值.xlsx | 同义表头+本机号码80%空值 | 4级:置信度不过放行线 |
+
+### 用例与结果(浏览器实测 + 接口断言)
+
+| # | 场景 | 预期 | 结果 |
+|---|---|---|---|
+| A1 | 文件B上传 | 3 sheet 模板匹配成功(tpl 23/21/1) | ✅ |
+| A2/A3 | 文件A+C混合上传 | A 匹配失败、C(改表头)匹配失败,各自状态正确 | ✅ |
+| B1 | 仅文件B智能清洗 | 批次 DONE、matched=3、**aiJudged=0**(守卫:已匹配的绝不插手) | ✅ |
+| B2 | 清洗日志绑定 | `cleanLog/list` 按 batchId 查,记录含 fileId/sheetId/batchId | ✅ |
+| C1 | 2级 AI 语义匹配 | job:exit=A、by=LLM、STRONG、置信0.78、refTpl=21;**落库 21条 failNum=0** | ✅(修复后) |
+| D3 | 完全无候选 | 修复前:NEED_REVIEW「没有合适的模板」(缺陷确认);修复后:3级自建表,动态表 5行×6列(文件自身表头,全 TEXT) | ✅(修复后) |
+| D2 | 同表头二次清洗 | 修复前:指纹直通仍新建模板 → **撞 uk_ai_clean_tpl_md5 唯一键 FAIL**(缺陷确认);修复后:HEADER_CACHE 复用同模板、**llmCalls=0**、数据并入(5+4=9行) | ✅(修复后) |
+| E1 | 4级·结构归一 | 文件A/文件F → NEED_REVIEW「该表块需要结构归一…」,处理日志可见,不写任何表 | ✅ |
+| E2 | 4级·置信度闸门 | 文件G → NEED_REVIEW「置信度未达出口 A 的放行线」(perColErrorRate=0.8/rowYield=0.2) | ✅ |
+| E3 | 4级落地断言 | job 状态/批次计数/need_review_detail/前端日志三处一致;aiLoad=0 | ✅ |
+| F1 | templateId 非空零 AI 判定 | 同 B1(aiJudged=0) | ✅ |
+| F3 | 治理结果链路 | 治理树含 AI 节点(负id,type=ai_table,count 正确);`/aiClean/data/page` 取数带表头行;治理任务重建后由 `GovernService:380` calcAiTree 补挂(代码走查) | ✅ |
+| 串行 | 按级 1→2→3 串行 | 后端日志「按降级顺序串行清洗 1级表=N → 2级表=M」;单测断言先 doClean(true) 后 doClean(false) 再 LOADING_AI | ✅ |
+| 噪音 | 无「未配置模板」误报 | 修复前混合批次会为 NEED_REVIEW 表记「未配置模板!」;修复后 0 条 | ✅ |
+| 按钮 | 进度页无按钮 | 完成态页面无「查看导入结果」(截图目视 + DOM 断言 buttons=[]) | ✅ |
+
+### 本轮修复(已随验证闭环)
+
+1. **出口 A 落库断链(P0)**:AI 命中既有模板的表上传时没有 `line_no`,`StreamingEtlListener:120` 首行 NPE,
+   **数据零落库但批次仍报 DONE**。修复:`ExistingHit` 携带表头行号,`dispatchExisting`/`ExistingTemplateDispatcher`
+   回填 `lineNo`;复测 21 条全部入库。⚠️ 已知限制:doClean 阶段失败仍不会回写批次计数(与旧手动清洗行为一致)。
+2. **完全匹配不到→自建表(需求)**:`judge` 无候选分支改为按文件自身表头建兜底动态表(全 TEXT),
+   `loadViaAiTemplate` 兼容 draft 为空;4级只留给硬失败。needsStructure 提前到无候选判定**之前**(结构问题优先定性)。
+3. **同表头指纹复用(P1)**:指纹直通命中 AI 模板库时复用已有模板与动态表(读回存档 `ai_clean_rule`,零模型调用),
+   不再新建模板撞 `uk_ai_clean_tpl_md5`;0 行失败不删复用表。
+4. **按降级顺序串行清洗(需求)**:拆分 1级/2级两趟 doClean,趟间 Latch 排队;首趟静默(不推终态/不关 SSE/不触发治理),
+   末趟收口;NEED_REVIEW/Skip 表不进任何一趟(顺带消除「未配置模板!」噪音日志)。
+5. **进度页移除「查看导入结果」按钮(需求)**:两处入口删除,`handleViewResult` 清理;
+   「查看错误日志」「改为手动清洗」为条件功能按钮,暂保留。
+
+### 新增/调整单测
+- `SelfHeaderPlanTest`(3 用例):兜底绑定逐列自绑/全 TEXT 计划/空表头转人工。
+- `AiSmartCleanServiceTest`:原「合并一次 doClean」断言改为「1级静默趟 → 2级收口趟」时序断言
+  (`cleansByLevelInOrder`),并断言出口 A 必须回填 lineNo。
+- aiclean 模块 76 用例全绿(5 跳过为真连 LLM 的活测试)。
+
+### 遗留观察
+- 出口 A 清洗中 `CallDataCleaner` 对个别列报 FunException(清洗函数 pattern 为 null,列级被清洗器内部消化,
+  failNum=0 数据完整入库)——既有 34 策略的函数配置问题,非本轮引入,建议后续核查对方号码列的清洗函数装配。
+- 治理任务全量跑一遍(验证 `truncate govern_tree` 重建后 AI 节点补挂的实测闭环)未执行,由代码走查
+  `GovernService:380` 与 calcAiTree 幂等性保障;日常治理后可观察树上 AI 表节点确认。