Przeglądaj źródła

feat: AI 动态表全列还原 + 导入文件/清洗进度页多项修复

导入文件管理(importList.vue):
- sheet 子行不再展示「重新清洗/删除/下载」按钮
- 点击 sheet 行「导入失败」按 fileId=父文件、sheetId=该 sheet 查日志(修复弹窗空白)
- 重新清洗入口记录清洗来源为自主页面

清洗进度页(cleanProgress.vue / upload.vue / importProgressSession.ts):
- 完成面板按钮按「自主页面/智能页面」来源区分,不再用 fused 判断
- 完成态「导入失败」数字固定红色
- 100% 后治理迟迟不收尾时加兜底看门狗(治理未启动 3s / 已启动静默 15s),异常也能进完成面板

AI 清洗(aiclean / dm):
- AI 动态表按全部非空表头建列:AI 绑定的列用其字段名/类型,未识别列按原值 TEXT 保留;
  ai_clean_field / 治理树不再只剩 AI 命中的几个字段;历史窄表复用时自动重建
- 动态表物理名改为 ai_clean_{模板id}
- 动态表中文名:sheet 名 / 文件名+sheetN / CSV 文件名
- 同文件同 sheet 重新清洗先清旧行,避免动态表重复追加
- 清洗完成(含纯 AI 批次)后清理案件库 raw_* 预览临时表
- 删除导入文件同步清理 AI 动态表行与 AI 任务,并刷新治理树计数
- 治理树 AI 节点下发字符串 aiTemplateId(雪花 id 超 JS 安全整数,前端转换丢精度导致查不到数据)
- AI 表翻页忽略字符串 "null" 排序参数(修复 ORDER BY null 导致整页返空)
- 完成统计只取每个 sheet 最新 job,AI 动态表不再重复计入原始行数

其他:sql/ai_clean.sql 注释更新;新增 AiCleanTableNamingTest(命名/物理表名前缀)
测试:aiclean 78 条单测通过(5 条 live 跳过)
cc 4 dni temu
rodzic
commit
fd101a92cb
21 zmienionych plików z 938 dodań i 125 usunięć
  1. 2 0
      ai-electron/frontend/src/case/api/govern/types/dto.ts
  2. 139 24
      ai-electron/frontend/src/case/views/data/cleanProgress.vue
  3. 44 2
      ai-electron/frontend/src/case/views/data/components/CleanLogModal.vue
  4. 57 26
      ai-electron/frontend/src/case/views/data/importList.vue
  5. 21 0
      ai-electron/frontend/src/case/views/data/importProgressSession.ts
  6. 36 9
      ai-electron/frontend/src/case/views/data/index.vue
  7. 14 1
      ai-electron/frontend/src/case/views/data/upload.vue
  8. 11 0
      ai-server/src/main/java/com/zsjz/ai/common/model/govern/entity/GovernTree.java
  9. 11 3
      ai-server/src/main/java/com/zsjz/ai/module/aiclean/entity/AiCleanTemplate.java
  10. 1 1
      ai-server/src/main/java/com/zsjz/ai/module/aiclean/enums/AiCleanExitEnum.java
  11. 92 3
      ai-server/src/main/java/com/zsjz/ai/module/aiclean/exec/AiDuckDb.java
  12. 89 14
      ai-server/src/main/java/com/zsjz/ai/module/aiclean/rule/AiRulePlan.java
  13. 121 12
      ai-server/src/main/java/com/zsjz/ai/module/aiclean/service/AiCleanService.java
  14. 42 7
      ai-server/src/main/java/com/zsjz/ai/module/aiclean/service/AiSmartCleanService.java
  15. 16 1
      ai-server/src/main/java/com/zsjz/ai/module/aiclean/store/AiCleanStore.java
  16. 146 17
      ai-server/src/main/java/com/zsjz/ai/module/dm/service/DmService.java
  17. 19 1
      ai-server/src/main/java/com/zsjz/ai/module/govern/serivce/GovernTreeService.java
  18. 2 2
      ai-server/src/test/java/com/zsjz/ai/module/aiclean/AiCleanPgRoundTripTest.java
  19. 73 0
      ai-server/src/test/java/com/zsjz/ai/module/aiclean/service/AiCleanTableNamingTest.java
  20. 1 1
      ai-server/src/test/java/com/zsjz/ai/module/aiclean/service/SelfHeaderPlanTest.java
  21. 1 1
      sql/ai_clean.sql

+ 2 - 0
ai-electron/frontend/src/case/api/govern/types/dto.ts

@@ -26,6 +26,8 @@ export interface GovernTree {
   tableNameEn?: ApiText;
   tableNameCn?: ApiText;
   dataCount?: ApiCount;
+  /** AI 动态表节点(id 为负)对应的模板 id,字符串形式(雪花 id 超 JS 安全整数,不能转 number) */
+  aiTemplateId?: ApiText;
 }
 
 /**

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

@@ -112,9 +112,8 @@
               </div>
               <div class="stat-divider"></div>
               <div class="stat-item">
-                <span class="stat-value" :class="{ 'is-fail': Number(displayFailCount) > 0 }">
-                  {{ displayFailCount }}
-                </span>
+                <!-- 失败数固定用红色(与成功数的绿色对应),0 也保持红色,避免完成态一眼看不出哪一项是失败口径 -->
+                <span class="stat-value is-fail">{{ displayFailCount }}</span>
                 <span class="stat-label">导入失败(条)</span>
               </div>
               <!-- AI 无法清洗的表:同属失败语义,单独明示(此前只藏在处理日志里) -->
@@ -128,7 +127,7 @@
             </div>
             <div class="complete-actions">
               <Button class="complete-log" @click="handleViewLog">查看清洗日志</Button>
-              <Button v-if="isFused" type="primary" class="complete-result" @click="handleStartAnalysis">
+              <Button v-if="isSmartCleanEntry" type="primary" class="complete-result" @click="handleStartAnalysis">
                 开始分析
               </Button>
               <Button v-else type="primary" class="complete-result" @click="handleViewResult">查看结果</Button>
@@ -167,6 +166,7 @@
   import {
     clearImportProgress,
     getActiveImportProgress,
+    getCleanFlowSource,
     getImportProgressEventSource,
     resetImportProgressRuntimeState,
     saveImportProgress,
@@ -213,6 +213,20 @@
   const logModalOpen = ref(false);
   let eventSource = getImportProgressEventSource();
 
+  /**
+   * 清洗 100% 后等待数据治理收尾的兜底:
+   * 治理未启动 / 启动失败 / 进度不再推送 / SSE 断流 / AI 终态消息丢失时,页面不能永远停在「执行中」
+   * —— 超过静默期就按清洗完成处理,用户仍能进结果页(治理可在数据页手动补跑)。
+   */
+  const COMPLETION_FALLBACK_GRACE_MS = 15_000;
+  /** 治理压根没启动时用更短的静默期(终端消息里 101→103→102 基本同刻到达,等 3s 足够) */
+  const COMPLETION_FALLBACK_QUICK_MS = 3_000;
+  const completionFallback = ref(false);
+  let completionFallbackTimer: ReturnType<typeof setTimeout> | undefined;
+  /** 最近一次收到任务事件(cmd 101/102/103)的时间戳:区分「任务在跑只是慢」与「任务没动静」 */
+  let lastTaskActivityAt = 0;
+  let completionFallbackNotified = false;
+
   const closeSse = () => {
     const activeEventSource = eventSource || getImportProgressEventSource();
     activeEventSource?.close();
@@ -286,6 +300,8 @@
     governTaskStarted.value = snapshot.governTaskStarted;
     governTaskFinished.value = snapshot.governTaskFinished;
     shouldRunGovernAfterClean.value = snapshot.shouldRunGovernAfterClean;
+    // 恢复快照即视为「此刻还活着」:卡 100% 的兜底看门狗从页面打开时刻重新计时
+    lastTaskActivityAt = Date.now();
     return true;
   };
 
@@ -363,8 +379,8 @@
   };
 
   /**
-   * 自主清洗的完成出口「查看结果」:跳清洗结果统计页(导入文件列表带数据量/成功失败条数)。
-   * 智能清洗的对应出口是「开始分析」(见 {@link handleStartAnalysis})。
+   * 自主页面发起的清洗,完成出口「查看结果」:跳清洗结果统计页(导入文件列表带数据量/成功失败条数)。
+   * 智能页面发起的清洗对应出口是「开始分析」(见 {@link handleStartAnalysis})。
    */
   const handleViewResult = async () => {
     if (!shouldRunGovernAfterClean.value) {
@@ -373,7 +389,7 @@
     await navigateToImportResult();
   };
 
-  /** 智能清洗的完成出口「开始分析」:直接进案件研判(智能工作台)新对话草稿 */
+  /** 智能页面发起的清洗,完成出口「开始分析」:直接进案件研判(智能工作台)新对话草稿 */
   const handleStartAnalysis = async () => {
     if (!shouldRunGovernAfterClean.value) {
       createMessage.warning('数据治理已关闭,本次导入未自动治理,可在数据页手动执行');
@@ -390,9 +406,12 @@
     // 激活时机与 URL 更新存在竞态(URL 参数保留作直连兜底)。
     sessionStorage.setItem(
       CLEAN_RESULT_CONTEXT_KEY,
-      JSON.stringify({ tab: 'files', mode: isFused.value ? 'smart' : 'manual' }),
+      JSON.stringify({ tab: 'files', mode: isSmartCleanEntry.value ? 'smart' : 'manual' }),
+    );
+    await go(
+      { path: PageEnum.BASE_HOME, query: { tab: 'files', mode: isSmartCleanEntry.value ? 'smart' : 'manual' } },
+      true,
     );
-    await go({ path: PageEnum.BASE_HOME, query: { tab: 'files', mode: isFused.value ? 'smart' : 'manual' } }, true);
     await nextTick();
     emitter.emit(CASE_DATA_REFRESH_EVENT);
   };
@@ -580,6 +599,7 @@
         taskProcess: progress.value,
       });
       governTaskStarted.value = true;
+      lastTaskActivityAt = Date.now();
       persistImportProgress();
       if (!eventSource || !sseConnected.value) {
         connectSse();
@@ -604,6 +624,11 @@
     }
 
     if (payload) {
+      // 任何任务事件(清洗 101 / 治理 102 / AI 状态 103)都算「任务在推进」:兜底看门狗据此续期;
+      // 999 心跳不算,避免 SSE 还连着但任务早已停摆时被无限续期
+      if (payload.cmd === 101 || payload.cmd === 102 || payload.cmd === 103) {
+        lastTaskActivityAt = Date.now();
+      }
       const value = normalizeSseProcess(payload.process);
       if (payload.cmd === 101) {
         const shouldApplyCleanProgress = !calcTaskTriggered.value && activeStep.value < 2;
@@ -882,6 +907,19 @@
   const aiStage = ref<SmartCleanStatusVO | null>(null);
   const aiStageTimeout = ref(false);
 
+  /**
+   * 完成面板第二个按钮按「清洗从哪个页面发起」区分:
+   * - 自主页面(传统壳:案件数据 → 上传数据 → 清洗)→「查看结果」(清洗结果统计页)
+   * - 智能页面(智能工作台:研判方案视图 → 上传文件 → 清洗)→「开始分析」(回工作台新对话)
+   *
+   * 来源由上传页启动清洗流程时写入 sessionStorage(见 importProgressSession.setCleanFlowSource);
+   * 拿不到时退回旧的 fused 判断兜底(仅智能清洗链路会带 fused=1)。
+   */
+  const isSmartCleanEntry = computed(() => {
+    const source = getCleanFlowSource();
+    return source ? source === 'smart' : isFused.value;
+  });
+
   /** 交给 AI 判定的表数 = 全部 sheet - 原本就匹配上模板的 sheet */
   const aiSheetTotal = computed(() =>
     Math.max(0, (aiStage.value?.sheetTotal ?? 0) - (aiStage.value?.matchedCount ?? 0)),
@@ -892,20 +930,48 @@
   /** 清洗结果页的「无法清洗」统计与明细(仅融合模式有 AI 链路) */
   const unableCount = computed(() => (isFused.value ? (aiStage.value?.needReviewCount ?? 0) : 0));
 
+  /**
+   * 统一进度:系统清洗(SSE)与 AI 识别两条链路按各自处理的 sheet 数加权合成一个百分比。
+   * AI 识别进度 = 已判定数 / 待判定数(判定完的入库/并入清洗随 SSE 推进)。
+   * 非融合模式(纯既有清洗)直接用 SSE 进度。
+   */
+  const overallProgress = computed(() => {
+    if (!isFused.value || !aiStage.value) return progress.value;
+    // 批次已到终态 = 1/2/3 级全部收尾,进度定格 100%(与 isComplete 的判定保持一致)
+    if (aiStageFinished.value) return 100;
+    const matched = aiStage.value.matchedCount ?? 0;
+    const total = Math.max(1, aiSheetTotal.value + matched);
+    const aiWeight = aiSheetTotal.value / total;
+    const aiPct = Math.round((aiJudgedCount.value / Math.max(1, aiSheetTotal.value)) * 100);
+    return Math.min(100, Math.round(progress.value * (1 - aiWeight) + aiPct * aiWeight));
+  });
+
   /**
    * 清洗阶段完成判定:非融合模式看 SSE 进度;融合模式看 AI 批次终态 —— 后端在
    * 1/2/3 级全部结束后推终态消息(cmd 103,stage=DONE/PARTIAL/FAILED)。纯 AI/4级
    * 批次没有 doClean,SSE 进度不会走到 100%,不能拿 progress 当完成依据。
    */
   const cleanPhaseDone = computed(() => (isFused.value ? aiStageFinished.value : progress.value >= 100));
-  const isComplete = computed(
-    () => cleanPhaseDone.value && (!shouldRunGovernAfterClean.value || governTaskFinished.value),
+  /**
+   * 「正常完成」判定(不含兜底):
+   * 1) 清洗完成 + (治理关闭或治理已完成);
+   * 2) 治理已完成且进度环已 100% —— 即便 AI 阶段终态消息(cmd 103)丢了也算完成。
+   */
+  const baseComplete = computed(
+    () =>
+      (cleanPhaseDone.value && (!shouldRunGovernAfterClean.value || governTaskFinished.value)) ||
+      (governTaskFinished.value && overallProgress.value >= 100),
   );
+  /**
+   * 最终完成态:兜底成立时不再要求 cleanPhaseDone —— 融合模式的 AI 阶段终态消息(cmd 103)
+   * 丢失时,进度环可能已经 100%(cmd 101),但 aiStageFinished 永远为 false,会永久卡住。
+   */
+  const isComplete = computed(() => completionFallback.value || baseComplete.value);
 
   /**
    * 完成时提示「另有 N 个表 AI 无法清洗」——只在完成那一刻提醒一次。
    * 完成后<b>不自动跳转</b>(2026-10-11 需求):跳转由完成面板上的按钮触发,
-   * 自主清洗给「查看结果」,智能清洗给「开始分析」。
+   * 自主页面发起 →「查看结果」,智能页面发起 →「开始分析」。
    */
   watch(isComplete, (done) => {
     if (!done) return;
@@ -926,20 +992,67 @@
     void triggerCalcTask();
   });
 
+  const clearCompletionFallbackTimer = () => {
+    if (completionFallbackTimer !== undefined) {
+      clearTimeout(completionFallbackTimer);
+      completionFallbackTimer = undefined;
+    }
+  };
+
+  const resetCompletionFallback = () => {
+    clearCompletionFallbackTimer();
+    completionFallback.value = false;
+    lastTaskActivityAt = 0;
+    completionFallbackNotified = false;
+  };
+
+  /** 进度环已 100%、但「正常完成」条件还不成立 = 卡在收尾阶段,需要看门狗兜底 */
+  const completionStalled = computed(() => overallProgress.value >= 100 && !baseComplete.value);
+
   /**
-   * 统一进度:系统清洗(SSE)与 AI 识别两条链路按各自处理的 sheet 数加权合成一个百分比。
-   * AI 识别进度 = 已判定数 / 待判定数(判定完的入库/并入清洗随 SSE 推进)。
-   * 非融合模式(纯既有清洗)直接用 SSE 进度。
+   * 卡 100% 的兜底看门狗:静默期内只要还有任务事件(cmd 101/102/103)就继续等,
+   * 彻底没动静后按完成处理 —— 覆盖治理未启动/启动失败、SSE 断流、AI 终态消息丢失等异常。
    */
-  const overallProgress = computed(() => {
-    if (!isFused.value || !aiStage.value) return progress.value;
-    // 批次已到终态 = 1/2/3 级全部收尾,进度定格 100%(与 isComplete 的判定保持一致)
-    if (aiStageFinished.value) return 100;
-    const matched = aiStage.value.matchedCount ?? 0;
-    const total = Math.max(1, aiSheetTotal.value + matched);
-    const aiWeight = aiSheetTotal.value / total;
-    const aiPct = Math.round((aiJudgedCount.value / Math.max(1, aiSheetTotal.value)) * 100);
-    return Math.min(100, Math.round(progress.value * (1 - aiWeight) + aiPct * aiWeight));
+  const scheduleCompletionFallback = () => {
+    clearCompletionFallbackTimer();
+    if (!completionStalled.value) return;
+    const waitMs = governTaskStarted.value ? COMPLETION_FALLBACK_GRACE_MS : COMPLETION_FALLBACK_QUICK_MS;
+    completionFallbackTimer = setTimeout(() => {
+      completionFallbackTimer = undefined;
+      if (!completionStalled.value) return;
+      // 还在静默期内(可能有事件晚到)或治理刚开始、需要给更长的观察期
+      const quietFor = Date.now() - lastTaskActivityAt;
+      if (
+        quietFor < COMPLETION_FALLBACK_QUICK_MS ||
+        (governTaskStarted.value && quietFor < COMPLETION_FALLBACK_GRACE_MS)
+      ) {
+        scheduleCompletionFallback();
+        return;
+      }
+      completionFallback.value = true;
+      addTaskLog('超过静默期未收到后续进度,已按完成处理(数据治理可能未执行)', 'milestone');
+      if (!completionFallbackNotified) {
+        completionFallbackNotified = true;
+        createMessage.warning('长时间未收到数据治理完成通知,已按清洗完成处理;如需治理可在数据页手动执行');
+      }
+    }, waitMs);
+  };
+
+  watch(
+    completionStalled,
+    (stalled) => {
+      if (!stalled) {
+        resetCompletionFallback();
+        return;
+      }
+      scheduleCompletionFallback();
+    },
+    { immediate: true },
+  );
+
+  // 新一轮任务(清洗未启动)时清掉上一轮留下的兜底状态,避免沿用旧的「已完成」判定
+  watch(cleanTaskStarted, (started) => {
+    if (!started) resetCompletionFallback();
   });
 
   /** 记录上一次看到的 AI 状态,用于把变化差量写进处理日志(SSE 推送与启动同步共用) */
@@ -992,6 +1105,7 @@
   const applyAiStage = (res: SmartCleanStatusVO) => {
     aiStage.value = res;
     aiStageTimeout.value = false;
+    lastTaskActivityAt = Date.now();
     logAiStageEvents(res);
     // AI 判定/入库属于「清洗入库」阶段:有在跑的 AI 任务就把步骤条推进到第 2 步
     if (!aiStageFinished.value && activeStep.value < 1) {
@@ -1078,6 +1192,7 @@
   });
 
   onBeforeUnmount(() => {
+    clearCompletionFallbackTimer();
     if (isComplete.value) {
       closeSse();
     } else {

+ 44 - 2
ai-electron/frontend/src/case/views/data/components/CleanLogModal.vue

@@ -109,11 +109,14 @@
       title?: string;
       batchId?: string | number;
       fileInfos?: FileInfo[];
+      /** 打开时默认选中的工作表(按 sheet 记录 id 匹配);从 sheet 行点开失败日志时传入 */
+      initialSheetId?: string | number;
     }>(),
     {
       title: '清洗日志',
       batchId: '',
       fileInfos: () => [],
+      initialSheetId: '',
     },
   );
 
@@ -293,6 +296,28 @@
     isSingleFileScope.value ? importedScopes.value[0]?.key || ALL_SCOPE : ALL_SCOPE,
   );
 
+  const initialSheetScopeId = computed(() => normalizeId(props.initialSheetId));
+
+  /**
+   * 从 sheet 行进入时,把「文件」「工作表」两个筛选条件都定位到该 sheet,
+   * 否则默认「全部工作表」只会按 fileId 查,看到的不是这张表的失败日志。
+   */
+  const applyInitialSheetScope = () => {
+    const targetSheetId = initialSheetScopeId.value;
+    if (!targetSheetId) return;
+
+    for (const scope of importedScopes.value) {
+      const matchedSheet = scope.sheets.find((item) => normalizeId(item.querySheetId) === targetSheetId);
+      if (!matchedSheet) continue;
+      filters.fileScopeId = scope.key;
+      filters.sheetScopeId = matchedSheet.key;
+      return;
+    }
+  };
+
+  // 程序化设置 fileScopeId 时,下面的 watch 会立刻把工作表重置回「全部」,需临时跳过
+  let restoringInitialScope = false;
+
   const sheetOptions = computed(() => {
     if (!selectedFileScope.value) {
       return [{ label: '全部工作表', value: ALL_SCOPE }];
@@ -520,11 +545,19 @@
     }));
   };
 
-  const resetFilters = () => {
+  const resetFilters = (keepInitialScope = false) => {
+    // 先置位再改值:fileScopeId 的 watch 会在本次刷新时执行,避免它把初始工作表冲掉
+    restoringInitialScope = true;
     filters.fileScopeId = defaultFileScopeId.value;
     filters.sheetScopeId = ALL_SCOPE;
     filters.orderKey = DEFAULT_SORT_KEY;
     filters.sort = DEFAULT_SORT_ORDER;
+    if (keepInitialScope) {
+      applyInitialSheetScope();
+    }
+    nextTick(() => {
+      restoringInitialScope = false;
+    });
   };
 
   const fetchLogs = async () => {
@@ -644,7 +677,7 @@
     nextTick(async () => {
       initFetchScheduled = false;
       if (!props.open) return;
-      resetFilters();
+      resetFilters(true);
       pagination.page = 1;
       await fetchLogs();
     });
@@ -688,9 +721,18 @@
     },
   );
 
+  watch(
+    () => props.initialSheetId,
+    () => {
+      if (!props.open) return;
+      scheduleInitFetch();
+    },
+  );
+
   watch(
     () => filters.fileScopeId,
     () => {
+      if (restoringInitialScope) return;
       filters.sheetScopeId = ALL_SCOPE;
     },
   );

+ 57 - 26
ai-electron/frontend/src/case/views/data/importList.vue

@@ -27,7 +27,12 @@
       />
     </div>
 
-    <CleanLogModal v-model:open="logModalOpen" title="导入失败日志" :file-infos="currentLogFileInfos" />
+    <CleanLogModal
+      v-model:open="logModalOpen"
+      title="导入失败日志"
+      :file-infos="currentLogFileInfos"
+      :initial-sheet-id="currentLogSheetId"
+    />
   </div>
 </template>
 <script lang="ts" setup name="ViewsSysEmpUserList">
@@ -49,7 +54,7 @@
   import { FileStatusEnum } from '@/case/types/enum';
   import componentSetting from '@/core/settings/componentSetting';
   import CleanLogModal from './components/CleanLogModal.vue';
-  import { resetImportProgressSession } from './importProgressSession';
+  import { resetImportProgressSession, setCleanFlowSource } from './importProgressSession';
 
   const props = defineProps({
     treeCodes: Array as PropType<string[]>,
@@ -74,6 +79,7 @@
   const currentPageFileCount = ref(0);
   const logModalOpen = ref(false);
   const currentLogFileInfos = ref<FileInfo[]>([]);
+  const currentLogSheetId = ref<string>('');
   const hasLoadedOnActivate = ref(false);
   const recleaningFileIds = ref<string[]>([]);
   const paginationLoading = ref(false);
@@ -143,6 +149,9 @@
     return Number.isFinite(num) ? String(num) : '0';
   };
 
+  /** sheet 子行判断:文件行的 pid 为空/0,sheet 行的 pid 指向所属文件 */
+  const isSheetRecord = (record: Partial<FileInfo>) => !(record.pid == null || record.pid === '0' || record.pid === 0);
+
   const fileStatusLabelMap = Object.entries(FileStatusEnum).reduce(
     (acc, [key, label]) => {
       acc[key] = label;
@@ -177,8 +186,7 @@
       ellipsis: true,
       customRender: ({ record }: { record: FileInfo }) => {
         // sheet 行优先显示工作表名(sheet 记录的 fileName 也是文件名,会与父行重复分不清)
-        const isSheet = !(record.pid == null || record.pid === '0' || record.pid === 0);
-        const label = isSheet
+        const label = isSheetRecord(record)
           ? getText(record.sheetName, getText(record.fileName, '未命名工作表'))
           : getText(record.fileName || record.sheetName, '未命名文件');
         return h('span', { class: 'file-name-text', title: label }, label);
@@ -260,28 +268,32 @@
   const actionColumn: BasicColumn<FileInfo> = {
     title: '操作',
     width: 196,
-    actions: (record: FileInfo) => [
-      {
-        label: '重新清洗',
-        title: '重新清洗',
-        disabled: recleaningFileIds.value.includes(String(record.id ?? '').trim()),
-        onClick: handleRecognizeImport.bind(this, record),
-      },
-      {
-        label: '删除',
-        title: '删除',
-        color: 'error',
-        popConfirm: {
-          title: '是否确认删除文件',
-          confirm: handleDelete.bind(this, record),
+    actions: (record: FileInfo) => {
+      // sheet 子节点只是文件的明细行:重新清洗/删除/下载都只针对文件行,子行不展示任何操作按钮
+      if (isSheetRecord(record)) return [];
+      return [
+        {
+          label: '重新清洗',
+          title: '重新清洗',
+          disabled: recleaningFileIds.value.includes(String(record.id ?? '').trim()),
+          onClick: handleRecognizeImport.bind(this, record),
         },
-      },
-      {
-        label: '下载',
-        title: '下载',
-        onClick: handleDownloadFile.bind(this, record),
-      },
-    ],
+        {
+          label: '删除',
+          title: '删除',
+          color: 'error',
+          popConfirm: {
+            title: '是否确认删除文件',
+            confirm: handleDelete.bind(this, record),
+          },
+        },
+        {
+          label: '下载',
+          title: '下载',
+          onClick: handleDownloadFile.bind(this, record),
+        },
+      ];
+    },
   };
 
   const selectedRowKeysRef = ref<string[]>([]);
@@ -442,6 +454,8 @@
         }),
       );
       resetImportProgressSession();
+      // 重新清洗入口在「导入文件管理」(自主页面),完成面板出口按自主来源给「查看结果」
+      setCleanFlowSource('manual');
       await go({
         path: PageEnum.BASE_CLEANING,
         query: {
@@ -479,8 +493,25 @@
       createMessage.info('当前文件暂无失败记录');
       return;
     }
-    const logFileInfo = { ...(record as FileInfo), pid: '0', children: [] as FileInfo[] };
+
+    // clean_error_log 里 fileId=所属文件、sheetId=工作表:
+    // 文件行用自身 id 查;sheet 行必须用 pid(父文件 id)+ 自身 id 查,
+    // 否则会拿 sheet 的 id 当 fileId 去等值匹配,一条日志都查不到(弹窗空白)。
+    const isSheet = isSheetRecord(record);
+    const fileId = String((isSheet ? record.pid : record.id) ?? '').trim();
+    let sheetRecord: FileInfo | null = null;
+    if (isSheet) {
+      sheetRecord = { ...(record as FileInfo), pid: fileId, children: [] };
+      delete (sheetRecord as Recordable).batchId;
+    }
+    const logFileInfo: FileInfo = {
+      ...(record as FileInfo),
+      id: fileId,
+      pid: '0',
+      children: sheetRecord ? [sheetRecord] : [],
+    };
     delete (logFileInfo as Recordable).batchId;
+    currentLogSheetId.value = sheetRecord ? String(record.id ?? '').trim() : '';
     currentLogFileInfos.value = [logFileInfo];
     logModalOpen.value = true;
   }

+ 21 - 0
ai-electron/frontend/src/case/views/data/importProgressSession.ts

@@ -2,6 +2,16 @@ import { reactive, toRefs } from 'vue';
 import type { FileInfo } from '@/types/api';
 
 const IMPORT_PROGRESS_STORAGE_KEY = 'case_clean_import_progress';
+const CLEAN_FLOW_SOURCE_STORAGE_KEY = 'case_clean_flow_source';
+
+/**
+ * 清洗流程发起来源:
+ * - manual:自主页面(传统壳,案件数据页「上传数据」进入上传 → 清洗)
+ * - smart :智能页面(智能工作台 / 研判方案视图「上传文件」进入上传 → 清洗)
+ *
+ * 完成面板的第二个按钮按它区分:「自主」给清洗结果、「智能」给开始分析。
+ */
+export type CleanFlowSource = 'manual' | 'smart';
 
 export interface ImportProgressSnapshot {
   active: boolean;
@@ -122,3 +132,14 @@ export const resetImportProgressSession = (caseId?: string | number) => {
   resetImportProgressRuntimeState();
   clearImportProgress(caseId);
 };
+
+/** 记录本次清洗流程的发起页(上传页跳转清洗/进度页前写入,跨页读取) */
+export const setCleanFlowSource = (source: CleanFlowSource) => {
+  getStorage()?.setItem(CLEAN_FLOW_SOURCE_STORAGE_KEY, source);
+};
+
+/** 读取本次清洗流程的发起页;未记录过(如旧会话/直连进度页)返回 null,由调用方兜底 */
+export const getCleanFlowSource = (): CleanFlowSource | null => {
+  const value = getStorage()?.getItem(CLEAN_FLOW_SOURCE_STORAGE_KEY);
+  return value === 'smart' || value === 'manual' ? value : null;
+};

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

@@ -1286,20 +1286,47 @@
   /** 治理树上 id<0 的节点是 AI 动态表(真实 id 取负),既有 /gt/treeTablePage 走实体注册查不到它 */
   const isAiTableNode = (node?: GovernTree | null) => Number(node?.id ?? 0) < 0;
 
+  /**
+   * AI 动态表对应的 ai_clean_template.id(字符串)。
+   * 不能用 -Number(node.id):雪花 id 是 19 位,超过 JS 安全整数,JSON number 解析时就已经丢精度
+   * (实测 ...0003968 会变成 ...0004000),回传后端就查不到表。后端已额外下发字符串版 aiTemplateId。
+   */
+  const resolveAiTemplateId = (node?: GovernTree | null): string => {
+    const precise = String(node?.aiTemplateId ?? '').trim();
+    if (precise) {
+      return precise;
+    }
+    // 兜底:从物理表名反解(ai_clean_{id} / ai_t{id})
+    const matched = /^ai_(?:clean_|t)(\d+)$/.exec(String(node?.tableNameEn ?? '').trim());
+    if (matched?.[1]) {
+      return matched[1];
+    }
+    return String(node?.id ?? '').replace(/^-/, '');
+  };
+
   const fetchTableData = async (params: GovernTreeQuery = {}) => {
     loading.value = true;
     try {
       // 翻页/排序/搜索都从这一个漏斗走,所以分流也放在这里:只换请求函数,
       // 表头与分页仍由 applyTableData 统一处理(/aiClean/data/page 的返回结构与 treeTablePage 一致)
-      const res = isAiTableNode(activeNode.value)
-        ? await aiCleanDataPage({
-            aiTemplateId: -Number(activeNode.value?.id),
-            orderKey: params.orderKey,
-            asc: params.sort !== 'DESC',
-            page: params.page,
-            size: params.limit,
-          })
-        : await treeTablePage(params);
+      const aiParams: {
+        aiTemplateId: string;
+        orderKey?: string;
+        asc?: boolean;
+        page?: number;
+        size?: number;
+      } = {
+        aiTemplateId: resolveAiTemplateId(activeNode.value),
+        page: params.page,
+        size: params.limit,
+      };
+      // 没排序时不要给 orderKey 占位:请求层会把空值序列化成字符串 "null",
+      // 后端拼成 ORDER BY null 会让整页查询直接失败(返空)
+      if (params.orderKey && params.sort) {
+        aiParams.orderKey = params.orderKey;
+        aiParams.asc = params.sort !== 'DESC';
+      }
+      const res = isAiTableNode(activeNode.value) ? await aiCleanDataPage(aiParams) : await treeTablePage(params);
       applyTableData(res);
     } catch (error) {
       console.error('获取治理树表格数据失败', error);

+ 14 - 1
ai-electron/frontend/src/case/views/data/upload.vue

@@ -184,7 +184,8 @@
   import { useEmitter } from '@/core/store/modules/case';
   import { clearUploadBatch, deletedFileList, preFileUpload, smartClean } from '@/case/api/govern/governApi';
   import { FileStatusEnum } from '@/case/types/enum';
-  import { resetImportProgressSession } from './importProgressSession';
+  import { resetImportProgressSession, setCleanFlowSource } from './importProgressSession';
+  import type { CleanFlowSource } from './importProgressSession';
   import type { FileInfo } from '@/types/api';
 
   const { prefixCls } = useDesign('case-upload');
@@ -691,6 +692,15 @@
     return redirect ? { ...query, redirect } : query;
   };
 
+  /**
+   * 本次上传/清洗流程从哪个页面进入:
+   * 智能工作台的「上传文件」入口固定带 redirect=/workbench?...,
+   * 其余入口(案件数据页「上传数据」)都算自主页面。
+   * 完成面板据此决定第二个按钮是「清洗结果」还是「开始分析」。
+   */
+  const resolveCleanFlowSource = (): CleanFlowSource =>
+    /^\/workbench(\/|\?|$)/.test(String(pageQuery.value.redirect || '')) ? 'smart' : 'manual';
+
   const cleanupAndLeave = async () => {
     isAbandoned.value = true;
     allowedToLeave.value = true;
@@ -746,6 +756,7 @@
       }
       allowedToLeave.value = true;
       resetImportProgressSession();
+      setCleanFlowSource(resolveCleanFlowSource());
       go({
         path: PageEnum.BASE_CLEAN_PROGRESS,
         query: { fused: '1', batchId: String(res.batchId) },
@@ -761,6 +772,7 @@
   const handleManualClean = () => {
     if (!hasCleanableSheet.value) return;
     allowedToLeave.value = true;
+    setCleanFlowSource(resolveCleanFlowSource());
     go({ path: PageEnum.BASE_CLEANING, query: withRedirect() });
   };
 
@@ -772,6 +784,7 @@
       return;
     }
     allowedToLeave.value = true;
+    setCleanFlowSource(resolveCleanFlowSource());
     go({ path: PageEnum.BASE_CLEANING, query: withRedirect({ focusKey }) });
   };
 

+ 11 - 0
ai-server/src/main/java/com/zsjz/ai/common/model/govern/entity/GovernTree.java

@@ -42,4 +42,15 @@ public class GovernTree extends TreeNode<GovernTree> {
      */
     @TableField("dataCount")
     private Integer dataCount;
+
+    /**
+     * AI 动态表节点(id 为负)对应的 ai_clean_template.id,字符串形式。
+     *
+     * <p>雪花 id 是 19 位、超过 JS {@code Number.MAX_SAFE_INTEGER},前端从 JSON number
+     * 转数字会丢精度(实测 ...963968 变成 ...964000),再取负回传就查不到表。
+     * 这里额外下发字符串版本,前端直接用它请求 {@code /aiClean/data/page}。
+     * 非 AI 节点为 null。
+     */
+    @TableField(exist = false)
+    private String aiTemplateId;
 }

+ 11 - 3
ai-server/src/main/java/com/zsjz/ai/module/aiclean/entity/AiCleanTemplate.java

@@ -37,20 +37,28 @@ public class AiCleanTemplate implements Serializable {
     @Serial
     private static final long serialVersionUID = 1L;
 
+    /** 物理表名前缀:{@code ai_clean_{雪花id}}(2026-10-18 起,历史表仍是 ai_t 前缀) */
+    public static final String TABLE_NAME_EN_PREFIX = "ai_clean_";
+
+    /** 由模板 id 生成物理表名(DuckDB 内),如 {@code ai_clean_2109...} */
+    public static String physicalTableName(Long aiTemplateId) {
+        return TABLE_NAME_EN_PREFIX + aiTemplateId;
+    }
+
     /**
-     * 雪花 ID。同时也是物理表名后缀({@code ai_t{id}})与治理树负节点 id 的来源。
+     * 雪花 ID。同时也是物理表名后缀({@code ai_clean_{id}})与治理树负节点 id 的来源。
      */
     @TableId(type = IdType.ASSIGN_ID)
     private Long id;
 
     /**
-     * 表中文名,模型语义命名
+     * 表中文名(AI 新建表时取 sheet 名 / 文件名,见 AiCleanService#buildTableNameCn)
      */
     @TableField("table_name_cn")
     private String tableNameCn;
 
     /**
-     * 物理表名(DuckDB 内),形如 {@code ai_t1948...};同时是前端 {@code ai_} 前缀判据的来源
+     * 物理表名(DuckDB 内),形如 {@code ai_clean_1948...}
      */
     @TableField("table_name_en")
     private String tableNameEn;

+ 1 - 1
ai-server/src/main/java/com/zsjz/ai/module/aiclean/enums/AiCleanExitEnum.java

@@ -29,7 +29,7 @@ public enum AiCleanExitEnum implements IEnum<String> {
     /** A2 不可行时的兜底:落 AI 表并标注「本该进既有表 #id」,绝不静默写既有表 */
     A3("既有模板·降级落AI表", "AI 动态表"),
 
-    /** 没有合适模板:新建 AI 模板 + 动态表 ai_t{id} */
+    /** 没有合适模板:新建 AI 模板 + 动态表 ai_clean_{id} */
     B("新建 AI 模板", "AI 动态表");
 
     private final String desc;

+ 92 - 3
ai-server/src/main/java/com/zsjz/ai/module/aiclean/exec/AiDuckDb.java

@@ -277,9 +277,52 @@ public final class AiDuckDb {
         }
     }
 
+    /**
+     * 按「文件 + sheet」删除:同一份文件重新清洗时先清掉上一轮的行,
+     * 避免同一个动态表被重复追加(AI 表按 file_id/sheet_id 两列隔离,不会误删别的文件)。
+     */
+    public static long deleteByFileSheet(String table, Long fileId, Long sheetId) {
+        if (fileId == null || sheetId == null) {
+            return 0L;
+        }
+        try (Connection c = caseDb().getConnection(); PreparedStatement ps =
+                     c.prepareStatement("DELETE FROM " + table + " WHERE file_id = ? AND sheet_id = ?")) {
+            ps.setLong(1, fileId);
+            ps.setLong(2, sheetId);
+            return ps.executeUpdate();
+        } catch (Exception e) {
+            log.warn("AI 重清洗前清理旧行失败: table={}, fileId={}, sheetId={}, err={}",
+                    table, fileId, sheetId, e.getMessage());
+            return -1L;
+        }
+    }
+
+    /**
+     * 按文件 ID 批量删除 AI 动态表里的行:删除导入文件时用。
+     *
+     * <p>AI 动态表不在 {@code table_info} 里,{@code DmService#deletedFiles} 的业务表清理
+     * 覆盖不到它们;不清理的话删了文件还能从动态表里查到「已删文件」的数据。
+     */
+    public static long deleteRowsByFileIds(String table, List<Long> fileIds) {
+        if (fileIds == null || fileIds.isEmpty()) {
+            return 0L;
+        }
+        String marks = String.join(",", java.util.Collections.nCopies(fileIds.size(), "?"));
+        try (Connection c = caseDb().getConnection(); PreparedStatement ps =
+                     c.prepareStatement("DELETE FROM " + table + " WHERE file_id IN (" + marks + ")")) {
+            for (int i = 0; i < fileIds.size(); i++) {
+                ps.setLong(i + 1, fileIds.get(i));
+            }
+            return ps.executeUpdate();
+        } catch (Exception e) {
+            log.warn("AI 表按文件删除失败(可能本案件没建过这张表): table={}, err={}", table, e.getMessage());
+            return -1L;
+        }
+    }
+
     /**
      * 删除 AI 动态表:入库失败后的补偿清理(流式写数中断/零行时调用),
-     * 不在案件库里留孤儿空表。表名由服务端生成(ai_t + 雪花 id),无注入面。
+     * 不在案件库里留孤儿空表。表名由服务端生成(ai_clean_ + 雪花 id),无注入面。
      */
     public static void dropTable(String table) {
         try (Connection c = caseDb().getConnection(); Statement st = c.createStatement()) {
@@ -300,6 +343,45 @@ public final class AiDuckDb {
         }
     }
 
+    /**
+     * 删除案件库里指定前缀的临时表(如预览阶段动态建的 {@code raw_{sheetId}})。
+     *
+     * <p>预览表只在「选择清洗模板 / 预览原始数据」阶段用得到,清洗完成后不清会随上传次数无限累积。
+     * 前缀由调用方传常量,这里再做一次字符白名单校验,表名同时用双引号包裹防注入。
+     */
+    public static int dropTablesByPrefix(String prefix) {
+        if (prefix == null || !prefix.matches("[A-Za-z0-9_]{1,32}")) {
+            return 0;
+        }
+        List<String> targets = new ArrayList<>();
+        try (Connection c = caseDb().getConnection();
+             PreparedStatement ps = c.prepareStatement(
+                     "SELECT table_name FROM information_schema.tables WHERE table_schema = 'main'")) {
+            try (ResultSet rs = ps.executeQuery()) {
+                while (rs.next()) {
+                    String name = rs.getString(1);
+                    if (name != null && name.startsWith(prefix)) {
+                        targets.add(name);
+                    }
+                }
+            }
+            int dropped = 0;
+            for (String name : targets) {
+                try (Statement st = c.createStatement()) {
+                    st.executeUpdate("DROP TABLE IF EXISTS \"" + name.replace("\"", "\"\"") + "\"");
+                    dropped++;
+                } catch (Exception e) {
+                    log.warn("临时表删除失败: table={}, err={}", name, e.getMessage());
+                }
+            }
+            log.info("清理临时表完成: prefix={}, 命中={}, 已删除={}", prefix, targets.size(), dropped);
+            return dropped;
+        } catch (Exception e) {
+            log.warn("清理临时表失败: prefix={}, err={}", prefix, e.getMessage());
+            return -1;
+        }
+    }
+
     /**
      * 按节点翻页取数(供 {@code /aiClean/data/page} 用)。
      *
@@ -307,9 +389,16 @@ public final class AiDuckDb {
      */
     public static List<Map<String, Object>> selectPage(String table, List<String> columns,
                                                        String orderKey, boolean asc, long page, long size) {
-        String safeOrder = AiColumnNames.isSafeOrderKey(orderKey) ? orderKey : null;
+        String candidate = orderKey == null ? null : orderKey.trim();
+        // 前端把空值统一序列化成字符串 "null"(见 core/utils/http 的 REQUEST_NULL_VALUE),
+        // 直接拼进 ORDER BY 会让 DuckDB 报 "ORDER BY non-integer literal has no effect" 并整页返回空。
+        // 非本表物理列同样忽略(排序列必须是这次 SELECT 里的列)。
+        boolean orderAllowed = candidate != null
+                && !"null".equalsIgnoreCase(candidate)
+                && columns.contains(candidate)
+                && AiColumnNames.isSafeOrderKey(candidate);
         String sql = "SELECT " + String.join(",", columns) + " FROM " + table
-                + (safeOrder == null ? "" : " ORDER BY " + safeOrder + (asc ? " ASC" : " DESC"))
+                + (orderAllowed ? " ORDER BY " + candidate + (asc ? " ASC" : " DESC") : "")
                 + " LIMIT ? OFFSET ?";
         List<Map<String, Object>> out = new ArrayList<>();
         try (Connection c = caseDb().getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {

+ 89 - 14
ai-server/src/main/java/com/zsjz/ai/module/aiclean/rule/AiRulePlan.java

@@ -10,7 +10,9 @@ import com.zsjz.ai.module.aiclean.model.TemplateCandidate;
 import com.zsjz.ai.module.aiclean.support.AiColumnNames;
 
 import java.util.ArrayList;
+import java.util.Comparator;
 import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 
@@ -74,32 +76,105 @@ public final class AiRulePlan {
 
     /**
      * 出口 B:AI 侧清洗计划 + 动态表字段定义。
+     *
+     * <p><b>动态表要能还原整张 sheet</b>:AI 通常只绑上部分列(如车辆表只认出号牌/号牌种类/所有人),
+     * 其余列按原表头顺序、原值保留为 TEXT。否则 {@code ai_clean_field} 与治理树只会显示模型
+     * 命中的那几个字段,表就像被截断了一样(2026-10-18 需求)。
      */
-    public static Plan forAi(List<RuleBind> binds, List<String> headerNames, String namePrefix) {
+    public static Plan forAi(List<RuleBind> binds, List<String> headerNames) {
+        Map<Integer, RuleBind> bindByIndex = new LinkedHashMap<>();
+        for (RuleBind b : binds) {
+            if (b != null && b.getFileColIndex() != null) {
+                bindByIndex.putIfAbsent(b.getFileColIndex(), b);
+            }
+        }
+        // 列序严格按原表头顺序;空表头跳过(无法命名也没有语义)
+        List<Integer> indexes = new ArrayList<>();
+        List<String> names = new ArrayList<>();
+        for (int i = 0; i < headerNames.size(); i++) {
+            if (StrUtil.isNotBlank(headerNames.get(i))) {
+                indexes.add(i);
+                names.add(headerNames.get(i));
+            }
+        }
+        // 绑定列若落在空表头/越界位置,也补进来,避免 AI 认出的列被丢掉
+        for (Integer idx : bindByIndex.keySet()) {
+            if (idx != null && idx >= 0 && !indexes.contains(idx)) {
+                indexes.add(idx);
+                names.add(colName(headerNames, idx));
+            }
+        }
+        List<String> safe = AiColumnNames.sanitizeAll(names);
         List<CompiledColumn> columns = new ArrayList<>();
         List<AiCleanField> fields = new ArrayList<>();
-        List<String> safe = AiColumnNames.sanitizeAll(
-                binds.stream().map(b -> colName(headerNames, b.getFileColIndex())).toList());
-        for (int i = 0; i < binds.size(); i++) {
-            RuleBind b = binds.get(i);
-            String type = typeOf(b);
+        for (int pos = 0; pos < indexes.size(); pos++) {
+            int fileColIndex = indexes.get(pos);
+            RuleBind bind = bindByIndex.get(fileColIndex);
+            String nameCn = names.get(pos);
+            String fieldNameEn = bind == null || StrUtil.isBlank(bind.getFieldNameEn())
+                    ? nameCn
+                    : bind.getFieldNameEn();
+            String type = bind == null ? "TEXT" : typeOf(bind);
+            boolean required = bind != null && Boolean.TRUE.equals(bind.getRequired());
             AiCleanField f = new AiCleanField();
-            f.setColumnName(safe.get(i));
-            f.setFieldNameCn(colName(headerNames, b.getFileColIndex()));
-            f.setFieldNameEn(b.getFieldNameEn());
+            f.setColumnName(safe.get(pos));
+            f.setFieldNameCn(nameCn);
+            f.setFieldNameEn(fieldNameEn);
             f.setFieldType(type);
-            f.setRequired(Boolean.TRUE.equals(b.getRequired()) ? 1 : 0);
+            f.setRequired(required ? 1 : 0);
             f.setFieldLen(0);
-            f.setFieldSort(i + 1);
-            f.setColIndex(b.getFileColIndex());
+            f.setFieldSort(pos + 1);
+            f.setColIndex(fileColIndex);
             f.setMatched(1);
             fields.add(f);
-            columns.add(CompiledColumn.of(b.getFileColIndex(), safe.get(i), b.getFieldNameEn(),
-                    type, f.getRequired() == 1, 0, AiRuleCompiler.compileFuns(b)));
+            columns.add(CompiledColumn.of(fileColIndex, safe.get(pos), fieldNameEn, type, required, 0,
+                    bind == null ? List.of() : AiRuleCompiler.compileFuns(bind)));
         }
         return new Plan(columns, fields);
     }
 
+    /**
+     * 复用已有 AI 模板时的执行计划:列名/类型一律以存档的 {@code ai_clean_field} 为准,
+     * 物理表结构不会因为「这次 AI 多绑/少绑了列」而对不上。
+     */
+    public static Plan forStoredTemplate(List<AiCleanField> storedFields, List<RuleBind> binds) {
+        Map<Integer, RuleBind> bindByIndex = new LinkedHashMap<>();
+        for (RuleBind b : binds) {
+            if (b != null && b.getFileColIndex() != null) {
+                bindByIndex.putIfAbsent(b.getFileColIndex(), b);
+            }
+        }
+        List<AiCleanField> sorted = new ArrayList<>(storedFields);
+        sorted.sort(Comparator.comparing(f -> f.getFieldSort() == null ? Integer.MAX_VALUE : f.getFieldSort()));
+        List<CompiledColumn> columns = new ArrayList<>();
+        for (AiCleanField f : sorted) {
+            RuleBind bind = f.getColIndex() == null ? null : bindByIndex.get(f.getColIndex());
+            columns.add(CompiledColumn.of(
+                    f.getColIndex() == null ? 0 : f.getColIndex(),
+                    f.getColumnName(),
+                    f.getFieldNameEn(),
+                    f.getFieldType(),
+                    Integer.valueOf(1).equals(f.getRequired()),
+                    f.getFieldLen(),
+                    bind == null ? List.of() : AiRuleCompiler.compileFuns(bind)));
+        }
+        return new Plan(columns, sorted);
+    }
+
+    /** 动态表应有的列数 = 非空表头列数(用于识别「历史模板只登记了部分列」) */
+    public static int expandedColumnCount(List<String> headerNames) {
+        if (headerNames == null) {
+            return 0;
+        }
+        int count = 0;
+        for (String header : headerNames) {
+            if (StrUtil.isNotBlank(header)) {
+                count++;
+            }
+        }
+        return count;
+    }
+
     /** 一个清洗计划 */
     public record Plan(List<CompiledColumn> columns, List<AiCleanField> fields) {
     }

+ 121 - 12
ai-server/src/main/java/com/zsjz/ai/module/aiclean/service/AiCleanService.java

@@ -1,5 +1,6 @@
 package com.zsjz.ai.module.aiclean.service;
 
+import cn.hutool.core.io.FileUtil;
 import cn.hutool.core.util.IdUtil;
 import cn.hutool.core.util.StrUtil;
 import com.baomidou.mybatisplus.core.toolkit.Wrappers;
@@ -362,7 +363,7 @@ public class AiCleanService {
      * 不再把整个 sheet collect 成两份 Map 驻留堆内 —— 几十万行的流水表以前会在这里打爆内存。
      *
      * <p><b>写序与 {@link AiCleanStore} 的注释对齐</b>(评审发现的幽灵模板问题):
-     * 模板 id 先行生成(物理表名 {@code ai_t{id}} 依赖它)→ 建表 → 流式写数 →
+     * 模板 id 先行生成(物理表名 {@code ai_clean_{id}} 依赖它)→ 建表 → 流式写数 →
      * <b>成功才登记 ONLINE</b>。写数失败/零行就不登记,并删掉刚建的空表 ——
      * 绝不留下「有元数据没物理表」的模板占着 {@code header_md5} 唯一键。
      *
@@ -376,8 +377,7 @@ public class AiCleanService {
         SheetEvidence ev = plan.ev();
         List<RuleBind> binds = plan.binds();
         List<String> headers = plan.headers();
-        AiRulePlan.Plan aiPlan = AiRulePlan.forAi(binds, headers, "ai_t");
-        // 同指纹复用:候选来自 AI 模板库时数据并入已有 ai_t 表,不再新建模板 ——
+        // 同指纹复用:候选来自 AI 模板库时数据并入已有 AI 动态表,不再新建模板 ——
         // header_md5 唯一键下重复登记会直接撞库(2026-10-11 E2 实测 FAIL)
         AiCleanTemplate tpl = null;
         boolean reuse = plan.candidate() != null && plan.candidate().aiTemplateId() != null;
@@ -388,18 +388,39 @@ public class AiCleanService {
                 tpl = null;
             }
         }
+        // 历史模板只登记了 AI 命中的少数列(如车辆表只有号牌/种类/所有人 3 列),
+        // 与「动态表要还原整张 sheet」的口径不一致:直接复用会让结果页/治理树永远只有那几个字段。
+        // 这里清掉旧模板与旧表,按完整表头重建;同表头的其他文件下次清洗会复用新表。
+        if (reuse) {
+            int expectedColumns = AiRulePlan.expandedColumnCount(headers);
+            Long storedColumns = fieldMapper.selectCount(Wrappers.<AiCleanField>lambdaQuery()
+                    .eq(AiCleanField::getAiTemplateId, tpl.getId()));
+            if (storedColumns == null || storedColumns != expectedColumns) {
+                log.info("AI 模板列数({})与表头列数({})不一致,重建模板与动态表: templateId={}, table={}",
+                        storedColumns, expectedColumns, tpl.getId(), tpl.getTableNameEn());
+                store.deleteTemplate(tpl.getId());
+                AiDuckDb.dropTable(tpl.getTableNameEn());
+                tpl = null;
+                reuse = false;
+            }
+        }
+        AiRulePlan.Plan aiPlan;
         List<AiCleanRule> ruleRows = new ArrayList<>();
         if (reuse) {
+            List<AiCleanField> storedFields = fieldMapper.selectList(Wrappers.<AiCleanField>lambdaQuery()
+                    .eq(AiCleanField::getAiTemplateId, tpl.getId())
+                    .orderByAsc(AiCleanField::getFieldSort));
+            aiPlan = AiRulePlan.forStoredTemplate(storedFields, binds);
             log.info("AI 表指纹命中,复用已有动态表: table={}, templateId={}", tpl.getTableNameEn(), tpl.getId());
         } else {
+            aiPlan = AiRulePlan.forAi(binds, headers);
             // 模板 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.setTableNameEn(AiCleanTemplate.physicalTableName(tpl.getId()));
+            // draft 可为空(按文件自身表头自建表的兜底路径没有模型草稿),命名不再用模型草稿:
+            // 动态表中文名按「sheet 名称 → 文件名 + sheetN → CSV 用文件名」规则生成
+            tpl.setTableNameCn(resolveAiTableNameCn(root, sheet));
             tpl.setHeaders(String.join(",", headers));
             tpl.setHeaderMd5(ev.headerMd5());
             tpl.setHeaderLineNo(ev.primaryBlock().headerRow());
@@ -412,11 +433,20 @@ public class AiCleanService {
             tpl.setCaseIdSrc(job.getCaseId());
             tpl.setConfidence(job.getConfidence());
             tpl.setCreateTime(LocalDateTime.now());
+            // 规则覆盖全部列(含按原样保留的 TEXT 列):下次同表头文件才能零模型重放、
+            // 并重建出同样宽的动态表,不会退回「只有 AI 命中的几列」
+            Map<Integer, RuleBind> bindByIndex = new LinkedHashMap<>();
             for (RuleBind b : binds) {
+                if (b != null && b.getFileColIndex() != null) {
+                    bindByIndex.putIfAbsent(b.getFileColIndex(), b);
+                }
+            }
+            for (AiCleanField f : aiPlan.fields()) {
+                RuleBind bind = bindByIndex.get(f.getColIndex());
                 AiCleanRule r = new AiCleanRule();
-                r.setColIndex(b.getFileColIndex());
-                r.setFieldNameEn(b.getFieldNameEn());
-                r.setHints(AiCleanStore.hintsJson(b.getHints()));
+                r.setColIndex(f.getColIndex());
+                r.setFieldNameEn(f.getFieldNameEn());
+                r.setHints(AiCleanStore.hintsJson(bind == null ? List.of() : bind.getHints()));
                 r.setVer(1);
                 ruleRows.add(r);
             }
@@ -426,6 +456,13 @@ public class AiCleanService {
             log.warn("AI 动态表建表失败,跳过写入: table={}, err={}", tpl.getTableNameEn(), err);
             return -1L;
         }
+        // 同一份文件的同一 sheet 重新清洗:先清掉它上一轮写进这张动态表的行,
+        // 否则「重新清洗/改了模板再跑」会把整表数据重复追加(AI 表按 file_id+sheet_id 隔离,不会误删别的文件)
+        long purged = AiDuckDb.deleteByFileSheet(tpl.getTableNameEn(), job.getFileId(), job.getSheetId());
+        if (purged > 0) {
+            log.info("AI 重清洗,已清理该 sheet 旧行: table={}, fileId={}, sheetId={}, rows={}",
+                    tpl.getTableNameEn(), job.getFileId(), job.getSheetId(), purged);
+        }
         // lambda 要求事实最终变量:tpl/reuse 在上面按复用/新建二选一赋值后不再变
         final AiCleanTemplate tplF = tpl;
         final boolean reuseF = reuse;
@@ -483,6 +520,78 @@ public class AiCleanService {
         return written;
     }
 
+    /**
+     * AI 动态表的中文名(2026-10-18 需求):表名跟数据来源对齐,不再用模型草稿语义命名。
+     * <ul>
+     *   <li>Excel → 直接用 sheet 名称;</li>
+     *   <li>sheet 名称缺失 → 「文件名称 + sheetN」,N 为该 sheet 在文件内的序号(从 1 开始);</li>
+     *   <li>CSV → 整份文件就是一张表,用文件名称。</li>
+     * </ul>
+     */
+    private String resolveAiTableNameCn(FileInfo root, FileInfo sheet) {
+        String fileType = root == null ? null : root.getFileType();
+        String fileName = root == null ? null : root.getFileName();
+        String sheetName = sheet == null ? null : sheet.getSheetName();
+        int sheetOrdinal = needsSheetOrdinal(fileType, sheetName) ? resolveSheetOrdinal(root, sheet) : 1;
+        return buildTableNameCn(fileType, fileName, sheetName, sheetOrdinal);
+    }
+
+    /** 是否要落到「文件名 + sheetN」:非 CSV 且 sheet 名称缺失 */
+    static boolean needsSheetOrdinal(String fileType, String sheetName) {
+        return !"CSV".equalsIgnoreCase(StrUtil.trim(fileType)) && StrUtil.trimToNull(sheetName) == null;
+    }
+
+    /**
+     * 纯命名规则(抽出静态方法便于单测):
+     * CSV 用文件名称;有 sheet 名用 sheet 名;否则「文件名称 + sheetN」。
+     */
+    static String buildTableNameCn(String fileType, String fileName, String sheetName, int sheetOrdinal) {
+        String displayName = displayFileName(fileName);
+        if ("CSV".equalsIgnoreCase(StrUtil.trim(fileType))) {
+            return fitNameCn(displayName);
+        }
+        String trimmedSheetName = StrUtil.trimToNull(sheetName);
+        if (trimmedSheetName != null) {
+            return fitNameCn(trimmedSheetName);
+        }
+        return fitNameCn(displayName + "sheet" + Math.max(1, sheetOrdinal));
+    }
+
+    /** 文件显示名:去掉落盘时的雪花 ID 前缀(如 {@code 2109826345177022464_})与扩展名 */
+    static String displayFileName(String fileName) {
+        String value = StrUtil.trimToEmpty(fileName);
+        if (value.isEmpty()) {
+            return "未命名文件";
+        }
+        String withoutIdPrefix = value.replaceFirst("^\\d{15,}_", "");
+        return StrUtil.blankToDefault(StrUtil.trim(FileUtil.mainName(withoutIdPrefix)), value);
+    }
+
+    /** sheet 在文件内的序号(1 基):sheet 记录按解析顺序落库,id 升序即工作簿里的顺序 */
+    private int resolveSheetOrdinal(FileInfo root, FileInfo sheet) {
+        if (root == null || root.getId() == null || sheet == null || sheet.getId() == null) {
+            return 1;
+        }
+        List<FileInfo> siblings = fileInfoMapper.selectList(Wrappers.<FileInfo>lambdaQuery()
+                .eq(FileInfo::getPid, root.getId())
+                .orderByAsc(FileInfo::getId));
+        for (int i = 0; i < siblings.size(); i++) {
+            if (sheet.getId().equals(siblings.get(i).getId())) {
+                return i + 1;
+            }
+        }
+        return 1;
+    }
+
+    /** ai_clean_template.table_name_cn 是 varchar(200),超长硬截断,避免元数据登记失败 */
+    static String fitNameCn(String name) {
+        String value = StrUtil.trimToEmpty(name);
+        if (value.isEmpty()) {
+            return "未命名表";
+        }
+        return value.length() <= 200 ? value : StrUtil.subPre(value, 200);
+    }
+
     /** 按 job 撤销 AI 表数据 */
     public long revert(Long jobId) {
         AiCleanJob job = jobMapper.selectById(jobId);
@@ -503,7 +612,7 @@ public class AiCleanService {
 
     private VerifyReport verify(List<RuleBind> binds, TemplateCandidate cand, List<String> headers,
                                 List<Map<Integer, String>> sample, AiMatchOutcome outcome) {
-        AiRulePlan.Plan plan = AiRulePlan.forAi(binds, headers, "ai_t");
+        AiRulePlan.Plan plan = AiRulePlan.forAi(binds, headers);
         long total = sample.size();
         return AiRuleVerifier.verify(sample, plan.columns(), outcome.candidate().score(),
                 cand.score(), total);

+ 42 - 7
ai-server/src/main/java/com/zsjz/ai/module/aiclean/service/AiSmartCleanService.java

@@ -202,23 +202,48 @@ public class AiSmartCleanService {
             if (userId == null) {
                 return;
             }
-            // 成功条数 = 系统清洗入库行数(dataNum-failNum)+ AI 动态表入库行数
+            List<AiCleanJob> jobs = jobMapper.selectList(Wrappers.lambdaQuery(AiCleanJob.class)
+                    .eq(AiCleanJob::getBatchId, batchId)
+                    .orderByDesc(AiCleanJob::getId));
+            // 同一 sheet 在同一批次下可能被重跑多次(重新智能清洗复用同一 batchId),
+            // 统计只认最新一条任务,否则行数会按重跑次数翻倍。
+            java.util.Map<Long, AiCleanJob> latestBySheet = new java.util.LinkedHashMap<>();
+            for (AiCleanJob job : jobs) {
+                if (job.getSheetId() != null) {
+                    latestBySheet.putIfAbsent(job.getSheetId(), job);
+                }
+            }
+            // AI 动态表(出口 B)的 sheet,file_info 里记的是「前置解析读到的原始行数」(含标题/表头行),
+            // 不能再计入系统清洗成功数,否则会与 AI 实际入库行数重复累加。
+            java.util.Set<Long> aiLoadedSheetIds = new java.util.HashSet<>();
+            long aiRows = 0;
+            long aiFailRows = 0;
+            for (AiCleanJob job : latestBySheet.values()) {
+                if (job.getAiTemplateId() != null) {
+                    aiLoadedSheetIds.add(job.getSheetId());
+                }
+                aiRows += java.util.Objects.requireNonNullElse(job.getRowLoaded(), 0);
+                int rowTotal = java.util.Objects.requireNonNullElse(job.getRowTotal(), 0);
+                int rowLoaded = java.util.Objects.requireNonNullElse(job.getRowLoaded(), 0);
+                aiFailRows += Math.max(0, rowTotal - rowLoaded);
+            }
             int dataNum = 0;
             int failNum = 0;
             for (FileInfo row : fileInfoMapper.selectList(Wrappers.lambdaQuery(FileInfo.class)
                     .eq(FileInfo::getBatchId, batchId)
                     .isNull(FileInfo::getFileType))) {
+                if (row.getId() != null && aiLoadedSheetIds.contains(row.getId())) {
+                    continue;
+                }
                 dataNum += java.util.Objects.requireNonNullElse(row.getDataNum(), 0);
                 failNum += java.util.Objects.requireNonNullElse(row.getFailNum(), 0);
             }
-            long aiRows = 0;
-            for (AiCleanJob job : jobMapper.selectList(Wrappers.lambdaQuery(AiCleanJob.class)
-                    .eq(AiCleanJob::getBatchId, batchId))) {
-                aiRows += java.util.Objects.requireNonNullElse(job.getRowLoaded(), 0);
-            }
+            // 成功条数 = 系统清洗入库行数(dataNum-failNum)+ AI 动态表实际入库行数;
+            // 失败条数 = 系统清洗失败行数 + AI 动态表未入库行数
             int completed = (int) Math.max(0, dataNum - failNum + aiRows);
+            int failed = (int) Math.max(0, failNum + aiFailRows);
             sseService.sendSee(userId, new SseDTO(101, 100, "[数据清洗]所有文件清洗完成!",
-                    new SeeDataDTO(completed, failNum)));
+                    new SeeDataDTO(completed, failed)));
             SmartCleanStatusVO vo = status(caseId, batchId);
             if (vo != null) {
                 sseService.sendSee(userId, new SseDTO(103, 100, "AI 清洗流程完成",
@@ -331,6 +356,16 @@ public class AiSmartCleanService {
             // 终态收口(全 SSE):统计 → 状态快照 → 治理宣告;纯 AI/无既有清洗的批次
             // 没有 doClean 的完成回调,前端的「完成并跳转统计页」完全依赖这一推
             pushTerminal(caseId, batchId);
+            // 清洗全部完成:预览阶段动态建的 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.warn("智能清洗完成后清理 raw_ 临时表失败(不影响清洗结果): {}", e.getMessage());
+            }
             log.info("智能清洗批次完成: batchId={}, 阶段={}, 判定={} 条, 交既有清洗={} 条, AI 入库={} 条, "
                             + "待人工={} 条, 失败={} 条, 耗时={}ms",
                     batchId, finalStage, pending.size(), toExisting, st.aiLoad.get(),

+ 16 - 1
ai-server/src/main/java/com/zsjz/ai/module/aiclean/store/AiCleanStore.java

@@ -55,7 +55,7 @@ public class AiCleanStore {
             tpl.setId(IdUtil.getSnowflakeNextId());
         }
         if (tpl.getTableNameEn() == null || tpl.getTableNameEn().isBlank()) {
-            tpl.setTableNameEn("ai_t" + tpl.getId());
+            tpl.setTableNameEn(AiCleanTemplate.physicalTableName(tpl.getId()));
         }
         tpl.setVer(tpl.getVer() == null ? 1 : tpl.getVer());
         tpl.setCreateTime(LocalDateTime.now());
@@ -73,6 +73,21 @@ public class AiCleanStore {
         return tpl.getId();
     }
 
+    /**
+     * 删除一份 AI 模板及其字段/规则(物理表由调用方在案件库里自行删除)。
+     *
+     * <p>用于历史模板结构升级:早期模板只登记了 AI 命中的少数列,无法表达整张 sheet,
+     * 重建前先清掉旧元数据,避免 {@code header_md5} 唯一键冲突。
+     */
+    public void deleteTemplate(Long aiTemplateId) {
+        if (aiTemplateId == null) {
+            return;
+        }
+        ruleMapper.delete(Wrappers.<AiCleanRule>lambdaQuery().eq(AiCleanRule::getAiTemplateId, aiTemplateId));
+        fieldMapper.delete(Wrappers.<AiCleanField>lambdaQuery().eq(AiCleanField::getAiTemplateId, aiTemplateId));
+        templateMapper.deleteById(aiTemplateId);
+    }
+
     /**
      * 读回一份 AI 模板的列绑定(同表头文件复用动态表时直接用存档规则,零模型调用)。
      *

+ 146 - 17
ai-server/src/main/java/com/zsjz/ai/module/dm/service/DmService.java

@@ -103,6 +103,8 @@ public class DmService {
     private com.zsjz.ai.module.aiclean.mapper.AiCleanJobMapper aiCleanJobMapper;
     @Autowired
     private com.zsjz.ai.module.aiclean.mapper.AiCleanTemplateMapper aiCleanTemplateMapper;
+    @Autowired
+    private com.zsjz.ai.module.aiclean.tree.AiGovernService aiGovernService;
 
     /**
      * 预览文件内容
@@ -623,6 +625,14 @@ public class DmService {
                         failNum += Objects.requireNonNullElse(child.getFailNum(), 0);
                     }
                     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.warn("清洗完成后清理 raw_ 临时表失败(不影响清洗结果): {}", e.getMessage());
+                    }
                     if (silent) {
                         // 静默趟:收口消息(终态/关会话/同时治理)由智能清洗批次统一决定时机,
                         // 但事实性清理(全文检索刷盘/临时文件)与统计照常执行
@@ -872,6 +882,13 @@ public class DmService {
         if (CollUtil.isEmpty(fileInfos)) {
             return List.of();
         }
+        // sheet 的 AI 任务记录(每 sheet 取最新一条):清洗方式/备注 + AI 动态表的行数口径都靠它
+        List<Long> sheetIds = fileInfos.stream()
+                .filter(info -> info.getPid() != null && info.getPid() != 0L)
+                .map(FileInfo::getId)
+                .filter(Objects::nonNull)
+                .toList();
+        Map<Long, com.zsjz.ai.module.aiclean.entity.AiCleanJob> latestJobs = loadLatestAiJobs(sheetIds);
         Map<Long, List<FileInfo>> fileMap = fileInfos.stream().collect(Collectors.groupingBy(FileInfo::getPid));
         List<FileInfo> rootFiles = Lists.newArrayList();
         for (FileInfo fileInfo : fileInfos) {
@@ -888,14 +905,33 @@ public class DmService {
             int dataNum = 0;
             int failNum = 0;
             for (FileInfo child : childrens) {
-                dataNum += Objects.requireNonNullElse(child.getDataNum(), 0);
-                failNum += Objects.requireNonNullElse(child.getFailNum(), 0);
+                com.zsjz.ai.module.aiclean.entity.AiCleanJob job = latestJobs.get(child.getId());
+                if (job != null && job.getAiTemplateId() != null) {
+                    // AI 动态表(出口 B)的 sheet:file_info 里是前置解析的原始行数(含标题/表头行),
+                    // 真实入库量看 AI 任务 —— 否则会重复累加,结果页数字虚高
+                    int rowTotal = Objects.requireNonNullElse(job.getRowTotal(), 0);
+                    int rowLoaded = Objects.requireNonNullElse(job.getRowLoaded(), 0);
+                    int aiFail = Math.max(0, rowTotal - rowLoaded);
+                    child.setDataNum(rowTotal);
+                    child.setFailNum(aiFail);
+                    child.setSuccessNum(rowLoaded);
+                    dataNum += rowTotal;
+                    failNum += aiFail;
+                    continue;
+                }
+                int childData = Objects.requireNonNullElse(child.getDataNum(), 0);
+                int childFail = Objects.requireNonNullElse(child.getFailNum(), 0);
+                // sheet 行自身的成功数 = 数据量 - 失败数(库里只写入 data_num/fail_num,
+                // 不回填的话列表 sheet 行的「导入成功」永远是 0)
+                child.setSuccessNum(Math.max(0, childData - childFail));
+                dataNum += childData;
+                failNum += childFail;
             }
             fileInfo.setDataNum(dataNum);
             fileInfo.setFailNum(failNum);
-            fileInfo.setSuccessNum(dataNum - failNum);
+            fileInfo.setSuccessNum(Math.max(0, dataNum - failNum));
             // sheet 行展示「清洗方式 / 备注」:按 AI 任务记录回填瞬态字段
-            fillSheetCleanInfo(childrens);
+            fillSheetCleanInfo(childrens, latestJobs);
         }
         return rootFiles
                 .stream()
@@ -904,6 +940,24 @@ public class DmService {
                 .toList();
     }
 
+    /**
+     * 批量取每个 sheet 的最新一条 AI 任务(雪花 id 降序首个):
+     * 同一 sheet 可能被清洗多次,展示与统计都只认最新一次。
+     */
+    private Map<Long, com.zsjz.ai.module.aiclean.entity.AiCleanJob> loadLatestAiJobs(List<Long> sheetIds) {
+        Map<Long, com.zsjz.ai.module.aiclean.entity.AiCleanJob> latestJob = new HashMap<>();
+        if (CollUtil.isEmpty(sheetIds)) {
+            return latestJob;
+        }
+        for (com.zsjz.ai.module.aiclean.entity.AiCleanJob job : aiCleanJobMapper.selectList(
+                Wrappers.<com.zsjz.ai.module.aiclean.entity.AiCleanJob>lambdaQuery()
+                        .in(com.zsjz.ai.module.aiclean.entity.AiCleanJob::getSheetId, sheetIds)
+                        .orderByDesc(com.zsjz.ai.module.aiclean.entity.AiCleanJob::getId))) {
+            latestJob.putIfAbsent(job.getSheetId(), job);
+        }
+        return latestJob;
+    }
+
     /**
      * 回填 sheet 的清洗方式与备注(瞬态字段,供「导入文件管理」列表展示):
      * <ul>
@@ -912,22 +966,14 @@ public class DmService {
      *   <li>无 AI 任务但已配模板(上传直配或手动清洗)→ 「系统清洗」</li>
      *   <li>都没有 → 留空(前端显示 -)</li>
      * </ul>
-     * sheet 可能被多次清洗(多条 job),取最新一条(雪花 id 降序首个)。
+     *
+     * @param latestJob 由 {@link #loadLatestAiJobs} 预取的 sheet 最新任务,避免每层重复查库
      */
-    private void fillSheetCleanInfo(List<FileInfo> sheets) {
+    private void fillSheetCleanInfo(List<FileInfo> sheets,
+                                    Map<Long, com.zsjz.ai.module.aiclean.entity.AiCleanJob> latestJob) {
         if (CollUtil.isEmpty(sheets)) {
             return;
         }
-        List<Long> sheetIds = sheets.stream().map(FileInfo::getId).filter(Objects::nonNull).toList();
-        Map<Long, com.zsjz.ai.module.aiclean.entity.AiCleanJob> latestJob = new HashMap<>();
-        if (CollUtil.isNotEmpty(sheetIds)) {
-            for (com.zsjz.ai.module.aiclean.entity.AiCleanJob job : aiCleanJobMapper.selectList(
-                    Wrappers.<com.zsjz.ai.module.aiclean.entity.AiCleanJob>lambdaQuery()
-                            .in(com.zsjz.ai.module.aiclean.entity.AiCleanJob::getSheetId, sheetIds)
-                            .orderByDesc(com.zsjz.ai.module.aiclean.entity.AiCleanJob::getId))) {
-                latestJob.putIfAbsent(job.getSheetId(), job);
-            }
-        }
         Map<Long, String> aiTableNames = new HashMap<>();
         for (FileInfo sheet : sheets) {
             com.zsjz.ai.module.aiclean.entity.AiCleanJob job = latestJob.get(sheet.getId());
@@ -959,7 +1005,10 @@ public class DmService {
                 if ("B".equals(job.getExitCode())) {
                     yield "AI 新建表:" + StrUtil.blankToDefault(
                             aiTableName(job.getAiTemplateId(), aiTableNames),
-                            job.getAiTemplateId() == null ? "AI 表" : "ai_t" + job.getAiTemplateId());
+                            job.getAiTemplateId() == null
+                                    ? "AI 表"
+                                    : com.zsjz.ai.module.aiclean.entity.AiCleanTemplate
+                                            .physicalTableName(job.getAiTemplateId()));
                 }
                 yield "";   // 判定进行中,暂无备注
             }
@@ -991,6 +1040,22 @@ public class DmService {
         // 业务表一旦失败就再无重试入口)。失败时保留全部原数据,可直接重试删除
         // (deleteDataByFileIds 按文件 ID 删,重试幂等)
         deleteBusinessTablesConcurrently(tableInfoList, ids);
+        // AI 动态表不在 table_info 里,上面的业务表清理覆盖不到;先把要删的「文件根 / sheet」归类,
+        // 再按 file_id(或 file_id+sheet_id)清掉动态表里的行和对应 AI 任务,避免删完还能查到数据
+        List<FileInfo> affectedFiles = fileInfoMapper.selectList(Wrappers.<FileInfo>lambdaQuery()
+                .in(FileInfo::getId, ids)
+                .or()
+                .in(FileInfo::getPid, ids));
+        Set<Long> rootIds = new HashSet<>(ids);
+        List<FileInfo> sheetTargets = new ArrayList<>();
+        for (FileInfo row : affectedFiles) {
+            if (row.getPid() != null && row.getPid() != 0L && ids.contains(row.getId())) {
+                // 传进来的是工作表:从「整文件删除」里摘出来,按 sheet 粒度删
+                rootIds.remove(row.getId());
+                sheetTargets.add(row);
+            }
+        }
+        cleanupAiDynamicRows(rootIds, sheetTargets);
         // 业务数据删净后再删文件登记行与 AI 识别档案(主库)
         fileInfoMapper.delete(Wrappers.lambdaQuery(FileInfo.class)
                 .in(FileInfo::getId, ids)
@@ -1006,6 +1071,70 @@ public class DmService {
         }
     }
 
+    /**
+     * 删除导入文件时清掉 AI 动态表里的对应行 + 对应 AI 任务,并刷新 AI 治理节点计数。
+     *
+     * @param rootIds      整文件删除的文件根 id
+     * @param sheetTargets 只删工作表的记录(按 file_id + sheet_id 精确删)
+     */
+    private void cleanupAiDynamicRows(Set<Long> rootIds, List<FileInfo> sheetTargets) {
+        List<Long> sheetIds = sheetTargets.stream().map(FileInfo::getId).filter(Objects::nonNull).toList();
+        if (rootIds.isEmpty() && sheetIds.isEmpty()) {
+            return;
+        }
+        List<com.zsjz.ai.module.aiclean.entity.AiCleanJob> jobs = aiCleanJobMapper.selectList(
+                aiJobScopeQuery(rootIds, sheetIds));
+        if (CollUtil.isEmpty(jobs)) {
+            return;
+        }
+        Set<Long> templateIds = jobs.stream()
+                .map(com.zsjz.ai.module.aiclean.entity.AiCleanJob::getAiTemplateId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        for (Long templateId : templateIds) {
+            com.zsjz.ai.module.aiclean.entity.AiCleanTemplate tpl = aiCleanTemplateMapper.selectById(templateId);
+            if (tpl == null || StrUtil.isBlank(tpl.getTableNameEn())) {
+                continue;
+            }
+            long deleted = 0L;
+            if (!rootIds.isEmpty()) {
+                deleted += com.zsjz.ai.module.aiclean.exec.AiDuckDb.deleteRowsByFileIds(
+                        tpl.getTableNameEn(), new ArrayList<>(rootIds));
+            }
+            for (FileInfo sheet : sheetTargets) {
+                if (sheet.getId() != null && sheet.getPid() != null && sheet.getPid() != 0L) {
+                    deleted += com.zsjz.ai.module.aiclean.exec.AiDuckDb.deleteByFileSheet(
+                            tpl.getTableNameEn(), sheet.getPid(), sheet.getId());
+                }
+            }
+            log.info("删除文件时清理 AI 动态表行: table={}, fileIds={}, sheetIds={}, rows={}",
+                    tpl.getTableNameEn(), rootIds, sheetIds, deleted);
+        }
+        // 任务行同步清掉,避免 job 指向已不存在的文件(模板共享,不删模板本身)
+        aiCleanJobMapper.delete(aiJobScopeQuery(rootIds, sheetIds));
+        // 行数变了要重建 AI 治理节点;失败只记日志,不影响文件删除结果
+        try {
+            aiGovernService.calcAiTree();
+        } catch (Exception e) {
+            log.warn("删除文件后刷新 AI 治理节点失败(不影响删除结果): {}", e.getMessage());
+        }
+    }
+
+    private com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<com.zsjz.ai.module.aiclean.entity.AiCleanJob> aiJobScopeQuery(
+            Set<Long> rootIds, List<Long> sheetIds) {
+        var query = Wrappers.<com.zsjz.ai.module.aiclean.entity.AiCleanJob>lambdaQuery();
+        if (!rootIds.isEmpty()) {
+            query.in(com.zsjz.ai.module.aiclean.entity.AiCleanJob::getFileId, rootIds);
+        }
+        if (!sheetIds.isEmpty()) {
+            if (!rootIds.isEmpty()) {
+                query.or();
+            }
+            query.in(com.zsjz.ai.module.aiclean.entity.AiCleanJob::getSheetId, sheetIds);
+        }
+        return query;
+    }
+
     private void deleteBusinessTablesConcurrently(List<String> tableNames, List<Long> ids) {
         if (CollUtil.isEmpty(tableNames)) {
             return;

+ 19 - 1
ai-server/src/main/java/com/zsjz/ai/module/govern/serivce/GovernTreeService.java

@@ -83,7 +83,25 @@ public class GovernTreeService extends ServiceImpl<GovernTreeMapper, GovernTree>
      * @return 构建完成的治理树列表
      */
     public List<GovernTree> getTree() {
-        return TreeUtils.build(list(Wrappers.lambdaQuery(GovernTree.class).gt(GovernTree::getDataCount, 0)));
+        List<GovernTree> tree = TreeUtils.build(list(Wrappers.lambdaQuery(GovernTree.class).gt(GovernTree::getDataCount, 0)));
+        fillAiTemplateIds(tree);
+        return tree;
+    }
+
+    /**
+     * 给 AI 动态表节点补字符串形式的模板 id(id 取负存放,雪花 id 超过 JS 安全整数范围,
+     * 前端用 JSON number 换算会丢精度,必须下发字符串)。
+     */
+    private void fillAiTemplateIds(List<GovernTree> nodes) {
+        if (CollUtil.isEmpty(nodes)) {
+            return;
+        }
+        for (GovernTree node : nodes) {
+            if (node.getId() != null && node.getId() < 0) {
+                node.setAiTemplateId(String.valueOf(-node.getId()));
+            }
+            fillAiTemplateIds(node.getChildren());
+        }
     }
 
     /**

+ 2 - 2
ai-server/src/test/java/com/zsjz/ai/module/aiclean/AiCleanPgRoundTripTest.java

@@ -62,7 +62,7 @@ class AiCleanPgRoundTripTest {
     private com.zsjz.ai.module.aiclean.store.AiCleanStore store;
 
     @Test
-    @DisplayName("走生产入口 register:表名自动补成 ai_t{id},字段与规则一并落库")
+    @DisplayName("走生产入口 register:表名自动补成 ai_clean_{id},字段与规则一并落库")
     void templateRoundTrip() {
         try {
             AiCleanTemplate tpl = new AiCleanTemplate();
@@ -92,7 +92,7 @@ class AiCleanPgRoundTripTest {
             assertNotNull(id);
             // 表名由 id 推导,而 id 只有雪花生成后才有 —— register 必须把顺序处理对,
             // 否则 NOT NULL 直接拒(本用例第一版就是手插才踩到)
-            assertEquals("ai_t" + id, tpl.getTableNameEn());
+            assertEquals(AiCleanTemplate.physicalTableName(id), tpl.getTableNameEn());
 
             AiCleanTemplate back = templateMapper.selectById(id);
             assertEquals("ONLINE", back.getStatus());

+ 73 - 0
ai-server/src/test/java/com/zsjz/ai/module/aiclean/service/AiCleanTableNamingTest.java

@@ -0,0 +1,73 @@
+package com.zsjz.ai.module.aiclean.service;
+
+import com.zsjz.ai.module.aiclean.entity.AiCleanTemplate;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * AI 动态表命名规则(需求 2026-10-18)的纯函数测试:
+ * 中文名 —— Excel 用 sheet 名;sheet 名缺失用「文件名称 + sheetN」;CSV 用文件名称。
+ * 物理表名 —— {@code ai_clean_} + 模板雪花 id。
+ *
+ * @author cc
+ * @since 2026/10/18
+ */
+class AiCleanTableNamingTest {
+
+    private static final String FILE_NAME = "2111759372622880768_周海--车辆登记信息.xls";
+
+    @Test
+    @DisplayName("动态表物理名:ai_clean_ 前缀 + 模板雪花 id")
+    void physicalTableNameUsesAiCleanPrefix() {
+        assertEquals("ai_clean_2109826345177022464", AiCleanTemplate.physicalTableName(2109826345177022464L));
+        assertEquals("ai_clean_1", AiCleanTemplate.physicalTableName(1L));
+    }
+
+    @Test
+    @DisplayName("Excel:中文名直接用 sheet 名称(文件名的 ID 前缀/扩展名不参与)")
+    void excelUsesSheetName() {
+        assertEquals("机动车信息", AiCleanService.buildTableNameCn("XLS", FILE_NAME, "机动车信息", 1));
+        assertEquals("星际航运", AiCleanService.buildTableNameCn("XLSX", FILE_NAME, " 星际航运 ", 2));
+    }
+
+    @Test
+    @DisplayName("sheet 名称缺失:中文名 = 文件名称(去雪花ID前缀+扩展名)+ sheetN")
+    void blankSheetNameFallsBackToFileNamePlusOrdinal() {
+        assertEquals("周海--车辆登记信息sheet2", AiCleanService.buildTableNameCn("XLS", FILE_NAME, null, 2));
+        assertEquals("周海--车辆登记信息sheet3", AiCleanService.buildTableNameCn("XLSX", FILE_NAME, "  ", 3));
+        assertEquals("无前缀文件sheet1",
+                AiCleanService.buildTableNameCn("XLS", "无前缀文件.xls", null, 0), "序号兜底 1 基");
+    }
+
+    @Test
+    @DisplayName("CSV:整份文件就是一张表,中文名 = 文件名称(sheet 名不参与)")
+    void csvUsesFileName() {
+        String csv = "2111759372622880768_周海--通信18905810269.csv";
+        assertEquals("周海--通信18905810269", AiCleanService.buildTableNameCn("CSV", csv, null, 1));
+        assertEquals("周海--通信18905810269", AiCleanService.buildTableNameCn("csv", csv, "Sheet1", 1));
+    }
+
+    @Test
+    @DisplayName("文件名/表名全缺失:中文名不能为空(DDL 非空约束),且长度不超过列宽 200")
+    void alwaysProducesNonBlankBoundedName() {
+        assertEquals("未命名文件sheet1", AiCleanService.buildTableNameCn("XLS", null, null, 1));
+        assertEquals("未命名文件", AiCleanService.buildTableNameCn("CSV", "  ", null, 1));
+
+        String tooLong = "表".repeat(260);
+        assertEquals(200, AiCleanService.fitNameCn(tooLong).length());
+        assertEquals(200, AiCleanService.buildTableNameCn("XLS", FILE_NAME, tooLong, 1).length());
+    }
+
+    @Test
+    @DisplayName("是否需要 sheet 序号:CSV 与有 sheet 名都不需要")
+    void needsOrdinalOnlyForBlankExcelSheet() {
+        assertFalse(AiCleanService.needsSheetOrdinal("CSV", null));
+        assertFalse(AiCleanService.needsSheetOrdinal("XLS", "机动车信息"));
+        assertTrue(AiCleanService.needsSheetOrdinal("XLS", null));
+        assertTrue(AiCleanService.needsSheetOrdinal("XLSX", "   "));
+    }
+}

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

@@ -40,7 +40,7 @@ class SelfHeaderPlanTest {
     @DisplayName("兜底绑定的执行计划:列名消毒、类型全 TEXT 兜底")
     void planFallsBackToTextColumns() {
         List<RuleBind> binds = AiCleanService.selfHeaderBinds(List.of("舰船编号", "出港日期"));
-        AiRulePlan.Plan plan = AiRulePlan.forAi(binds, List.of("舰船编号", "出港日期"), "ai_t");
+        AiRulePlan.Plan plan = AiRulePlan.forAi(binds, List.of("舰船编号", "出港日期"));
 
         assertEquals(2, plan.columns().size());
         assertTrue(plan.columns().stream().allMatch(c -> "TEXT".equalsIgnoreCase(c.fieldType())),

+ 1 - 1
sql/ai_clean.sql

@@ -20,7 +20,7 @@ create table if not exists ai_clean_template
 (
     id                   bigint primary key,
     table_name_cn        varchar(200)  not null,          -- 中文名,模型语义命名
-    table_name_en        varchar(200)  not null,          -- 物理表名 ai_t{id},同时是前端分流的 ai_ 前缀来源
+    table_name_en        varchar(200)  not null,          -- 物理表名 ai_clean_{id}(2026-10-18 起;历史表为 ai_t{id})
     headers              varchar(4000),                   -- 结构归一后的列头,逗号分隔
     header_md5           varchar(64)   not null,          -- 归一化表头指纹(唯一)
     header_line_no       integer,                         -- 表头所在行(多级表头压平后的那一行)