Jelajahi Sumber

feat(aiclean): 失败明细不再脱敏,完整展示原值

- AiRecordCleaner.preview 只做超长截断,不再掩码身份证/手机号/卡号
- 失败明细弹窗文案改为「原值预览」,直接展示完整原值
- 新增 07 样本(18 位坏证件)与界面截图,补充 TC-08 验收记录
- 补充单测:敏感值不被掩码
cc 2 hari lalu
induk
melakukan
9dba0e641a

+ 3 - 7
ai-electron/frontend/src/case/views/data/components/AiRejectModal.vue

@@ -15,7 +15,7 @@
       </span>
       <span class="ai-reject-summary__ok">成功 {{ successNum }}</span>
       <span class="ai-reject-summary__fail">失败 {{ failNum }}</span>
-      <span class="ai-reject-summary__hint">每一行失败都记录了阻塞列、原因与脱敏后的原值</span>
+      <span class="ai-reject-summary__hint">每一行失败都记录了阻塞列、原因与原值</span>
     </div>
 
     <Table
@@ -101,7 +101,7 @@
     { title: '行号', dataIndex: 'rowNo', key: 'rowNo', width: 90 },
     { title: '阻塞列', dataIndex: 'column', key: 'column', width: 180, ellipsis: true },
     { title: '原因', dataIndex: 'reason', key: 'reason', width: 200 },
-    { title: '原值预览(脱敏)', dataIndex: 'value', key: 'value', ellipsis: true },
+    { title: '原值预览', dataIndex: 'value', key: 'value', ellipsis: true },
     { title: '该行其他错误', dataIndex: 'others', key: 'others', ellipsis: true },
   ];
 
@@ -138,11 +138,7 @@
     // 优先展示中文表头: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})` : ''}`;
+    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),

+ 1 - 1
ai-server/src/main/java/com/zsjz/ai/module/aiclean/api/AiCleanController.java

@@ -85,7 +85,7 @@ public class AiCleanController {
      * 某个 job 的行级失败明细(分页)。
      *
      * <p>返回 {@code {records,total,size,current}};每条包含 row_no / 阻塞列 / 原因 /
-     * 脱敏后的值预览 / 该行全部列错误的 JSON。跨案件查询会被拒绝。
+     * 原值预览 / 该行全部列错误的 JSON。跨案件查询会被拒绝。
      */
     @GetMapping("/job/rejections")
     public Result<Map<String, Object>> rejections(@RequestParam Long jobId,

+ 5 - 12
ai-server/src/main/java/com/zsjz/ai/module/aiclean/exec/AiRecordCleaner.java

@@ -15,7 +15,6 @@ import java.util.ArrayList;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Set;
 import java.util.regex.Pattern;
 
 /**
@@ -42,10 +41,7 @@ public final class AiRecordCleaner {
     /** 类型化列的值清洗:只去控制字符,保留 + - . 供数字/日期解析 */
     private static final Pattern VALUE_JUNK = Pattern.compile("[\\t\\r\\n'’‘\\u3000]+");
 
-    /** 需要脱敏后再进日志/失败记录的类型 */
-    private static final Set<String> SENSITIVE = Set.of("PHONE", "ACCT_CARD_NO", "ID_CARD_NUMBER");
-
-    /** 失败记录里的值预览长度 */
+    /** 失败记录里的值预览长度:只做超长截断,不做脱敏 */
     private static final int PREVIEW_LEN = 80;
 
     private AiRecordCleaner() {
@@ -160,7 +156,7 @@ public final class AiRecordCleaner {
         if (!coerced.isOk()) {
             if (errors != null) {
                 errors.putIfAbsent(col.columnName(),
-                        "类型转换失败(" + type + ") 值=" + preview(v, type));
+                        "类型转换失败(" + type + ") 值=" + preview(v));
             }
             return null;
         }
@@ -200,18 +196,15 @@ public final class AiRecordCleaner {
 
     private static ColumnError error(CompiledColumn col, AiRejectReason reason, String raw) {
         return new ColumnError(col.colIndex(), col.columnName(), reason,
-                preview(raw, normalizeType(col.fieldType())), reason.blocking());
+                preview(raw), reason.blocking());
     }
 
-    /** 失败记录/日志里的原值预览:敏感类型掩码,其余截断 */
-    static String preview(String raw, String type) {
+    /** 失败记录/日志里的原值预览:只截断超长文本,不脱敏 */
+    static String preview(String raw) {
         if (raw == null) {
             return null;
         }
         String value = raw.trim();
-        if (SENSITIVE.contains(type) && value.length() > 6) {
-            value = value.substring(0, 3) + "***" + value.substring(value.length() - 2);
-        }
         return value.length() <= PREVIEW_LEN ? value
                 : StrUtil.subPre(value, PREVIEW_LEN) + "...";
     }

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

@@ -4,7 +4,7 @@ package com.zsjz.ai.module.aiclean.model;
  * 一行被拒绝的持久化记录(案件 DuckDB 的 {@code ai_clean_reject})。
  *
  * <p>每个被拒绝的行一条:{@code blockingCol*} 是第一个阻塞列,
- * {@code errorsJson} 保留该行<b>所有列</b>的错误(列名/原因/脱敏值预览),
+ * {@code errorsJson} 保留该行<b>所有列</b>的错误(列名/原因/原值预览),
  * 既能按行回答「为什么没进去」,也能按列聚合出「哪一列错得最多」。
  *
  * @param jobId             AI 清洗任务 id
@@ -17,7 +17,7 @@ package com.zsjz.ai.module.aiclean.model;
  * @param blockingColName   第一个阻塞列的物理列名
  * @param blockingColNameCn 第一个阻塞列的中文表头(可能为空;前端/日志优先展示)
  * @param reason            阻塞原因({@code AiRejectReason#name()})
- * @param errorsJson        该行全部列错误的 JSON(已脱敏)
+ * @param errorsJson        该行全部列错误的 JSON(原值预览,超长截断)
  * @author cc
  * @since 2026/10/22
  */

+ 1 - 1
ai-server/src/main/java/com/zsjz/ai/module/aiclean/model/ColumnError.java

@@ -8,7 +8,7 @@ import com.zsjz.ai.module.aiclean.enums.AiRejectReason;
  * @param colIndex     文件列序号(0 基)
  * @param columnName   动态表物理列名
  * @param reason       失败原因
- * @param valuePreview 原值预览(敏感类型已脱敏、长度已截断)
+ * @param valuePreview 原值预览(超长截断,不脱敏)
  * @param blocking     是否导致整行拒绝
  * @author cc
  * @since 2026/10/22

+ 11 - 0
ai-server/src/test/java/com/zsjz/ai/module/aiclean/exec/AiRecordCleanerTest.java

@@ -171,6 +171,17 @@ class AiRecordCleanerTest {
         assertEquals(AiRejectReason.REQUIRED_FUNC_ERROR, result.firstBlocking().reason());
     }
 
+    @Test
+    @DisplayName("失败记录不脱敏:身份证等敏感原值完整保留")
+    void sensitiveValueNotMasked() {
+        AiRowCleanResult result = AiRecordCleaner.cleanRowDetailed(
+                row(0, "110105194912310021"),
+                List.of(col(0, "id", "ID_CARD_NUMBER", true)));
+        assertTrue(result.rejected());
+        assertEquals("110105194912310021", result.firstBlocking().valuePreview(),
+                "失败明细/日志要展示完整原值,不能出现 ***");
+    }
+
     @Test
     @DisplayName("日期列按目标类型转成 LocalDate,而不是把字符串交给 DuckDB")
     void dateTyped() {

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

@@ -4,7 +4,7 @@
 
 验证本轮改造在真实案件、真实数据源上的表现:
 
-- 行级失败可追溯:每一行为什么没进去、因为哪一列、原值(脱敏)。
+- 行级失败可追溯:每一行为什么没进去、因为哪一列、原值。
 - 必填列坏值整行拒绝;非必填坏值置空、行照常入库。
 - 成功 / 失败 / 警告 / 未处理条数口径正确。
 - 日期、时间、金额、数字、电话、身份证、卡号的类型化与入库。
@@ -37,6 +37,7 @@
 | `04_csv_types.csv` | 与 01 同数据,UTF-8 CSV | CSV 探测/入库;可选列脏值只置空 |
 | `05_required_bad.xlsx` | 同 9 列表头,20 行;第 1 行身份证 `12345` 非法 | 必填列坏值整行拒绝 + 失败明细 |
 | `06_csv_clean.csv` | 同 9 列表头,7 行全合法 | CSV 正常入库 |
+| `07_required_bad_id18.xlsx` | 同 9 列表头,20 行;第 1 行身份证 `110105194912310021`(校验位错) | 必填列整行拒绝 + 失败明细完整展示原值(不脱敏) |
 
 01 的 9 列与脏值覆盖:
 
@@ -132,7 +133,7 @@
 ### TC-07 单测覆盖
 
 `mvn -pl ai-server -Dtest='com.zsjz.ai.module.aiclean.**.*Test,com.zsjz.ai.module.dm.**.*Test' test`:
-**192 个用例通过**(5 个 live 跳过)。覆盖:
+**193 个用例通过**(5 个 live 跳过)。覆盖:
 
 - 行级失败原因、必填整行拒绝、非必填置空计数
 - `AiValueCoercer` 日期/时间/金额/数字/电话/身份证/卡号格式矩阵
@@ -140,6 +141,22 @@
 - `sameOrderedHeaders` 复用安全
 - 真 DuckDB 的类型化入库、reject 表分页/清理、先写后删
 
+### TC-08 必填身份证整行拒绝 + 原值不脱敏(07)
+
+- 前置:把 AI 模板字段 `c1`(身份证号)临时改为 `required=1`。
+- 操作:上传 `07`(20 行,第 1 行身份证为校验位错误的完整 18 位 `110105194912310021`)→ 智能清洗。
+- 预期:19 行入库、1 行拒绝;失败明细完整显示 18 位原值(不脱敏、只做超长截断)。
+- 实测:`loadedRows=19, rejectedRows=1`;文件列表「导入成功 19 / 导入失败 1」。
+- 失败明细弹窗:
+  - 行号 `2`
+  - 阻塞列 `c1(身份证号)`(有中文名时括号展示)
+  - 原因「必填列类型不合法」
+  - 原值预览 `110105194912310021`(完整 18 位,无掩码)
+- 截图:[ai-reject-modal-id18.png](screenshots/ai-reject-modal-id18.png)
+- 后置:`required` 已重置为 0。
+- 任务日志:进度页内只保留内存日志,离开页面即清空;导入文件页不再提供「任务日志」入口,
+  旧版本遗留的 `localStorage.case_clean_task_log` 会在进入进度页时清除。
+
 ## 5. 本次发现并修复的问题
 
 1. **非必填脏值被当成硬错误**:`AiRuleVerifier` 用 `cleanRow` 的全部错误计算列错误率,

TEMPAT SAMPAH
docs/ai-clean-e2e/fixtures/07_required_bad_id18.xlsx


+ 11 - 0
docs/ai-clean-e2e/generate_fixtures.py

@@ -91,6 +91,17 @@ def main():
             "2026-01-05 10:30:00", "100.00", "1", "ok",
         ])
     save_xlsx("05_required_bad.xlsx", "经营者信息", rows05)
+    # 07:同 05,但坏证件是完整 18 位(校验位错),验证失败明细不脱敏、原值完整展示
+    rows07 = []
+    for i in range(1, 21):
+        bad = i == 1
+        rows07.append([
+            "坏18位证件行" if bad else f"正常{i}",
+            "110105194912310021" if bad else "11010519491231002X",
+            "13800000000", "6222020200001234", "2026-01-05",
+            "2026-01-05 10:30:00", "100.00", "1", "ok",
+        ])
+    save_xlsx("07_required_bad_id18.xlsx", "经营者信息", rows07)
     # 06:干净 CSV(验证 CSV 正常入库)
     save_csv("06_csv_clean.csv", [
         [f"CSV正常{i}", "11010519491231002X", "13800000000", "6222020200001234",

TEMPAT SAMPAH
docs/ai-clean-e2e/screenshots/ai-reject-modal-id18.png