Procházet zdrojové kódy

feat(aiclean): 失败日志与明细展示中文表头

- 失败日志/ai_clean_reject/明细弹窗的阻塞列从 c1(列 1)改为 c1(身份证号):
  有中文表头时括号里展示中文,没有时保留列序号
- ai_clean_reject 新增 blocking_col_cn(懒建表 + 已有案件 ALTER 补列),
  errors_json/samples/detail.load.columns 同步带 columnCn
- 失败弹窗「该行其他错误」也带中文列名;接口返回 blocking_col_cn
- 补充单测断言与 E2E 截图

测试:aiclean + dm 192 个用例通过(5 个 live 跳过)
cc před 5 dny
rodič
revize
edee47c468

+ 14 - 2
ai-electron/frontend/src/case/views/data/components/AiRejectModal.vue

@@ -52,6 +52,7 @@
     row_no?: number | string | null;
     blocking_col_index?: number | string | null;
     blocking_col_name?: string | null;
+    blocking_col_cn?: string | null;
     reason?: string | null;
     errors_json?: string | null;
   }
@@ -127,14 +128,25 @@
     const blocking = errors.find((e: any) => e?.blocking) || errors[0] || {};
     const others = errors
       .filter((e: any) => e !== blocking)
-      .map((e: any) => `${e?.column || '?'}:${reasonLabel(e?.reason)}`)
+      .map((e: any) => {
+        const cn = String(e?.columnCn ?? '').trim();
+        const col = e?.column || '?';
+        return `${col}${cn ? `(${cn})` : ''}:${reasonLabel(e?.reason)}`;
+      })
       .join(';');
     const col = raw.blocking_col_name || blocking?.column || '';
+    // 优先展示中文表头:c1(身份证号);没有中文名时保留列序号
+    const cn = String(raw.blocking_col_cn ?? blocking?.columnCn ?? '').trim();
     const idx = raw.blocking_col_index ?? blocking?.colIndex;
+    const column = !col
+      ? '-'
+      : cn
+        ? `${col}(${cn})`
+        : `${col}${idx != null && idx !== '' ? `(列 ${idx})` : ''}`;
     return {
       rowKey: `${raw.row_no ?? ''}-${idx ?? ''}-${raw.reason ?? ''}`,
       rowNo: Number(raw.row_no ?? 0),
-      column: col ? `${col}${idx != null && idx !== '' ? `(列 ${idx})` : ''}` : '-',
+      column,
       reason: reasonLabel(raw.reason || blocking?.reason),
       value: String(blocking?.value ?? '-'),
       others: others || '-',

+ 11 - 5
ai-server/src/main/java/com/zsjz/ai/module/aiclean/exec/AiDuckDb.java

@@ -332,7 +332,10 @@ public final class AiDuckDb {
             st.executeUpdate("CREATE TABLE IF NOT EXISTS ai_clean_reject ("
                     + "id BIGINT PRIMARY KEY, job_id BIGINT, case_id BIGINT, file_id BIGINT, sheet_id BIGINT,"
                     + "block_no INTEGER, row_no INTEGER, blocking_col_index INTEGER, blocking_col_name VARCHAR,"
-                    + "reason VARCHAR(50), errors_json VARCHAR, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP)");
+                    + "blocking_col_cn VARCHAR, reason VARCHAR(50), errors_json VARCHAR,"
+                    + "create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP)");
+            // 已有案件 2026-10-22 之前的失败表没有中文列名列,补上(新表 CREATE 已包含)
+            st.executeUpdate("ALTER TABLE ai_clean_reject ADD COLUMN IF NOT EXISTS blocking_col_cn VARCHAR");
             st.executeUpdate("CREATE INDEX IF NOT EXISTS idx_ai_clean_reject_job "
                     + "ON ai_clean_reject (job_id, row_no)");
         }
@@ -349,7 +352,8 @@ public final class AiDuckDb {
             return;
         }
         String sql = "INSERT INTO ai_clean_reject (id, job_id, case_id, file_id, sheet_id, block_no, row_no,"
-                + " blocking_col_index, blocking_col_name, reason, errors_json) VALUES (?,?,?,?,?,?,?,?,?,?,?)";
+                + " blocking_col_index, blocking_col_name, blocking_col_cn, reason, errors_json)"
+                + " VALUES (?,?,?,?,?,?,?,?,?,?,?,?)";
         try (Connection c = caseDb().getConnection()) {
             ensureRejectTable(c);
             try (PreparedStatement ps = c.prepareStatement(sql)) {
@@ -367,9 +371,10 @@ public final class AiDuckDb {
                         ps.setInt(8, r.blockingColIndex());
                     }
                     ps.setString(9, r.blockingColName());
-                    ps.setString(10, r.reason());
+                    ps.setString(10, r.blockingColNameCn());
+                    ps.setString(11, r.reason());
                     String json = r.errorsJson();
-                    ps.setString(11, json == null || json.length() <= 8000 ? json : json.substring(0, 8000));
+                    ps.setString(12, json == null || json.length() <= 8000 ? json : json.substring(0, 8000));
                     ps.addBatch();
                 }
                 ps.executeBatch();
@@ -396,7 +401,8 @@ public final class AiDuckDb {
         try (Connection c = caseDb().getConnection()) {
             ensureRejectTable(c);
             try (PreparedStatement ps = c.prepareStatement(
-                    "SELECT row_no, blocking_col_index, blocking_col_name, reason, errors_json, create_time"
+                    "SELECT row_no, blocking_col_index, blocking_col_name, blocking_col_cn,"
+                            + " reason, errors_json, create_time"
                             + " FROM ai_clean_reject" + where + " ORDER BY row_no, blocking_col_index"
                             + " LIMIT ? OFFSET ?")) {
                 int idx = 1;

+ 45 - 11
ai-server/src/main/java/com/zsjz/ai/module/aiclean/exec/AiRejectCollector.java

@@ -34,6 +34,7 @@ public final class AiRejectCollector {
     private final Long fileId;
     private final Long sheetId;
     private final Integer blockNo;
+    private final Map<String, String> columnNameCn;
     private final int sampleLimit;
     private final boolean logAll;
 
@@ -48,12 +49,13 @@ public final class AiRejectCollector {
     private final List<Map<String, Object>> samples = new ArrayList<>();
 
     public AiRejectCollector(Long jobId, Long caseId, Long fileId, Long sheetId, Integer blockNo,
-                             int sampleLimit, boolean logAll) {
+                             Map<String, String> columnNameCn, int sampleLimit, boolean logAll) {
         this.jobId = jobId;
         this.caseId = caseId;
         this.fileId = fileId;
         this.sheetId = sheetId;
         this.blockNo = blockNo;
+        this.columnNameCn = columnNameCn == null ? Map.of() : columnNameCn;
         this.sampleLimit = Math.max(0, sampleLimit);
         this.logAll = logAll;
     }
@@ -78,21 +80,22 @@ public final class AiRejectCollector {
             ColumnError first = result.firstBlocking();
             AiRejectReason reason = first == null ? AiRejectReason.REQUIRED_EMPTY : first.reason();
             String column = first == null ? null : first.columnName();
+            String columnCn = blankToNull(cnOf(column));
             reasonCounts.merge(reason.name(), 1, Integer::sum);
             if (column != null) {
-                columnCounts.merge(column, 1, Integer::sum);
+                // 统计键带中文表头,排障时一眼能看出是哪一列
+                columnCounts.merge(display(column, columnCn), 1, Integer::sum);
             }
             pending.add(new AiRejectRecord(jobId, caseId, fileId, sheetId, blockNo, (int) rowNo,
-                    first == null ? null : first.colIndex(), column, reason.name(),
+                    first == null ? null : first.colIndex(), column, columnCn, reason.name(),
                     errorsJson(result.errors())));
             if (samples.size() < MAX_SAMPLES) {
-                samples.add(sample(rowNo, first));
+                samples.add(sample(rowNo, first, columnCn));
             }
             if (logAll || rejected <= sampleLimit) {
-                log.warn("AI 清洗拒绝: jobId={}, fileId={}, sheetId={}, rowNo={}, col={}({}), reason={}, value={}",
+                log.warn("AI 清洗拒绝: jobId={}, fileId={}, sheetId={}, rowNo={}, col={}, reason={}, value={}",
                         jobId, fileId, sheetId, rowNo,
-                        first == null ? null : first.columnName(),
-                        first == null ? null : first.colIndex(),
+                        displayWithIndex(column, columnCn, first == null ? null : first.colIndex()),
                         reason.name(),
                         first == null ? null : first.valuePreview());
             }
@@ -105,7 +108,8 @@ public final class AiRejectCollector {
             warned++;
             for (ColumnError e : result.errors()) {
                 if (!e.blocking()) {
-                    optionalCounts.merge(e.columnName() + ":" + e.reason().name(), 1, Integer::sum);
+                    optionalCounts.merge(display(e.columnName(), blankToNull(cnOf(e.columnName())))
+                            + ":" + e.reason().name(), 1, Integer::sum);
                 }
             }
         }
@@ -120,7 +124,7 @@ public final class AiRejectCollector {
                 "reason", AiRejectReason.WRITER_ERROR.name(),
                 "value", message == null ? "" : message)));
         pending.add(new AiRejectRecord(jobId, caseId, fileId, sheetId, blockNo, (int) rowNo,
-                null, null, AiRejectReason.WRITER_ERROR.name(), detail));
+                null, null, null, AiRejectReason.WRITER_ERROR.name(), detail));
         flush();
     }
 
@@ -160,13 +164,14 @@ public final class AiRejectCollector {
         return Json.toStr(out);
     }
 
-    private static String errorsJson(List<ColumnError> errors) {
+    private String errorsJson(List<ColumnError> errors) {
         List<Map<String, Object>> list = new ArrayList<>();
         if (errors != null) {
             for (ColumnError e : errors) {
                 Map<String, Object> m = new LinkedHashMap<>();
                 m.put("colIndex", e.colIndex());
                 m.put("column", e.columnName());
+                m.put("columnCn", blankToNull(cnOf(e.columnName())));
                 m.put("reason", e.reason() == null ? null : e.reason().name());
                 m.put("value", e.valuePreview());
                 m.put("blocking", e.blocking());
@@ -176,13 +181,42 @@ public final class AiRejectCollector {
         return Json.toStr(list);
     }
 
-    private static Map<String, Object> sample(long rowNo, ColumnError first) {
+    private Map<String, Object> sample(long rowNo, ColumnError first, String columnCn) {
         Map<String, Object> m = new LinkedHashMap<>();
         m.put("rowNo", rowNo);
         m.put("column", first == null ? null : first.columnName());
+        m.put("columnCn", columnCn);
         m.put("colIndex", first == null ? null : first.colIndex());
         m.put("reason", first == null ? null : first.reason().name());
         m.put("value", first == null ? null : first.valuePreview());
         return m;
     }
+
+    private String cnOf(String column) {
+        return column == null ? null : columnNameCn.get(column);
+    }
+
+    /** 统计/展示用:c1(身份证号);没有中文名时退回 c1 */
+    private static String display(String column, String columnCn) {
+        if (column == null) {
+            return "";
+        }
+        return columnCn == null || columnCn.isBlank() || columnCn.equals(column)
+                ? column : column + "(" + columnCn + ")";
+    }
+
+    /** 日志用:没有中文名时保留列序号,c1(列 1) */
+    private static String displayWithIndex(String column, String columnCn, Integer colIndex) {
+        if (column == null) {
+            return null;
+        }
+        if (columnCn != null && !columnCn.isBlank() && !columnCn.equals(column)) {
+            return column + "(" + columnCn + ")";
+        }
+        return colIndex == null ? column : column + "(列 " + colIndex + ")";
+    }
+
+    private static String blankToNull(String value) {
+        return value == null || value.isBlank() ? null : value;
+    }
 }

+ 2 - 0
ai-server/src/main/java/com/zsjz/ai/module/aiclean/model/AiRejectRecord.java

@@ -15,6 +15,7 @@ package com.zsjz.ai.module.aiclean.model;
  * @param rowNo             原文件物理行号(1 基)
  * @param blockingColIndex  第一个阻塞列的文件列序号(0 基)
  * @param blockingColName   第一个阻塞列的物理列名
+ * @param blockingColNameCn 第一个阻塞列的中文表头(可能为空;前端/日志优先展示)
  * @param reason            阻塞原因({@code AiRejectReason#name()})
  * @param errorsJson        该行全部列错误的 JSON(已脱敏)
  * @author cc
@@ -28,6 +29,7 @@ public record AiRejectRecord(Long jobId,
                              Integer rowNo,
                              Integer blockingColIndex,
                              String blockingColName,
+                             String blockingColNameCn,
                              String reason,
                              String errorsJson) {
 }

+ 9 - 1
ai-server/src/main/java/com/zsjz/ai/module/aiclean/service/AiCleanService.java

@@ -530,8 +530,16 @@ public class AiCleanService {
         final AiCleanTemplate tplF = tpl;
         List<String> cols = aiPlan.fields().stream().map(AiCleanField::getColumnName).toList();
         int headerRow = ev.primaryBlock().headerRow();
+        // 物理列名 c0/c1 → 原表头中文名:失败日志/明细弹窗要展示「c1(身份证号)」而不是只有 c1
+        Map<String, String> columnNameCn = new LinkedHashMap<>();
+        for (AiCleanField f : aiPlan.fields()) {
+            if (f.getColumnName() != null) {
+                columnNameCn.put(f.getColumnName(), f.getFieldNameCn());
+            }
+        }
         AiRejectCollector rejects = new AiRejectCollector(job.getId(), job.getCaseId(), job.getFileId(),
-                job.getSheetId(), ev.primaryBlock().blockNo(), rejectLogSample, rejectLogAll);
+                job.getSheetId(), ev.primaryBlock().blockNo(), columnNameCn,
+                rejectLogSample, rejectLogAll);
         long written = -1L;
         try (AiDuckDb.BatchWriter writer = AiDuckDb.openWriter(tpl.getTableNameEn(), cols)) {
             // 行数据优先取自上传预览表:不用为了入库再把整份源文件重新解析一遍。

+ 3 - 2
ai-server/src/test/java/com/zsjz/ai/module/aiclean/exec/AiDuckDbRejectTest.java

@@ -53,7 +53,7 @@ class AiDuckDbRejectTest {
     }
 
     private static AiRejectRecord record(long jobId, int rowNo, String reason) {
-        return new AiRejectRecord(jobId, 9L, 1001L, 2001L, 0, rowNo, 2, "c2", reason,
+        return new AiRejectRecord(jobId, 9L, 1001L, 2001L, 0, rowNo, 2, "c2", "金额", reason,
                 "[{\"column\":\"c2\",\"reason\":\"" + reason + "\"}]");
     }
 
@@ -80,6 +80,7 @@ class AiDuckDbRejectTest {
         Map<String, Object> first = (Map<String, Object>) ((List<?>) filtered.get("records")).getFirst();
         assertEquals(4, ((Number) first.get("row_no")).intValue());
         assertEquals("c2", first.get("blocking_col_name"));
+        assertEquals("金额", first.get("blocking_col_cn"), "失败明细要能读回中文列名");
     }
 
     @Test
@@ -88,7 +89,7 @@ class AiDuckDbRejectTest {
         AiDuckDb.insertRejects(List.of(
                 record(1L, 4, "REQUIRED_EMPTY"),
                 record(2L, 6, "REQUIRED_EMPTY"),
-                new AiRejectRecord(3L, 9L, 9999L, 8888L, 0, 7, 1, "c1", "REQUIRED_EMPTY", "[]")));
+                new AiRejectRecord(3L, 9L, 9999L, 8888L, 0, 7, 1, "c1", "姓名", "REQUIRED_EMPTY", "[]")));
 
         assertEquals(1L, AiDuckDb.deleteRejectsByJob(1L));
         assertEquals(0L, ((Number) AiDuckDb.rejectPage(1L, null, 1, 10).get("total")).longValue());

+ 10 - 4
ai-server/src/test/java/com/zsjz/ai/module/aiclean/service/AiRejectCollectorTest.java

@@ -77,7 +77,7 @@ class AiRejectCollectorTest {
     @Test
     @DisplayName("逐行收集:拒绝/警告计数、失败表、汇总 JSON")
     void collectAndSummarize() {
-        AiRejectCollector c = new AiRejectCollector(1L, 9L, 1001L, 2001L, 0, 20, false);
+        AiRejectCollector c = new AiRejectCollector(1L, 9L, 1001L, 2001L, 0, java.util.Map.of("c1", "身份证号", "c2", "姓名"), 20, false);
         c.countSource();
         c.record(2, rejected(1, "c1", AiRejectReason.REQUIRED_TYPE_INVALID));
         c.countSource();
@@ -94,17 +94,23 @@ class AiRejectCollectorTest {
         c.flush();
         Map<String, Object> page = AiDuckDb.rejectPage(1L, null, 1, 10);
         assertEquals(2L, page.get("total"), "两个被拒绝的行各一条记录");
+        @SuppressWarnings("unchecked")
+        Map<String, Object> firstRecord = (Map<String, Object>) ((List<?>) page.get("records")).getFirst();
+        assertEquals("身份证号", firstRecord.get("blocking_col_cn"),
+                "失败记录要带中文表头,前端展示 c1(身份证号)");
 
         String summary = c.summaryJson(2, 0);
         assertTrue(summary.contains("\"source\":4"));
         assertTrue(summary.contains("\"rejected\":2"));
         assertTrue(summary.contains("\"warned\":1"));
+        assertTrue(summary.contains("c1(身份证号)"), "汇总的列统计也要带中文名");
+        assertTrue(summary.contains("\"columnCn\":\"身份证号\""), "样例里要带中文列名");
     }
 
     @Test
     @DisplayName("成功任务行数守恒:total = loaded + rejected")
     void successCounts() {
-        AiRejectCollector c = new AiRejectCollector(2L, 9L, 1001L, 2001L, 0, 20, false);
+        AiRejectCollector c = new AiRejectCollector(2L, 9L, 1001L, 2001L, 0, java.util.Map.of("c1", "身份证号", "c2", "姓名"), 20, false);
         for (int i = 0; i < 10; i++) {
             c.countSource();
             if (i < 3) {
@@ -126,7 +132,7 @@ class AiRejectCollectorTest {
     @Test
     @DisplayName("失败任务:最终表无新增行,剩余行计入未处理")
     void failureCounts() {
-        AiRejectCollector c = new AiRejectCollector(3L, 9L, 1001L, 2001L, 0, 20, false);
+        AiRejectCollector c = new AiRejectCollector(3L, 9L, 1001L, 2001L, 0, java.util.Map.of("c1", "身份证号", "c2", "姓名"), 20, false);
         for (int i = 0; i < 10; i++) {
             c.countSource();
             if (i < 3) {
@@ -147,7 +153,7 @@ class AiRejectCollectorTest {
     @Test
     @DisplayName("写入异常单独记一条 WRITER_ERROR")
     void writerError() {
-        AiRejectCollector c = new AiRejectCollector(4L, 9L, 1001L, 2001L, 0, 20, false);
+        AiRejectCollector c = new AiRejectCollector(4L, 9L, 1001L, 2001L, 0, java.util.Map.of("c1", "身份证号", "c2", "姓名"), 20, false);
         c.countSource();
         c.recordWriterError(9, "connection reset");
         c.flush();

+ 4 - 2
docs/ai-clean-e2e/README.md

@@ -78,13 +78,15 @@
 
   ```json
   {"row_no":2,"blocking_col_index":1,"blocking_col_name":"c1",
+   "blocking_col_cn":"身份证号",
    "reason":"REQUIRED_TYPE_INVALID",
-   "errors_json":"[{\"colIndex\":1,\"column\":\"c1\",\"reason\":\"REQUIRED_TYPE_INVALID\",\"value\":\"12345\",\"blocking\":true}]"}
+   "errors_json":"[{\"colIndex\":1,\"column\":\"c1\",\"columnCn\":\"身份证号\",\"reason\":\"REQUIRED_TYPE_INVALID\",\"value\":\"12345\",\"blocking\":true}]"}
   ```
 
+- 失败日志:`AI 清洗拒绝: ... rowNo=2, col=c1(身份证号), reason=REQUIRED_TYPE_INVALID, value=12345`
 - 界面:导入文件列表 → 展开文件 → 工作表行「导入失败 1」→ 弹窗显示:
   - 文件 / 工作表、**成功 19 / 失败 1**
-  - 行号 2、阻塞列 `c1(列 1)`、原因「必填列类型不合法」、原值预览 `12345`
+  - 行号 2、阻塞列 `c1(身份证号)`、原因「必填列类型不合法」、原值预览 `12345`
 - 截图:[ai-reject-modal.png](screenshots/ai-reject-modal.png)
 - 后置:`required` 已重置为 0,避免影响后续样本。
 

binární
docs/ai-clean-e2e/screenshots/ai-reject-modal.png