Sfoglia il codice sorgente

perf(aiclean): 抽样与入库复用上传预览表,不再重读源文件

- 预览表 raw_{sheetId} 增加 row_no 物理行号:Fesod 跳过空行后仍能还原真实位置,
  测试确认 sheet/row 两个 holder 给的都是原文件物理行号
- AiDuckDb 增加预览表可用性校验与分段读取:表在、有 row_no、行数等于
  file_info.data_num 才可用,防止上传写库中断的半截表被当成全量
- AI 抽样与出口 B 入库优先读预览表,老结构 / 单 job / 行数不一致时
  回退 AiRowReader 读源文件,行为与以前一致
- 融合链路静默清洗趟不再清理 raw_*,改由智能批次 finally 统一清理,
  保证金 3 级 AI 入库时预览表还在
- 顺带修复 AiRowReader 对 CSV 误用 XLSX 读取类型
- 新增 AiDuckDbRawTest / PreDataListenerRawTest / ReadRowIndexParityTest

测试:aiclean + dm 154 个用例通过(5 个 live 跳过)
cc 2 giorni fa
parent
commit
e3071c6527

+ 176 - 1
ai-server/src/main/java/com/zsjz/ai/module/aiclean/exec/AiDuckDb.java

@@ -343,10 +343,185 @@ public final class AiDuckDb {
         }
     }
 
+    /** 预览表分段读取的物理行宽:每页最多这么多行,内存恒定 */
+    private static final int RAW_PAGE = 5000;
+
+    /**
+     * 预览表可读信息。
+     *
+     * @param table   物理表名 {@code raw_{sheetId}}
+     * @param colCount col_0..col_{n-1} 的列数
+     * @param maxRowNo 最大物理行号(空表不可能出现本记录)
+     */
+    public record RawSheet(String table, int colCount, long maxRowNo) {
+    }
+
+    /** 预览表行回调:row 的 key 是列序号(0 基),lineNo 是原文件物理行号(1 基) */
+    @FunctionalInterface
+    public interface RawRowHandler {
+        void accept(Map<Integer, String> row, int lineNo);
+    }
+
+    /**
+     * 判断上传预览阶段建的 {@code raw_{sheetId}} 能否作为 AI 取数源。
+     *
+     * <p>四个条件缺一不可:表在、有 {@code row_no} 列、至少有一行数据、
+     * <b>行数与上传时记录的数据量一致</b>。最后一条是防「半截预览表」:
+     * {@code PreDataListener} 写库失败时会放弃本 sheet 剩余行但上传仍算成功,
+     * 这种残缺表要是被 AI 当成全量,入库就会静默少行。老版本代码建的预览表
+     * 没有 row_no(空行被 Fesod 跳过后无法还原真实行号),同样返回 null。
+     *
+     * @param expectedRows 上传时 {@code file_info.data_num} 记录的行数(含表头/标题行);
+     *                     &lt;= 0 表示无法校验,直接判不可用
+     * @return null 表示不可用
+     */
+    public static RawSheet rawSheet(Long sheetId, int expectedRows) {
+        if (sheetId == null || expectedRows <= 0) {
+            return null;
+        }
+        String table = "raw_" + sheetId;
+        try (Connection c = caseDb().getConnection()) {
+            boolean hasRowNo = false;
+            int maxCol = -1;
+            try (PreparedStatement ps = c.prepareStatement(
+                    "SELECT column_name FROM information_schema.columns "
+                            + "WHERE table_schema = 'main' AND table_name = ? ORDER BY ordinal_position")) {
+                ps.setString(1, table);
+                try (ResultSet rs = ps.executeQuery()) {
+                    while (rs.next()) {
+                        String name = rs.getString(1);
+                        if ("row_no".equalsIgnoreCase(name)) {
+                            hasRowNo = true;
+                        } else if (name != null && name.startsWith("col_")) {
+                            try {
+                                maxCol = Math.max(maxCol, Integer.parseInt(name.substring(4)));
+                            } catch (NumberFormatException ignore) {
+                                // 非本模块命名的列,忽略
+                            }
+                        }
+                    }
+                }
+            }
+            if (!hasRowNo || maxCol < 0) {
+                return null;
+            }
+            try (Statement st = c.createStatement();
+                 ResultSet rs = st.executeQuery("SELECT count(*), max(row_no) FROM " + table)) {
+                if (!rs.next()) {
+                    return null;
+                }
+                long count = rs.getLong(1);
+                long max = rs.getLong(2);
+                boolean maxNull = rs.wasNull();
+                if (maxNull || count <= 0) {
+                    return null;
+                }
+                if (count != expectedRows) {
+                    log.info("预览表行数与上传记录不一致,AI 改读源文件: sheetId={}, table={}, rawRows={}, expected={}",
+                            sheetId, table, count, expectedRows);
+                    return null;
+                }
+                return new RawSheet(table, maxCol + 1, max);
+            }
+        } catch (Exception e) {
+            log.info("预览表不可用,AI 改读源文件: sheetId={}, err={}", sheetId, e.getMessage());
+            return null;
+        }
+    }
+
+    /**
+     * 从预览表取抽样行(只读前 {@code limit} 条非空行)。
+     *
+     * @return null = 预览表不可用,调用方应回退读源文件;空集合 = 预览表可用但没有数据行
+     */
+    public static List<Map<Integer, String>> readRawSample(Long sheetId, int headerRow, int limit,
+                                                           int expectedRows) {
+        RawSheet raw = rawSheet(sheetId, expectedRows);
+        if (raw == null) {
+            return null;
+        }
+        List<Map<Integer, String>> out = new ArrayList<>();
+        try {
+            streamRawRows(raw, headerRow, limit <= 0 ? Long.MAX_VALUE : limit,
+                    (row, lineNo) -> out.add(row));
+        } catch (Exception e) {
+            log.error("预览表抽样读取失败: table={}, err={}", raw.table(), e.getMessage());
+            // 一行都没取到才回退读文件;已拿到部分样本就按部分判定,不为抽样再全量读一遍
+            return out.isEmpty() ? null : out;
+        }
+        return out;
+    }
+
+    /**
+     * 从预览表按物理行号顺序流式回调(跳过表头行及其上方内容、整行为空的行)。
+     *
+     * <p>与 {@code AiRowReader.forEachRow} 同一套读值口径(两处都走 Fesod
+     * {@code headRowNumber(0)} + 同款转换器),差别只是数据源从源文件换成上传预览表。
+     * 分段按 {@code row_no} 区间查询而不是 keyset 翻页:row_no 单调递增,
+     * 每个区间最多 {@value #RAW_PAGE} 行,堆占用恒定,也不会为翻页反复排序整表。
+     *
+     * <p><b>失败语义</b>:中途读失败直接抛出,绝不当成「读完了」——
+     * 半截数据落库会让 AI 清洗显示成功但行数少了,而全自动链路没人能发现。
+     */
+    public static void forEachRawRow(RawSheet raw, int headerRow, RawRowHandler handler) {
+        streamRawRows(raw, headerRow, Long.MAX_VALUE, handler);
+    }
+
+    private static void streamRawRows(RawSheet raw, int headerRow, long maxRows,
+                                      RawRowHandler handler) {
+        if (raw == null || raw.colCount() <= 0) {
+            return;
+        }
+        long start = Math.max(0L, headerRow) + 1L;
+        if (start > raw.maxRowNo()) {
+            return;
+        }
+        StringBuilder sql = new StringBuilder("SELECT row_no");
+        for (int i = 0; i < raw.colCount(); i++) {
+            sql.append(", col_").append(i);
+        }
+        sql.append(" FROM ").append(raw.table())
+                .append(" WHERE row_no >= ? AND row_no < ? ORDER BY row_no");
+        long delivered = 0;
+        try (Connection c = caseDb().getConnection();
+             PreparedStatement ps = c.prepareStatement(sql.toString())) {
+            for (long from = start; from <= raw.maxRowNo(); from += RAW_PAGE) {
+                ps.setLong(1, from);
+                ps.setLong(2, from + RAW_PAGE);
+                try (ResultSet rs = ps.executeQuery()) {
+                    while (rs.next()) {
+                        int lineNo = rs.getInt(1);
+                        Map<Integer, String> row = new LinkedHashMap<>();
+                        boolean any = false;
+                        for (int i = 0; i < raw.colCount(); i++) {
+                            String value = rs.getString(i + 2);
+                            if (value != null && !value.isBlank()) {
+                                any = true;
+                            }
+                            row.put(i, value);
+                        }
+                        // 与 AiRowReader 同口径:整行为空的行不落库
+                        if (!any) {
+                            continue;
+                        }
+                        handler.accept(row, lineNo);
+                        if (++delivered >= maxRows) {
+                            return;
+                        }
+                    }
+                }
+            }
+        } catch (Exception e) {
+            throw new IllegalStateException("预览表读取失败: table=" + raw.table()
+                    + ", err=" + e.getMessage(), e);
+        }
+    }
+
     /**
      * 删除案件库里指定前缀的临时表(如预览阶段动态建的 {@code raw_{sheetId}})。
      *
-     * <p>预览表只在「选择清洗模板 / 预览原始数据」阶段用得到,清洗完成后不清会随上传次数无限累积。
+     * <p>预览表同时是 AI 清洗的取数源,所以只能在<b>整批清洗结束</b>后清:
+     * 融合链路里静默的既有清洗趟不能删它,否则后面的 AI 自建表入库会退回读源文件。
      * 前缀由调用方传常量,这里再做一次字符白名单校验,表名同时用双引号包裹防注入。
      */
     public static int dropTablesByPrefix(String prefix) {

+ 11 - 9
ai-server/src/main/java/com/zsjz/ai/module/aiclean/probe/AiRowReader.java

@@ -16,15 +16,16 @@ import java.util.List;
 import java.util.Map;
 
 /**
- * AI 侧独立读表器:按指定表头行取数,产出「列序号 → 值」的行列表。
+ * AI 侧读表器:按指定表头行取数,产出「列序号 → 值」的行列表。
  *
- * <p>为什么不复用既有 {@code raw_{sheetId}}:那张表是全 VARCHAR、且没做过结构归一,
- * 而 AI 链路可能改过表头行与列数;也为了让读法完全由本模块掌控,
- * 不依赖也不修改清洗链路的任何监听器。
+ * <p><b>正常入库路径不经过它</b>:数据优先从上传预览表 {@code raw_{sheetId}} 读取
+ * (见 {@code AiDuckDb#forEachRawRow}),避免为入库再把源文件解析一遍。
+ * 本类只在预览表不可用时兜底 —— 老预览表没有 {@code row_no}、预览写入失败、
+ * 或单 job 重跑时预览表已被清理。两条路径的读值口径必须一致(Fesod {@code headRowNumber(0)}
+ * + 同一对转换器),否则抽样校验的结论不能外推到正式入库。
  *
- * <p>与既有清洗同样用 Fesod + {@code headRowNumber(0)}(自己接管表头),
- * 并注册同样的两个转换器,保证「同一格在两条链路上读出来的值一致」——
- * 这条对齐很重要,否则抽样校验的结论不能外推到正式入库。
+ * <p>{@code SheetProbe} 仍然只读源文件:合并单元格非左上格在预览表里已经是 NULL,
+ * 落库后无法还原,结构探测只能回源文件。
  *
  * @author cc
  * @since 2026/9/21
@@ -81,8 +82,9 @@ public final class AiRowReader {
         if (file == null || !file.exists()) {
             return false;
         }
-        ExcelTypeEnum type = file.getName().toLowerCase().endsWith(".xls")
-                ? ExcelTypeEnum.XLS : ExcelTypeEnum.XLSX;
+        String lowerName = file.getName().toLowerCase();
+        ExcelTypeEnum type = lowerName.endsWith(".xls") ? ExcelTypeEnum.XLS
+                : lowerName.endsWith(".csv") ? ExcelTypeEnum.CSV : ExcelTypeEnum.XLSX;
         try (FileInputStream in = new FileInputStream(file)) {
             var reader = FesodSheet.read(in, new AnalysisEventListener<Map<Integer, String>>() {
                 @Override

+ 2 - 0
ai-server/src/main/java/com/zsjz/ai/module/aiclean/probe/SheetProbe.java

@@ -24,6 +24,8 @@ import java.util.List;
  * 在 {@code PreDataListener.invoke} 拿到的 Map 里已经是 null,落库后没有任何信息可以还原;
  * 多级表头同理。所以这一步必须回到源文件(源文件必然在盘上,
  * 否则既有清洗本身就跑不了,见 {@code DmService.hasSourceFile:764-773})。
+ * 注意这只约束「结构探测」:行数据本身在入库时优先读 {@code raw_{sheetId}}
+ * (见 {@code AiDuckDb#forEachRawRow}),不再重读源文件。
  *
  * <p>只扫前 {@link #MAX_ROWS} 行:表头、分块、形状判定都在这个范围内足够,
  * 大文件也不会因为探测而多吃内存。

+ 70 - 25
ai-server/src/main/java/com/zsjz/ai/module/aiclean/service/AiCleanService.java

@@ -305,8 +305,15 @@ public class AiCleanService {
         }
 
         mark(job, AiCleanStatusEnum.VERIFYING);
-        List<Map<Integer, String>> sample = AiRowReader.read(new File(root.getFilePath()),
-                sheet.getSheetName(), ev.primaryBlock().headerRow(), SAMPLE);
+        int headerRow = ev.primaryBlock().headerRow();
+        // 抽样优先读上传预览表 raw_{sheetId}:与源文件同一套读值口径(Fesod 同配置 + 同款转换器),
+        // 但不用再把整份文件重新解析一遍。老预览表 / 预览写入失败时回退读源文件。
+        List<Map<Integer, String>> sample = AiDuckDb.readRawSample(job.getSheetId(), headerRow, SAMPLE,
+                sheet.getDataNum() == null ? 0 : sheet.getDataNum());
+        if (sample == null) {
+            log.info("预览表不可用,AI 抽样回退读源文件: jobId={}, sheetId={}", job.getId(), job.getSheetId());
+            sample = AiRowReader.read(new File(root.getFilePath()), sheet.getSheetName(), headerRow, SAMPLE);
+        }
         VerifyReport report = verify(binds, outcome.candidate(), headers, sample, outcome);
         job.setConfidence(java.math.BigDecimal.valueOf(report.confidence().overall()));
         // 抽样报告落库(预览行截到 5 行防膨胀):排障时能看到「哪个列错误率多少、为什么被拒」
@@ -365,9 +372,15 @@ public class AiCleanService {
     /**
      * 出口 B:建 AI 模板 + 动态表并写数。
      *
-     * <p><b>流式管道</b>(评审 P0-1):Fesod 逐行回调里边清洗边写 DuckDB(攒批 flush),
+     * <p><b>流式管道</b>(评审 P0-1):逐行回调里边清洗边写 DuckDB(攒批 flush),
      * 不再把整个 sheet collect 成两份 Map 驻留堆内 —— 几十万行的流水表以前会在这里打爆内存。
      *
+     * <p><b>取数源</b>:优先读上传预览阶段建好的 {@code raw_{sheetId}}(带物理行号),
+     * 不再为入库重新解析源文件;预览表缺失 / 老结构 / 行数与上传记录不一致(半截写入)时才
+     * 回退 {@link AiRowReader} 读盘。
+     * 两条路径喂给 {@link AiRecordCleaner} 的行数据完全等价(同为 Fesod {@code headRowNumber(0)}
+     * + 同款转换器),所以清洗结果不会因为数据源切换而漂移。
+     *
      * <p><b>写序与 {@link AiCleanStore} 的注释对齐</b>(评审发现的幽灵模板问题):
      * 模板 id 先行生成(物理表名 {@code ai_clean_{id}} 依赖它)→ 建表 → 流式写数 →
      * <b>成功才登记 ONLINE</b>。写数失败/零行就不登记,并删掉刚建的空表 ——
@@ -475,29 +488,45 @@ public class AiCleanService {
         List<String> cols = aiPlan.fields().stream().map(AiCleanField::getColumnName).toList();
         long written;
         java.util.concurrent.atomic.AtomicLong total = new java.util.concurrent.atomic.AtomicLong();
+        int headerRow = ev.primaryBlock().headerRow();
         try (AiDuckDb.BatchWriter writer = AiDuckDb.openWriter(tpl.getTableNameEn(), cols)) {
-            boolean ok = AiRowReader.forEachRow(new File(root.getFilePath()), sheet.getSheetName(),
-                    ev.primaryBlock().headerRow(), (row, lineNo) -> {
-                        Map<String, String> errors = new LinkedHashMap<>();
-                        Map<String, Object> cleaned = AiRecordCleaner.cleanRow(row, aiPlan.columns(), errors);
-                        if (cleaned == null) {
-                            return;
-                        }
-                        cleaned.put("id", IdUtil.getSnowflakeNextId());
-                        cleaned.put("case_id", job.getCaseId());
-                        cleaned.put("file_id", job.getFileId());
-                        cleaned.put("sheet_id", job.getSheetId());
-                        cleaned.put("block_no", ev.primaryBlock().blockNo());
-                        // row_no 存真实原文件行号(流式回调按物理行回填),是事后定位与撤销的抓手;
-                        // 旧实现用「清洗后序号 + 表头行」近似,sheet 里有空行时对不上号
-                        cleaned.put("row_no", lineNo);
-                        cleaned.put("ai_job_id", job.getId());
-                        cleaned.put("ai_rule_ver", tplF.getVer() == null ? 1 : tplF.getVer());
-                        cleaned.put("category", tplF.getCategory());
-                        // 写失败会在这里抛出,中断流式读取 —— 半截数据绝不能被当成成功
-                        writer.add(cleaned);
-                        total.incrementAndGet();
-                    });
+            // 行数据优先取自上传预览表:不用为了入库再把整份源文件重新解析一遍。
+            // 注意这里仍然逐行走 AiRecordCleaner —— 预览表存的是原值(全 VARCHAR),
+            // 特殊字符剔除、函数链、类型规整都还没做,纯 SQL 的 CREATE TABLE AS 会把这些全部丢掉。
+            AiDuckDb.RawRowHandler handler = (row, lineNo) -> {
+                Map<String, String> errors = new LinkedHashMap<>();
+                Map<String, Object> cleaned = AiRecordCleaner.cleanRow(row, aiPlan.columns(), errors);
+                if (cleaned == null) {
+                    return;
+                }
+                cleaned.put("id", IdUtil.getSnowflakeNextId());
+                cleaned.put("case_id", job.getCaseId());
+                cleaned.put("file_id", job.getFileId());
+                cleaned.put("sheet_id", job.getSheetId());
+                cleaned.put("block_no", ev.primaryBlock().blockNo());
+                // row_no 存真实原文件行号(流式回调按物理行回填),是事后定位与撤销的抓手;
+                // 旧实现用「清洗后序号 + 表头行」近似,sheet 里有空行时对不上号
+                cleaned.put("row_no", lineNo);
+                cleaned.put("ai_job_id", job.getId());
+                cleaned.put("ai_rule_ver", tplF.getVer() == null ? 1 : tplF.getVer());
+                cleaned.put("category", tplF.getCategory());
+                // 写失败会在这里抛出,中断流式读取 —— 半截数据绝不能被当成成功
+                writer.add(cleaned);
+                total.incrementAndGet();
+            };
+            AiDuckDb.RawSheet raw = AiDuckDb.rawSheet(job.getSheetId(), expectedRawRows(sheet, job));
+            boolean ok;
+            if (raw != null) {
+                log.info("AI 入库读取上传预览表: jobId={}, sheetId={}, table={}",
+                        job.getId(), job.getSheetId(), raw.table());
+                AiDuckDb.forEachRawRow(raw, headerRow, handler);
+                ok = true;
+            } else {
+                log.info("预览表不可用,AI 入库回退读源文件: jobId={}, sheetId={}",
+                        job.getId(), job.getSheetId());
+                ok = AiRowReader.forEachRow(new File(root.getFilePath()), sheet.getSheetName(),
+                        headerRow, handler::accept);
+            }
             written = ok ? writer.finish() : -1L;
         } catch (Exception e) {
             log.error("AI 流式入库失败: jobId={}, table={}, err={}", job.getId(), tpl.getTableNameEn(), e.getMessage());
@@ -526,6 +555,22 @@ public class AiCleanService {
         return written;
     }
 
+    /**
+     * 预览表完整性校验用的行数:上传解析时 {@code FileInfo.dataNum} 记的就是写入 raw 的行数。
+     *
+     * <p>融合链路传进来的 sheet 是前端那棵树上的节点,正常带 dataNum;缺失时回查库里那份。
+     * 仍拿不到就返回 0 —— 调用方据此放弃预览表、回退读源文件,
+     * 绝不能把一张无法校验完整性的表当成全量数据源。
+     */
+    private int expectedRawRows(FileInfo sheet, AiCleanJob job) {
+        Integer rows = sheet == null ? null : sheet.getDataNum();
+        if (rows == null && job != null && job.getSheetId() != null) {
+            FileInfo dbSheet = fileInfoMapper.selectById(job.getSheetId());
+            rows = dbSheet == null ? null : dbSheet.getDataNum();
+        }
+        return rows == null ? 0 : Math.max(0, rows);
+    }
+
     /**
      * AI 动态表的中文名(2026-10-18 需求):表名跟数据来源对齐,不再用模型草稿语义命名。
      * <ul>

+ 15 - 10
ai-server/src/main/java/com/zsjz/ai/module/aiclean/service/AiSmartCleanService.java

@@ -370,16 +370,6 @@ public class AiSmartCleanService {
             // 终态收口(全 SSE):统计 → 状态快照 → 治理宣告;纯 AI/无既有清洗的批次
             // 没有 doClean 的完成回调,前端的「完成并跳转统计页」完全依赖这一推
             pushTerminal(caseId, batchId, autoGovern);
-            // 清洗全部完成:预览阶段动态建的 raw_{sheetId} 临时表已无用,统一清理。
-            // 融合链路里 doClean 完成回调已清过一次,这里再兜一次纯 AI 批次(没有 doClean 的情况)。
-            try {
-                int droppedRaw = com.zsjz.ai.module.aiclean.exec.AiDuckDb.dropTablesByPrefix("raw_");
-                if (droppedRaw > 0) {
-                    log.info("智能清洗完成,已清理预览临时表 raw_*: {} 张", droppedRaw);
-                }
-            } catch (Exception e) {
-                log.error("智能清洗完成后清理 raw_ 临时表失败(不影响清洗结果): {}", e.getMessage());
-            }
             log.info("智能清洗批次完成: batchId={}, 阶段={}, 判定={} 条, 交既有清洗={} 条, AI 入库={} 条, "
                             + "待人工={} 条, 失败={} 条, 耗时={}ms",
                     batchId, finalStage, pending.size(), toExisting, st.aiLoad.get(),
@@ -392,10 +382,25 @@ public class AiSmartCleanService {
             update.setUpdateTime(LocalDateTime.now());
             batchMapper.update(update, batchKey(caseId, batchId));
         } finally {
+            // 预览表 raw_* 是 3 级 AI 自建表入库的取数源,必须等整批清洗结束才能清;
+            // 放在 finally 里,异常中断的批次也不会把临时表留在案件库里。
+            dropPreviewTables();
             reviewItems.remove(batchId);
         }
     }
 
+    /** 清理预览阶段建的 raw_* 临时表;失败只记日志,不影响批次结果 */
+    private void dropPreviewTables() {
+        try {
+            int dropped = com.zsjz.ai.module.aiclean.exec.AiDuckDb.dropTablesByPrefix("raw_");
+            if (dropped > 0) {
+                log.info("智能清洗批次结束,已清理预览临时表 raw_*: {} 张", dropped);
+            }
+        } catch (Exception e) {
+            log.error("智能清洗批次结束后清理 raw_ 临时表失败(不影响清洗结果): {}", e.getMessage());
+        }
+    }
+
     private void handle(Long batchId, PendingAi p, AiJudgeResult result,
                         List<AiJudgeResult.ExistingHit> existingHits,
                         Map<Long, AiJudgeResult.AiPlan> plans, Stats st) {

+ 12 - 5
ai-server/src/main/java/com/zsjz/ai/module/dm/pre/PreDataListener.java

@@ -36,6 +36,10 @@ import java.util.stream.Collectors;
  * 监听 Excel/CSV 文件读取过程,解析文件结构、匹配模板
  * 为每个 sheet 在 DuckDB 动态创建 raw_{sheetId} 表(全 VARCHAR),用 DuckDBAppender 流式追加全部数据行
  * 实现流式数据处理,避免大文件内存溢出
+ *
+ * <p>{@code raw_{sheetId}} 同时是 AI 清洗的取数源:除 file_id / sheet_id 外还存
+ * {@code row_no}(原文件物理行号,1 基)。AI 侧按 row_no 过滤表头、定位行,
+ * 不必再重读一遍源文件;缺这个列就只剩插入顺序,空行被跳过后无法还原真实行号。
  */
 @Slf4j
 public class PreDataListener extends AnalysisEventListener<Map<Integer, String>> {
@@ -132,8 +136,8 @@ public class PreDataListener extends AnalysisEventListener<Map<Integer, String>>
         if (rowIndex <= 20 && !matched.get()) {
             findTableHead(rowData, rowIndex);
         }
-        // 全部行写入 DuckDB 动态表
-        appendRawRow(rowData);
+        // 全部行写入 DuckDB 动态表(+1 换成与原文件一致的 1 基物理行号)
+        appendRawRow(rowData, rowIndex + 1);
     }
 
     /**
@@ -141,8 +145,9 @@ public class PreDataListener extends AnalysisEventListener<Map<Integer, String>>
      * 首行时懒初始化建表,行宽超过现有列数时动态扩列
      *
      * @param rowData 当前行数据,key 为列索引
+     * @param rowNo   原文件物理行号(1 基;空行被 Fesod 跳过后仍能还原真实位置)
      */
-    private void appendRawRow(Map<Integer, String> rowData) {
+    private void appendRawRow(Map<Integer, String> rowData, int rowNo) {
         rowCount++;
         if (!rawTableInited) {
             initRawTable(rowData);
@@ -159,6 +164,7 @@ public class PreDataListener extends AnalysisEventListener<Map<Integer, String>>
             rawAppender.beginRow();
             rawAppender.append(String.valueOf(fileId));
             rawAppender.append(String.valueOf(fileSheet.getId()));
+            rawAppender.append(rowNo);
             for (int i = 0; i < rawColCount; i++) {
                 // append(String) 对 null 安全,缺失列自动写 NULL
                 rawAppender.append(rowData.get(i));
@@ -184,9 +190,10 @@ public class PreDataListener extends AnalysisEventListener<Map<Integer, String>>
             DynamicRoutingDataSource ds = (DynamicRoutingDataSource) SpringUtil.getBean(DataSource.class);
             rawConn = (DuckDBConnection) ds.getDataSource(StrConsts.DS_KEY_SLAVE).getConnection();
             rawColCount = Math.max(1, rowData.isEmpty() ? 1 : Collections.max(rowData.keySet()) + 1);
-            // 全 VARCHAR:file_id、sheet_id 关联列 + col_0..col_{n-1}
+            // 全 VARCHAR:file_id、sheet_id 关联列 + row_no 物理行号 + col_0..col_{n-1}
+            // row_no 让 AI 清洗能按真实行号切表头、跳过标题行,是「直接复用预览表」的前提
             StringJoiner cols = new StringJoiner(", ");
-            cols.add("file_id VARCHAR").add("sheet_id VARCHAR");
+            cols.add("file_id VARCHAR").add("sheet_id VARCHAR").add("row_no INTEGER");
             for (int i = 0; i < rawColCount; i++) {
                 cols.add("col_" + i + " VARCHAR");
             }

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

@@ -636,11 +636,16 @@ public class DmService {
                     int completedCount = dataNum - failNum;
                     // 所有清洗任务已完成:预览阶段动态建的 raw_{sheetId} 临时表不再需要,整表清掉;
                     // 不清理会随每次上传在案件库里无限累积(AI 微批还会再叠一份),拖慢备份与开案。
-                    try {
-                        int droppedRaw = com.zsjz.ai.module.aiclean.exec.AiDuckDb.dropTablesByPrefix("raw_");
-                        log.info("清洗完成,已清理预览临时表 raw_*: {} 张", droppedRaw);
-                    } catch (Exception e) {
-                        log.error("清洗完成后清理 raw_ 临时表失败(不影响清洗结果): {}", e.getMessage());
+                    // ★ 静默趟不能删:预览表同时是 AI 自建表(出口 B)的取数源,融合链路里
+                    //   1/2 级静默清洗之后还有 3 级 AI 入库,删早了 AI 只能回退重读源文件。
+                    //   静默批次的清理统一由 AiSmartCleanService 在整批结束后执行。
+                    if (!silent) {
+                        try {
+                            int droppedRaw = com.zsjz.ai.module.aiclean.exec.AiDuckDb.dropTablesByPrefix("raw_");
+                            log.info("清洗完成,已清理预览临时表 raw_*: {} 张", droppedRaw);
+                        } catch (Exception e) {
+                            log.error("清洗完成后清理 raw_ 临时表失败(不影响清洗结果): {}", e.getMessage());
+                        }
                     }
                     if (silent) {
                         // 静默趟:收口消息(终态/关会话/同时治理)由智能清洗批次统一决定时机,

+ 275 - 0
ai-server/src/test/java/com/zsjz/ai/module/aiclean/exec/AiDuckDbRawTest.java

@@ -0,0 +1,275 @@
+package com.zsjz.ai.module.aiclean.exec;
+
+import com.zsjz.ai.module.aiclean.entity.AiCleanField;
+import com.zsjz.ai.module.aiclean.rule.CompiledColumn;
+import com.zaxxer.hikari.HikariConfig;
+import com.zaxxer.hikari.HikariDataSource;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Path;
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * 预览表 {@code raw_{sheetId}} 作为 AI 取数源的读取契约,跑在真 DuckDB 上。
+ *
+ * <p>这段逻辑承担「不再重读源文件」的正确性:表头以上必须切掉、空行必须跳过、
+ * 物理行号必须原样带到 AI 动态表、分段读取不能丢行或重复。
+ * mock 一层 ResultSet 证明不了这些,所以在临时库里真建表真查。
+ *
+ * @author cc
+ * @since 2026/10/22
+ */
+class AiDuckDbRawTest {
+
+    @TempDir
+    Path dir;
+
+    private HikariDataSource ds;
+
+    @BeforeEach
+    void setUp() {
+        HikariConfig cfg = new HikariConfig();
+        cfg.setJdbcUrl("jdbc:duckdb:" + dir.resolve("case.duckdb"));
+        cfg.setDriverClassName("org.duckdb.DuckDBDriver");
+        // 写入器与预览表读取器各占一条连接(模拟真实入库:一边读 raw、一边写 ai_clean)
+        cfg.setMaximumPoolSize(4);
+        ds = new HikariDataSource(cfg);
+        AiDuckDb.useDataSource(ds);
+    }
+
+    @AfterEach
+    void tearDown() {
+        AiDuckDb.useDataSource(null);
+        try {
+            ds.close();
+        } catch (Exception ignored) {
+            // 临时库释放失败不影响断言
+        }
+    }
+
+    /** 建预览表:file_id / sheet_id / row_no + col_0..col_{n-1},全 VARCHAR(与 PreDataListener 一致) */
+    private void createRaw(long sheetId, int colCount, boolean withRowNo) throws Exception {
+        StringBuilder ddl = new StringBuilder("CREATE TABLE raw_" + sheetId
+                + " (file_id VARCHAR, sheet_id VARCHAR");
+        if (withRowNo) {
+            ddl.append(", row_no INTEGER");
+        }
+        for (int i = 0; i < colCount; i++) {
+            ddl.append(", col_").append(i).append(" VARCHAR");
+        }
+        ddl.append(")");
+        try (Connection c = ds.getConnection(); Statement st = c.createStatement()) {
+            st.execute(ddl.toString());
+        }
+    }
+
+    private void insertRaw(long sheetId, int colCount, Integer rowNo, String... values) throws Exception {
+        StringBuilder sql = new StringBuilder("INSERT INTO raw_" + sheetId + " VALUES (?, ?");
+        if (rowNo != null) {
+            sql.append(", ?");
+        }
+        for (int i = 0; i < colCount; i++) {
+            sql.append(", ?");
+        }
+        sql.append(")");
+        try (Connection c = ds.getConnection(); PreparedStatement ps = c.prepareStatement(sql.toString())) {
+            int idx = 1;
+            ps.setString(idx++, "1001");
+            ps.setString(idx++, String.valueOf(sheetId));
+            if (rowNo != null) {
+                ps.setInt(idx++, rowNo);
+            }
+            for (int i = 0; i < colCount; i++) {
+                ps.setString(idx++, i < values.length ? values[i] : null);
+            }
+            ps.executeUpdate();
+        }
+    }
+
+    @Test
+    @DisplayName("老结构预览表(没有 row_no)不可用 → 调用方回退读源文件")
+    void rejectsOldSchema() throws Exception {
+        createRaw(1L, 2, false);
+        insertRaw(1L, 2, null, "a", "b");
+
+        assertNull(AiDuckDb.rawSheet(1L, 1));
+        assertNull(AiDuckDb.readRawSample(1L, 0, 10, 1));
+    }
+
+    @Test
+    @DisplayName("空预览表不可用:预览写入失败不能被当成「源文件没有数据」")
+    void rejectsEmptyTable() throws Exception {
+        createRaw(2L, 2, true);
+
+        assertNull(AiDuckDb.rawSheet(2L, 1));
+    }
+
+    @Test
+    @DisplayName("表不存在返回 null,不抛异常")
+    void rejectsMissingTable() {
+        assertNull(AiDuckDb.rawSheet(404L, 1));
+    }
+
+    @Test
+    @DisplayName("半截预览表不可用:写库中断后行数与上传记录对不上,不能当全量")
+    void rejectsPartialTable() throws Exception {
+        long sheetId = 7L;
+        createRaw(sheetId, 1, true);
+        insertRaw(sheetId, 1, 1, "表头");
+        insertRaw(sheetId, 1, 2, "张三");
+
+        assertNull(AiDuckDb.rawSheet(sheetId, 5), "只写进去 2 行、上传记录 5 行 → 回退读源文件");
+    }
+
+    @Test
+    @DisplayName("切掉表头以上、跳过空行、物理行号原样回填(含空行造成的行号缺口)")
+    void skipsHeaderAndBlankRowsKeepingPhysicalRowNo() throws Exception {
+        long sheetId = 3L;
+        createRaw(sheetId, 3, true);
+        insertRaw(sheetId, 3, 1, "某某银行流水", null, null);   // 标题行
+        insertRaw(sheetId, 3, 2, "姓名", "金额", "账号");       // 表头
+        insertRaw(sheetId, 3, 3, "张三", "100.00", "6222");    // 数据
+        insertRaw(sheetId, 3, 5, null, null, null);            // 空行(Fesod 不会跳的话也会存在)
+        insertRaw(sheetId, 3, 6, "李四", "2,300.50", "6223");  // 数据(4 号空行没有落库 → 行号缺口)
+        insertRaw(sheetId, 3, 10, "王五", "5.00", "6224");
+
+        AiDuckDb.RawSheet raw = AiDuckDb.rawSheet(sheetId, 6);
+        assertNotNull(raw);
+        assertEquals(3, raw.colCount());
+        assertEquals(10L, raw.maxRowNo());
+
+        List<Integer> lineNos = new ArrayList<>();
+        List<String> firstCells = new ArrayList<>();
+        AiDuckDb.forEachRawRow(raw, 2, (row, lineNo) -> {
+            lineNos.add(lineNo);
+            firstCells.add(row.get(0));
+        });
+
+        assertEquals(List.of(3, 6, 10), lineNos, "表头以上与整空行都要跳过,行号保持原文件位置");
+        assertEquals(List.of("张三", "李四", "王五"), firstCells);
+    }
+
+    @Test
+    @DisplayName("分段读取覆盖全部行:不丢、不重、顺序稳定")
+    void pagingCoversAllRows() throws Exception {
+        long sheetId = 4L;
+        int rows = 12_345;
+        createRaw(sheetId, 2, true);
+        try (Connection c = ds.getConnection();
+             PreparedStatement ps = c.prepareStatement(
+                     "INSERT INTO raw_" + sheetId + " VALUES (?, ?, ?, ?, ?)")) {
+            for (int i = 1; i <= rows; i++) {
+                ps.setString(1, "1001");
+                ps.setString(2, String.valueOf(sheetId));
+                ps.setInt(3, i);
+                ps.setString(4, "v" + i);
+                ps.setString(5, "x" + i);
+                ps.addBatch();
+                if (i % 2000 == 0) {
+                    ps.executeBatch();
+                }
+            }
+            ps.executeBatch();
+        }
+
+        AiDuckDb.RawSheet raw = AiDuckDb.rawSheet(sheetId, rows);
+        assertNotNull(raw);
+        List<Integer> lineNos = new ArrayList<>();
+        AiDuckDb.forEachRawRow(raw, 0, (row, lineNo) -> lineNos.add(lineNo));
+
+        assertEquals(rows, lineNos.size(), "分段读取不能丢行");
+        assertEquals(1, lineNos.getFirst());
+        assertEquals(rows, lineNos.getLast());
+        for (int i = 1; i < lineNos.size(); i++) {
+            assertEquals(lineNos.get(i - 1) + 1, lineNos.get(i), "行序必须严格递增且不重复");
+        }
+    }
+
+    @Test
+    @DisplayName("抽样只取前 N 条非空行")
+    void sampleStopsAtLimit() throws Exception {
+        long sheetId = 5L;
+        createRaw(sheetId, 1, true);
+        for (int i = 1; i <= 10; i++) {
+            insertRaw(sheetId, 1, i, "v" + i);
+        }
+
+        List<Map<Integer, String>> sample = AiDuckDb.readRawSample(sheetId, 0, 3, 10);
+        assertNotNull(sample);
+        assertEquals(3, sample.size());
+        assertEquals("v1", sample.getFirst().get(0));
+    }
+
+    @Test
+    @DisplayName("边读预览表边写 AI 表:清洗结果与行号都落库")
+    void loadFromRawWhileWriting() throws Exception {
+        long sheetId = 6L;
+        createRaw(sheetId, 2, true);
+        insertRaw(sheetId, 2, 1, "姓名", "金额");
+        insertRaw(sheetId, 2, 2, "张三", "100.00");
+        insertRaw(sheetId, 2, 4, "李四", "200.00");
+
+        AiCleanField f = new AiCleanField();
+        f.setColumnName("c0");
+        f.setFieldNameCn("姓名");
+        f.setFieldNameEn("c0");
+        f.setFieldType("TEXT");
+        f.setRequired(0);
+        f.setFieldLen(0);
+        f.setFieldSort(1);
+        f.setColIndex(0);
+        List<AiCleanField> fields = List.of(f);
+        assertNull(AiDuckDb.createAiTable("ai_clean_raw_test", fields));
+
+        CompiledColumn col = CompiledColumn.of(0, "c0", "c0", "TEXT", false, 0, List.of());
+        AiDuckDb.RawSheet raw = AiDuckDb.rawSheet(sheetId, 3);
+        assertNotNull(raw);
+
+        List<Integer> storedRowNos = new ArrayList<>();
+        long written;
+        try (AiDuckDb.BatchWriter writer = AiDuckDb.openWriter("ai_clean_raw_test", List.of("c0"))) {
+            AiDuckDb.forEachRawRow(raw, 1, (row, lineNo) -> {
+                Map<String, Object> cleaned = AiRecordCleaner.cleanRow(row, List.of(col), new LinkedHashMap<>());
+                assertNotNull(cleaned);
+                Map<String, Object> toWrite = new LinkedHashMap<>(cleaned);
+                toWrite.put("id", (long) lineNo);
+                toWrite.put("case_id", 9L);
+                toWrite.put("file_id", 1001L);
+                toWrite.put("sheet_id", sheetId);
+                toWrite.put("block_no", 0);
+                toWrite.put("row_no", lineNo);
+                toWrite.put("ai_job_id", 88L);
+                toWrite.put("ai_rule_ver", 1);
+                toWrite.put("category", "PERSON");
+                storedRowNos.add(lineNo);
+                writer.add(toWrite);
+            });
+            written = writer.finish();
+        }
+
+        assertEquals(2L, written, "两行数据都要落库(整空行跳过,但没有空行)");
+        assertEquals(List.of(2, 4), storedRowNos, "原文件物理行号要原样传给 AI 表");
+        assertEquals(2L, AiDuckDb.count("ai_clean_raw_test"));
+
+        List<Map<String, Object>> page = AiDuckDb.selectPage("ai_clean_raw_test",
+                List.of("c0", "row_no"), "row_no", true, 1, 10);
+        assertEquals(2, page.size());
+        assertEquals(2, ((Number) page.get(0).get("row_no")).intValue());
+        assertEquals("张三", page.get(0).get("c0"));
+        assertEquals(4, ((Number) page.get(1).get("row_no")).intValue());
+    }
+}

+ 176 - 0
ai-server/src/test/java/com/zsjz/ai/module/dm/pre/PreDataListenerRawTest.java

@@ -0,0 +1,176 @@
+package com.zsjz.ai.module.dm.pre;
+
+import cn.hutool.extra.spring.SpringUtil;
+import com.baomidou.dynamic.datasource.DynamicRoutingDataSource;
+import com.zsjz.ai.common.config.DuckdbUnpooledDataSource;
+import com.zsjz.ai.common.model.dm.entity.FileInfo;
+import com.zsjz.ai.module.aiclean.exec.AiDuckDb;
+import org.apache.fesod.sheet.ExcelReader;
+import org.apache.fesod.sheet.FesodSheet;
+import org.apache.fesod.sheet.support.ExcelTypeEnum;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.springframework.context.ApplicationContext;
+import org.springframework.context.support.StaticApplicationContext;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * 上传预览表 {@code raw_{sheetId}} 的写入契约:AI 入库现在直接吃这张表,
+ * 列顺序、物理行号、行数任何一项错了都会让 AI 表整列错位或少行。
+ *
+ * <p>这里不 mock Fesod 也不 mock DuckDB:真 xlsx → 真 Fesod → 真 DuckDB 预览表,
+ * 再把 AI 侧的 {@link AiDuckDb#rawSheet} / {@link AiDuckDb#forEachRawRow} 接上去,
+ * 证明「上传写进去的」和「AI 读出来的」是同一份数据。
+ *
+ * @author cc
+ * @since 2026/10/22
+ */
+class PreDataListenerRawTest {
+
+    @TempDir
+    Path dir;
+
+    private DuckdbUnpooledDataSource ds;
+    private ApplicationContext previousContext;
+    private DynamicRoutingDataSource routing;
+
+    @BeforeEach
+    void setUp() {
+        // 必须用不池化的数据源:PreDataListener 会把连接强转成 DuckDBConnection 拿 Appender,
+        // Hikari 代理连接转不过去(生产走的也是 DuckdbUnpooledDataSource)
+        ds = new DuckdbUnpooledDataSource("jdbc:duckdb:" + dir.resolve("case.duckdb"),
+                "org.duckdb.DuckDBDriver");
+        AiDuckDb.useDataSource(ds);
+
+        routing = mock(DynamicRoutingDataSource.class);
+        when(routing.getDataSource("slave")).thenReturn(ds);
+
+        previousContext = SpringUtil.getApplicationContext();
+        StaticApplicationContext ctx = new StaticApplicationContext();
+        ctx.refresh();
+        ctx.getBeanFactory().registerSingleton("dataSource", routing);
+        new SpringUtil().setApplicationContext(ctx);
+    }
+
+    @AfterEach
+    void tearDown() {
+        new SpringUtil().setApplicationContext(previousContext);
+        AiDuckDb.useDataSource(null);
+        try {
+            ds.close();
+        } catch (Exception ignored) {
+            // 临时库释放失败不影响断言
+        }
+    }
+
+    private File writeWorkbook() throws Exception {
+        File f = dir.resolve("preview.xlsx").toFile();
+        try (XSSFWorkbook wb = new XSSFWorkbook()) {
+            Sheet sheet = wb.createSheet("经营者");
+            sheet.createRow(0).createCell(0).setCellValue("标题");
+            sheet.createRow(1);                              // 空行
+            Row header = sheet.createRow(2);
+            header.createCell(0).setCellValue("姓名");
+            header.createCell(1).setCellValue("金额");
+            Row first = sheet.createRow(3);
+            first.createCell(0).setCellValue("张三");
+            first.createCell(1).setCellValue("100.00");
+            sheet.createRow(4);                              // 空行
+            Row second = sheet.createRow(5);
+            second.createCell(0).setCellValue("李四");
+            second.createCell(1).setCellValue("200.00");
+            try (FileOutputStream out = new FileOutputStream(f)) {
+                wb.write(out);
+            }
+        }
+        return f;
+    }
+
+    private FileInfo parseWithPreview(File f) throws Exception {
+        List<FileInfo> infos = new ArrayList<>();
+        FileInfo root = new FileInfo();
+        root.setId(1000L);
+        root.setPid(0L);
+        root.setFileName(f.getName());
+        root.setFileType("XLSX");
+        infos.add(root);
+
+        PreDataListener listener = new PreDataListener(infos, f.getName(), 1000L, 1L);
+        try (InputStream in = Files.newInputStream(f.toPath());
+             ExcelReader reader = FesodSheet.read(in, listener).headRowNumber(0)
+                     .excelType(ExcelTypeEnum.XLSX).autoTrim(true)
+                     .registerConverter(new Str2NullConverter())
+                     .registerConverter(new DateNumberConverter()).build()) {
+            reader.readAll();
+        }
+        return infos.stream().filter(i -> i.getPid() != null && i.getPid() == 1000L).findFirst().orElse(null);
+    }
+
+    @Test
+    @DisplayName("上传写入的预览表带物理行号,AI 侧可直接读出表头之后的数据")
+    void previewTableFeedsAiLoader() throws Exception {
+        File f = writeWorkbook();
+        FileInfo sheet = parseWithPreview(f);
+        assertNotNull(sheet, "PreDataListener 必须登记 sheet 节点");
+        long sheetId = sheet.getId();
+        assertEquals(4, sheet.getDataNum(), "dataNum 记的是全部非空行数(标题+表头+2 行数据)");
+
+        // 预览表内容:file_id / sheet_id / row_no(物理行号,空行留缺口) / col_0 / col_1
+        List<Integer> rowNos = new ArrayList<>();
+        List<String> firstCol = new ArrayList<>();
+        try (Connection c = ds.getConnection(); Statement st = c.createStatement();
+             ResultSet rs = st.executeQuery("SELECT row_no, col_0 FROM raw_" + sheetId + " ORDER BY row_no")) {
+            while (rs.next()) {
+                rowNos.add(rs.getInt(1));
+                firstCol.add(rs.getString(2));
+            }
+        }
+        assertEquals(List.of(1, 3, 4, 6), rowNos);
+        assertEquals(List.of("标题", "姓名", "张三", "李四"), firstCol);
+
+        // AI 侧:行数校验通过 → 可用;表头行 3 之后只给数据行
+        AiDuckDb.RawSheet raw = AiDuckDb.rawSheet(sheetId, sheet.getDataNum());
+        assertNotNull(raw, "完整写入的预览表必须可被 AI 侧识别");
+        assertEquals(2, raw.colCount());
+
+        List<Integer> dataRowNos = new ArrayList<>();
+        List<String> dataNames = new ArrayList<>();
+        AiDuckDb.forEachRawRow(raw, 3, (row, lineNo) -> {
+            dataRowNos.add(lineNo);
+            dataNames.add(row.get(0));
+        });
+        assertEquals(List.of(4, 6), dataRowNos, "表头行 3 必须切掉,物理行号原样保留");
+        assertEquals(List.of("张三", "李四"), dataNames);
+    }
+
+    @Test
+    @DisplayName("行数与 dataNum 对不上时 AI 侧拒绝预览表(防半截写入)")
+    void partialPreviewIsRejected() throws Exception {
+        File f = writeWorkbook();
+        FileInfo sheet = parseWithPreview(f);
+        assertNotNull(sheet);
+        assertTrue(AiDuckDb.rawSheet(sheet.getId(), sheet.getDataNum() + 1) == null);
+    }
+}

+ 101 - 0
ai-server/src/test/java/com/zsjz/ai/module/dm/pre/ReadRowIndexParityTest.java

@@ -0,0 +1,101 @@
+package com.zsjz.ai.module.dm.pre;
+
+import com.zsjz.ai.module.aiclean.probe.AiRowReader;
+import org.apache.fesod.sheet.ExcelReader;
+import org.apache.fesod.sheet.FesodSheet;
+import org.apache.fesod.sheet.context.AnalysisContext;
+import org.apache.fesod.sheet.event.AnalysisEventListener;
+import org.apache.fesod.sheet.support.ExcelTypeEnum;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * 预览表 {@code row_no} 的取值依据:Fesod 的 {@code readSheetHolder} 与 {@code readRowHolder}
+ * 给出的必须是<b>原文件物理行号</b>,且空行被跳过后仍保留缺口。
+ *
+ * <p>AI 侧按这个行号切表头、跳过标题行、事后定位原始行;如果两个 holder 给的是
+ * 「第几个非空行」,有标题或空行的文件整列数据都会错位。这里用真 xlsx 把假设钉死。
+ *
+ * @author cc
+ * @since 2026/10/22
+ */
+class ReadRowIndexParityTest {
+
+    @TempDir
+    Path dir;
+
+    /** 标题 + 空行 + 表头 + 数据 + 空行 + 数据 */
+    private File writeWorkbook() throws Exception {
+        File f = dir.resolve("rows.xlsx").toFile();
+        try (XSSFWorkbook wb = new XSSFWorkbook()) {
+            Sheet sheet = wb.createSheet("s");
+            sheet.createRow(0).createCell(0).setCellValue("标题");
+            sheet.createRow(1);                         // 空行:Fesod ignoreEmptyRow 会跳过
+            Row header = sheet.createRow(2);
+            header.createCell(0).setCellValue("姓名");
+            header.createCell(1).setCellValue("金额");
+            Row first = sheet.createRow(3);
+            first.createCell(0).setCellValue("张三");
+            first.createCell(1).setCellValue("100.00");
+            sheet.createRow(4);                         // 空行
+            Row second = sheet.createRow(5);
+            second.createCell(0).setCellValue("李四");
+            second.createCell(1).setCellValue("200.00");
+            try (FileOutputStream out = new FileOutputStream(f)) {
+                wb.write(out);
+            }
+        }
+        return f;
+    }
+
+    @Test
+    @DisplayName("空行被跳过后,两个 holder 仍返回原文件物理行号(1/3/4/6)")
+    void rowIndexIsPhysicalAndKeepsGaps() throws Exception {
+        File f = writeWorkbook();
+        List<Integer> sheetHolder = new ArrayList<>();
+        List<Integer> rowHolder = new ArrayList<>();
+        List<Map<Integer, String>> previewRows = new ArrayList<>();
+        try (InputStream in = Files.newInputStream(f.toPath());
+             ExcelReader reader = FesodSheet.read(in, new AnalysisEventListener<Map<Integer, String>>() {
+                 @Override
+                 public void invoke(Map<Integer, String> data, AnalysisContext context) {
+                     int lineNo = context.readRowHolder().getRowIndex() + 1;
+                     sheetHolder.add(context.readSheetHolder().getRowIndex() + 1);
+                     rowHolder.add(lineNo);
+                     if (lineNo > 3) {
+                         previewRows.add(new java.util.LinkedHashMap<>(data));
+                     }
+                 }
+
+                 @Override
+                 public void doAfterAllAnalysed(AnalysisContext context) {
+                     // 本测试只关心行号与读值
+                 }
+             }).headRowNumber(0).excelType(ExcelTypeEnum.XLSX).autoTrim(true)
+                     .registerConverter(new Str2NullConverter())
+                     .registerConverter(new DateNumberConverter()).build()) {
+            reader.readAll();
+        }
+
+        assertEquals(List.of(1, 3, 4, 6), sheetHolder, "row_no 必须按原文件物理行号,空行留下的缺口保留");
+        assertEquals(sheetHolder, rowHolder, "两个 holder 的行号必须一致,否则预览表与 AI 读表器会错位");
+        // 预览表存进去的就是这份 Map;它与 AiRowReader 读出的值必须逐格一致,
+        // 否则「抽样校验通过」的结论不能外推到正式入库
+        assertEquals(AiRowReader.read(f, "s", 3, 0), previewRows);
+    }
+}