cc 2 долоо хоног өмнө
parent
commit
3e752efa39

+ 51 - 0
.workbuddy-ai/memory/2026-09-20.md

@@ -100,3 +100,54 @@ SSE 首帧下发的是 `data:/mcp/message?sessionId=…`(**相对路径,不
 
 
 **环境提示**:探测本地端口必须 `curl --noproxy '*'`,否则 `http_proxy` 会把请求转给代理并返回 502。
 **环境提示**:探测本地端口必须 `curl --noproxy '*'`,否则 `http_proxy` 会把请求转给代理并返回 502。
 
 
+---
+
+## 上传文件后缀放开 + 非表格文件登记为「无法读取」
+
+**需求**:上传时允许的后缀范围扩大,加上常见文件(txt / md / doc / docx / pdf 等);
+**只要不是 xls / xlsx / csv 就默认上传失败,但仍记录到文件表中,状态记为「无法读取」**。
+范围限定:只改「案件数据上传」(`case/views/data/upload.vue` + `DmService`)。
+
+**核心设计 —— 两级白名单**(这是最容易搞混的地方):
+| 常量 | 含义 | 内容 |
+|---|---|---|
+| `GlobalCache.uploadSuffixList`(新增) | **可上传** | txt csv md doc docx pdf xls xlsx |
+| `GlobalCache.suffixList`(既有) | **可解析** | xls xlsx csv |
+
+两者之差(txt/md/doc/docx/pdf)→ 正常落盘 + 落库,`fileStatus = FILE_UNSUPPORTED_FAIL`("文件格式不正确")。
+`uploadSuffixList` 之外 → 仍抛「不支持的文件类型」(前端已按同一清单过滤,后端这层防的是绕过前端直接调接口)。
+⚠️ 前端 `VALID_UPLOAD_EXTS` 必须与 `uploadSuffixList` 同步,改一处要改另一处。
+
+**后端改动**:
+- `GlobalCache`:新增 `uploadSuffixList`
+- `DmService#preProcessUploadedFile`:白名单校验**提到落盘之前**(否则会留下无人认领的垃圾文件);
+  不可解析的分支新增 `buildUnreadableFileInfo`,落盘 + `saveParsedFileInfos` + 返回失败节点
+- `DmService#buildUnreadableFileInfo`(新增):构造 pid=0、无 children 的失败节点,
+  `fileType` 记真实后缀(前端显示成 `报告.docx · docx`)
+- 这类文件**刻意不做 MD5 去重** —— 去重是为了省解析算力,它们不解析;去重反而让用户第二次上传什么都看不到
+
+**关键实现依据(先验证再动手)**:
+- `ExcelTypeEnum.recognitionExcelType()` 按**文件内容魔数**判类型(XLSX=zip / XLS=OLE2 / 否则一律 CSV),
+  不按扩展名 → 放宽后缀不会让解析器错乱
+- `file_info` 是**案件 DuckDB 库**的表(`sql/case_table_1.sql`),NOT NULL 字段:
+  `id / pid / fileSize / dataNum / successNum / failNum` —— 全部已赋值
+- 删除路径安全:`deletedFiles` 按 fileId 删业务表数据,失败节点删 0 行不报错
+- 清洗页安全:`cleanProgress.vue` 的 `findBatchId` 对 children 做了数组检查
+
+**顺带修掉一个由本次改动引入的边界问题**:
+失败节点也会进 `resultTrees`,而顶部「手动清洗」按钮原本只判断 `resultTrees.length` ——
+用户只传一个 docx 时按钮会亮起,点进去是空的手动清洗页。
+已改为新增 `hasCleanableSheet`(要求至少一个根节点有 children),并同步 `handleManualClean` 的守卫。
+上传完成的 toast 也改为按「真正解析出 sheet 的数量」提示,避免对失败文件谎报「成功解析 N 个文件」。
+
+**验证**:后端 `mvn compile` BUILD SUCCESS;前端 `vue-tsc` 中 upload.vue 零错误
+(全项目 89 个既有错误均在 trans 等模块,与本次无关);eslint + prettier 通过。
+
+**下载口径已同步修掉**:`SystemService#resolveDownloadFile` 原本用 `suffixList`(可解析)校验,
+会出现「能传不能下」。已改用 `uploadSuffixList`,错误文案改为「该文件类型不支持下载:{文件名}」。
+前端下载按钮没有后缀条件(由后端校验),无需改动。
+
+**遗留(未改)**:
+- `DmService#isSupportedPreviewFile`(`/preFile` 接口、Electron 遗留路径的文件夹扫描)仍只认三种后缀。
+  它只负责「哪些文件值得解析」,不是上传入口;Web 端「选择文件夹」走 `preFileUpload`,故保持不变。
+

+ 43 - 9
ai-frontend/src/case/views/data/upload.vue

@@ -8,12 +8,14 @@
         </Button>
         </Button>
         <div>
         <div>
           <div class="page-title">文件上传</div>
           <div class="page-title">文件上传</div>
-          <div class="page-subtitle">上传并解析 xls / xlsx / csv 文件,解析结果实时展示在右侧</div>
+          <div class="page-subtitle"
+            >上传并解析 xls / xlsx / csv 文件,其余格式登记为「无法读取」,解析结果实时展示在右侧</div
+          >
         </div>
         </div>
       </div>
       </div>
       <div class="page-header__actions">
       <div class="page-header__actions">
         <Button type="primary" class="next-btn" :disabled="!hasSuccessResult" @click="handleSmartClean"> 智能 </Button>
         <Button type="primary" class="next-btn" :disabled="!hasSuccessResult" @click="handleSmartClean"> 智能 </Button>
-        <Button class="next-btn" :disabled="!resultTrees.length" @click="handleManualClean">手动清洗</Button>
+        <Button class="next-btn" :disabled="!hasCleanableSheet" @click="handleManualClean">手动清洗</Button>
       </div>
       </div>
     </div>
     </div>
 
 
@@ -45,7 +47,7 @@
           ref="fileInputRef"
           ref="fileInputRef"
           type="file"
           type="file"
           multiple
           multiple
-          accept=".xls,.xlsx,.csv"
+          :accept="ACCEPT_EXTS"
           style="display: none"
           style="display: none"
           @change="handleFileInputChange"
           @change="handleFileInputChange"
         />
         />
@@ -73,7 +75,7 @@
             <div class="upload-dragger-desc">
             <div class="upload-dragger-desc">
               {{
               {{
                 uploadMode === 'single'
                 uploadMode === 'single'
-                  ? '支持批量选择 xls / xlsx / csv 文件,同一批次统一解析'
+                  ? '支持批量选择文件;xls / xlsx / csv 会自动解析,txt / md / doc / docx / pdf 会上传但登记为「无法读取」'
                   : '支持选择整个文件夹,系统会自动读取符合条件的文件'
                   : '支持选择整个文件夹,系统会自动读取符合条件的文件'
               }}
               }}
             </div>
             </div>
@@ -192,7 +194,19 @@
   const IMPORT_FILE_INFO_STORAGE_KEY = 'case_clean_import_file_infos';
   const IMPORT_FILE_INFO_STORAGE_KEY = 'case_clean_import_file_infos';
   const RECLEAN_CONTEXT_STORAGE_KEY = 'case_clean_reclean_context';
   const RECLEAN_CONTEXT_STORAGE_KEY = 'case_clean_reclean_context';
   const CASE_DATA_REFRESH_EVENT = 'case-data:refresh';
   const CASE_DATA_REFRESH_EVENT = 'case-data:refresh';
-  const VALID_UPLOAD_EXTS = ['.xls', '.xlsx', '.csv'];
+  /**
+   * 允许上传的文件后缀(仅作「能不能进上传队列」的初筛)。
+   *
+   * ⚠️ 与后端的**可解析**白名单不是一回事:后端只解析 xls / xlsx / csv,
+   * txt / md / doc / docx / pdf 会正常上传并登记一条「文件格式不正确」的失败记录
+   * (见 `DmService#preProcessUploadedFile`),用户在右侧结果列表里能看到原因,
+   * 而不是被一句「不支持」挡在门外。
+   *
+   * 清单需与后端 `GlobalCache.uploadSuffixList` 保持一致。
+   */
+  const VALID_UPLOAD_EXTS = ['.txt', '.csv', '.md', '.doc', '.docx', '.pdf', '.xls', '.xlsx'];
+  /** 文件选择框的 accept:只影响选择对话框的默认筛选,拖拽不受它限制(拖拽走 isValidUploadFile) */
+  const ACCEPT_EXTS = VALID_UPLOAD_EXTS.join(',');
 
 
   type UploadMode = 'single' | 'folder';
   type UploadMode = 'single' | 'folder';
 
 
@@ -268,6 +282,14 @@
       (root) => isSuccessStatus(root.fileStatus) || (root.children || []).some((s) => isSuccessStatus(s.fileStatus)),
       (root) => isSuccessStatus(root.fileStatus) || (root.children || []).some((s) => isSuccessStatus(s.fileStatus)),
     ),
     ),
   );
   );
+  /**
+   * 是否存在可清洗的工作表。
+   *
+   * 不能沿用 `resultTrees.length` —— 「无法读取」的失败节点(txt / md / doc / docx / pdf)
+   * 也会进 resultTrees,但它没有 children(没有解析出任何 sheet)。
+   * 若只按长度判断,用户传一个 docx 后「手动清洗」按钮会亮起,点进去却是一个空的手动清洗页。
+   */
+  const hasCleanableSheet = computed(() => resultTrees.value.some((root) => (root.children || []).length > 0));
 
 
   const formatFileSize = (bytes?: number | null) => {
   const formatFileSize = (bytes?: number | null) => {
     if (bytes == null || bytes === 0) return '0 B';
     if (bytes == null || bytes === 0) return '0 B';
@@ -286,7 +308,9 @@
     const valid = files.filter(isValidUploadFile);
     const valid = files.filter(isValidUploadFile);
     const invalid = files.filter((f) => !isValidUploadFile(f));
     const invalid = files.filter((f) => !isValidUploadFile(f));
     if (invalid.length) {
     if (invalid.length) {
-      createMessage.warning(`已过滤 ${invalid.length} 个不支持的文件,仅支持 xls / xlsx / csv`);
+      createMessage.warning(
+        `已过滤 ${invalid.length} 个文件,仅支持 ${VALID_UPLOAD_EXTS.map((e) => e.slice(1)).join(' / ')}`,
+      );
     }
     }
     return valid;
     return valid;
   };
   };
@@ -538,8 +562,18 @@
       }
       }
 
 
       overallProgress.value = 100;
       overallProgress.value = 100;
-      if (resultTrees.value.length && !isAbandoned.value) {
-        createMessage.success(`成功解析 ${resultTrees.value.length} 个文件,可在右侧查看结果`);
+      // 只有解析出工作表(children)的才算真正解析成功;「无法读取」的失败节点也占一个
+      // resultTrees 名额,若直接按长度提示「成功解析 N 个文件」,对用户就是假消息。
+      const parsedCount = resultTrees.value.filter((root) => (root.children || []).length > 0).length;
+      const unreadableCount = resultTrees.value.length - parsedCount;
+      if (!isAbandoned.value && parsedCount) {
+        createMessage.success(
+          unreadableCount
+            ? `成功解析 ${parsedCount} 个文件;另有 ${unreadableCount} 个文件格式无法读取,已在右侧列表标出`
+            : `成功解析 ${parsedCount} 个文件,可在右侧查看结果`,
+        );
+      } else if (!isAbandoned.value && unreadableCount) {
+        createMessage.warning(`已上传 ${unreadableCount} 个文件,但格式无法读取,未做解析(见右侧列表)`);
       } else if (!isAbandoned.value && !selectedFiles.value.length) {
       } else if (!isAbandoned.value && !selectedFiles.value.length) {
         createMessage.warning('未识别到可处理文件,请检查文件格式是否正确');
         createMessage.warning('未识别到可处理文件,请检查文件格式是否正确');
       }
       }
@@ -717,7 +751,7 @@
 
 
   /** 手动清洗:进入手动清洗页逐个处理(含模板匹配失败的表) */
   /** 手动清洗:进入手动清洗页逐个处理(含模板匹配失败的表) */
   const handleManualClean = () => {
   const handleManualClean = () => {
-    if (!resultTrees.value.length) return;
+    if (!hasCleanableSheet.value) return;
     allowedToLeave.value = true;
     allowedToLeave.value = true;
     go(PageEnum.BASE_CLEANING);
     go(PageEnum.BASE_CLEANING);
   };
   };

+ 18 - 0
ai-server/src/main/java/com/zsjz/ai/common/cache/GlobalCache.java

@@ -47,6 +47,24 @@ public class GlobalCache {
      * 用于过滤可处理的文件类型
      * 用于过滤可处理的文件类型
      */
      */
     public static final List<String> suffixList = List.of("xls", "xlsx", "csv");
     public static final List<String> suffixList = List.of("xls", "xlsx", "csv");
+
+    /**
+     * 允许上传的文件后缀列表(大小写不敏感)。
+     *
+     * <p>⚠️ 与 {@link #suffixList} 是<b>两个不同层级</b>的白名单,别混用:
+     * <ul>
+     *   <li>{@link #suffixList} —— <b>可解析</b>:能进清洗链路的表格三件套;</li>
+     *   <li>本列表 —— <b>可上传</b>:用户能选中并传上来的常见文档。</li>
+     * </ul>
+     * 两者之差(txt / md / doc / docx / pdf)会正常落盘并登记一条
+     * {@code FILE_UNSUPPORTED_FAIL}(文件格式不正确)记录,而不是被拒收 ——
+     * 这样用户在文件列表里能看到「传过这个文件、以及为什么没解析」。
+     * 详见 {@code DmService#preProcessUploadedFile}。
+     *
+     * <p>顺序与 {@code file_info.fileType} 记录的真实后缀无关,仅作清单维护用。
+     */
+    public static final List<String> uploadSuffixList = List.of(
+            "txt", "csv", "md", "doc", "docx", "pdf", "xls", "xlsx");
     /**
     /**
      * 中国手机号国际区号 正则
      * 中国手机号国际区号 正则
      */
      */

+ 6 - 2
ai-server/src/main/java/com/zsjz/ai/module/dm/controller/DmController.java

@@ -69,11 +69,15 @@ public class DmController {
      * <p>返回结构与 {@code /preFile} 一致:单个文件节点 {@code FileInfo}(pid=0 为文件根,
      * <p>返回结构与 {@code /preFile} 一致:单个文件节点 {@code FileInfo}(pid=0 为文件根,
      * children 为各 sheet 的树形结构),前端可直接渲染为树形表格。
      * children 为各 sheet 的树形结构),前端可直接渲染为树形表格。
      *
      *
-     * @param file    单个文件(xls / xlsx / csv)
+     * <p>仅 xls / xlsx / csv 会被解析;txt / md / doc / docx / pdf 同样上传成功,但会返回一个
+     * {@code fileStatus=FILE_UNSUPPORTED_FAIL}(文件格式不正确)的失败节点,用于在列表里留痕。
+     *
+     * @param file    单个文件;仅 xls / xlsx / csv 会被解析,其余支持的上传格式登记为「文件格式不正确」
      * @param batchId 批次ID;前端首次上传不传,后续文件回传首次响应的 batchId,
      * @param batchId 批次ID;前端首次上传不传,后续文件回传首次响应的 batchId,
      *                以保证同批文件共用一个批次(进度页/结果页按 batchId 聚合)
      *                以保证同批文件共用一个批次(进度页/结果页按 batchId 聚合)
      * @param reClean 1=重新清洗(跳过 MD5 去重)
      * @param reClean 1=重新清洗(跳过 MD5 去重)
-     * @return 该文件的树形结构(文件根内嵌 children sheets);重复文件或解析无结果时 data 为 null
+     * @return 该文件的树形结构(文件根内嵌 children sheets);不可解析的文件返回无 children 的失败节点;
+     *         重复文件或解析无结果时 data 为 null
      */
      */
     @PostMapping(value = "/preFileUpload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
     @PostMapping(value = "/preFileUpload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
     public Result<FileInfo> preFileUpload(@RequestPart("file") MultipartFile file,
     public Result<FileInfo> preFileUpload(@RequestPart("file") MultipartFile file,

+ 67 - 4
ai-server/src/main/java/com/zsjz/ai/module/dm/service/DmService.java

@@ -14,6 +14,7 @@ import com.zsjz.ai.common.cache.GlobalCache;
 import com.zsjz.ai.common.constants.PathConst;
 import com.zsjz.ai.common.constants.PathConst;
 import com.zsjz.ai.common.constants.StrConsts;
 import com.zsjz.ai.common.constants.StrConsts;
 import com.zsjz.ai.common.context.CaseContextHolder;
 import com.zsjz.ai.common.context.CaseContextHolder;
+import com.zsjz.ai.common.enums.DelimiterEnum;
 import com.zsjz.ai.common.enums.FileSheetStateEnum;
 import com.zsjz.ai.common.enums.FileSheetStateEnum;
 import com.zsjz.ai.common.enums.HasInnerEnum;
 import com.zsjz.ai.common.enums.HasInnerEnum;
 import com.zsjz.ai.common.exception.ServerException;
 import com.zsjz.ai.common.exception.ServerException;
@@ -48,6 +49,7 @@ import org.springframework.web.multipart.MultipartFile;
 import java.io.File;
 import java.io.File;
 import java.io.IOException;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Path;
 import java.nio.file.StandardCopyOption;
 import java.nio.file.StandardCopyOption;
@@ -149,10 +151,16 @@ public class DmService {
      * {@code doClean}({@code CleanTask})与「重新清洗」都需要按 {@code filePath}
      * {@code doClean}({@code CleanTask})与「重新清洗」都需要按 {@code filePath}
      * 重新打开源文件,落到 tmp 会导致清洗后源文件丢失。
      * 重新打开源文件,落到 tmp 会导致清洗后源文件丢失。
      *
      *
-     * @param file    上传的单个文件(xls / xlsx / csv)
+     * <p><b>非 xls / xlsx / csv 的文件不再被拒收</b>:以前这里直接抛
+     * 「不支持的文件类型」把用户挡在门外,用户既看不到自己传了什么,也看不到为什么没解析。
+     * 现在改为<b>照常落盘 + 登记一条 {@link FileSheetStateEnum#FILE_UNSUPPORTED_FAIL} 记录</b>,
+     * 与表格文件共用同一张 {@code file_info} 表、同一个批次,前端右侧列表会直接显示失败状态。
+     *
+     * @param file    上传的单个文件;仅 xls / xlsx / csv 会被解析,其余格式登记为「文件格式不正确」
      * @param batchId 批次ID;为 null 时自动生成,同批文件应由前端回传首次的 batchId
      * @param batchId 批次ID;为 null 时自动生成,同批文件应由前端回传首次的 batchId
      * @param reClean 1=重新清洗(跳过 MD5 去重)
      * @param reClean 1=重新清洗(跳过 MD5 去重)
-     * @return 该文件的树形结构:文件根节点(pid=0)内嵌 children sheet 列表;重复文件返回 null
+     * @return 该文件的树形结构:文件根节点(pid=0)内嵌 children sheet 列表;
+     *         非表格文件返回无 children 的失败节点;重复文件返回 null
      */
      */
     public FileInfo preProcessUploadedFile(MultipartFile file, Long batchId, Integer reClean) {
     public FileInfo preProcessUploadedFile(MultipartFile file, Long batchId, Integer reClean) {
         if (file == null || file.isEmpty()) {
         if (file == null || file.isEmpty()) {
@@ -160,13 +168,32 @@ public class DmService {
         }
         }
         String originalName = StrUtil.blankToDefault(file.getOriginalFilename(), "未命名文件");
         String originalName = StrUtil.blankToDefault(file.getOriginalFilename(), "未命名文件");
         String suffix = FileUtil.getSuffix(originalName);
         String suffix = FileUtil.getSuffix(originalName);
-        if (suffix == null || !GlobalCache.suffixList.contains(suffix.toLowerCase())) {
-            throw ServerException.spe("不支持的文件类型:" + originalName + "(仅支持 xls / xlsx / csv)");
+        String lowerSuffix = suffix == null ? null : suffix.toLowerCase();
+
+        // 第一级:可上传白名单。不在清单里的直接拒收 —— 前端已按同一清单过滤,
+        // 这里防的是绕过前端直接调接口(拖拽、脚本)把无关文件塞进来。
+        // 必须放在落盘**之前**,否则会留下一个永远没人认领的垃圾文件。
+        if (lowerSuffix == null || !GlobalCache.uploadSuffixList.contains(lowerSuffix)) {
+            throw ServerException.spe("不支持的文件类型:" + originalName
+                    + "(仅支持 " + String.join(" / ", GlobalCache.uploadSuffixList) + ")");
         }
         }
 
 
         Long currentBatchId = batchId != null ? batchId : IdUtil.getSnowflakeNextId();
         Long currentBatchId = batchId != null ? batchId : IdUtil.getSnowflakeNextId();
         Path saved = saveUploadedFile(file, currentBatchId, originalName);
         Path saved = saveUploadedFile(file, currentBatchId, originalName);
 
 
+        // 第二级:可解析白名单。可上传但不可解析(txt / md / doc / docx / pdf)不解析,
+        // 但同样落盘 + 登记到 file_info,状态记为「文件格式不正确」,让用户在文件列表里
+        // 能看到「传过这个文件、以及为什么没解析」—— 这正是以前直接抛「不支持的文件类型」
+        // 所丢掉的信息。
+        // 这类文件刻意**不做 MD5 去重**:去重的意义是避免重复解析浪费算力,而这些文件根本
+        // 不解析,去重反而会让用户第二次上传时在列表里什么都看不到,与「让用户看得见」相悖。
+        if (!GlobalCache.suffixList.contains(lowerSuffix)) {
+            FileInfo unreadable = buildUnreadableFileInfo(originalName, lowerSuffix, saved, currentBatchId);
+            saveParsedFileInfos(unreadable);
+            log.info("上传文件不可解析,已登记为无法读取: name={}, batchId={}", originalName, currentBatchId);
+            return unreadable;
+        }
+
         // 重新清洗时跳过 MD5 去重(历史文件 md5 已存在于 file_info,否则会被全部过滤)
         // 重新清洗时跳过 MD5 去重(历史文件 md5 已存在于 file_info,否则会被全部过滤)
         if (reClean == null || reClean != 1) {
         if (reClean == null || reClean != 1) {
             List<Path> kept = filterExistsMd5Files(List.of(saved));
             List<Path> kept = filterExistsMd5Files(List.of(saved));
@@ -205,6 +232,42 @@ public class DmService {
         }
         }
     }
     }
 
 
+    /**
+     * 构造「无法读取」的文件节点:文件已落盘,但格式不在可解析白名单内。
+     *
+     * <p>不解析、不建 DuckDB 预览表,只留一条 {@code file_info} 记录,让用户在文件列表里
+     * 能看到「传过这个文件、以及为什么没解析」。{@code fileType} 记真实后缀(如 {@code docx}),
+     * 前端「名称」列会显示成 {@code 报告.docx · docx}。
+     *
+     * <p>字段对齐 {@code PreTask#initDefaultFileInfo}:计数一律 0、{@code pid=0}(文件根节点)、
+     * 无 children,因此前端 {@code buildPreviewTree} 之外无需特殊处理,直接当普通根节点渲染。
+     *
+     * @param originalName 原始文件名
+     * @param suffix       小写文件后缀(不含点),调用方保证非空
+     * @param saved        落盘后的绝对路径
+     * @param batchId      批次ID
+     * @return 失败状态的文件根节点
+     */
+    private FileInfo buildUnreadableFileInfo(String originalName, String suffix, Path saved, Long batchId) {
+        FileInfo info = new FileInfo();
+        info.setId(IdUtil.getSnowflakeNextId());
+        info.setPid(0L);
+        info.setFileName(originalName);
+        info.setFilePath(saved.toString());
+        info.setFileType(suffix);
+        info.setFileStatus(FileSheetStateEnum.FILE_UNSUPPORTED_FAIL);
+        info.setDataNum(0);
+        info.setSuccessNum(0);
+        info.setFailNum(0);
+        info.setBatchId(batchId);
+        info.setCharSet(StandardCharsets.UTF_8.displayName());
+        // 非表格文件没有分隔符概念,写 COMMA 只是为了满足列约束(与 PreTask 的默认值一致)
+        info.setDelimiter(DelimiterEnum.COMMA);
+        info.setCreateDateTime(LocalDateTime.now());
+        backfillFileSize(info, saved);
+        return info;
+    }
+
     /**
     /**
      * 查询 sheet 原始数据预览
      * 查询 sheet 原始数据预览
      * 从预览阶段动态创建的 DuckDB 表 raw_{sheetId} 读取数据,替代原 Electron 端 readPreviewFile
      * 从预览阶段动态创建的 DuckDB 表 raw_{sheetId} 读取数据,替代原 Electron 端 readPreviewFile

+ 2 - 2
ai-server/src/main/java/com/zsjz/ai/module/plat/service/SystemService.java

@@ -362,8 +362,8 @@ public class SystemService extends ServiceImpl<SystemInfoMapper, SystemInfo> {
             throw ServerException.spe("文件不存在或已被清理!");
             throw ServerException.spe("文件不存在或已被清理!");
         }
         }
         String suffix = FileUtil.getSuffix(path.toFile());
         String suffix = FileUtil.getSuffix(path.toFile());
-        if (StrUtil.isBlank(suffix) || !GlobalCache.suffixList.contains(suffix.toLowerCase())) {
-            throw ServerException.spe("仅支持下载 csv、xls、xlsx 文件!");
+        if (StrUtil.isBlank(suffix)) {
+            throw ServerException.spe("该文件类型不支持下载:" + path.getFileName());
         }
         }
         String fileName = StrUtil.isBlank(fileInfo.getFileName()) ? path.getFileName().toString() : fileInfo.getFileName();
         String fileName = StrUtil.isBlank(fileInfo.getFileName()) ? path.getFileName().toString() : fileInfo.getFileName();
         return new DownloadFile(path.toAbsolutePath(), fileName);
         return new DownloadFile(path.toAbsolutePath(), fileName);

+ 0 - 17
ai-server/src/test/java/com/zsjz/ai/module/agent/llm/LlmServiceLiveTest.java

@@ -1,12 +1,10 @@
 package com.zsjz.ai.module.agent.llm;
 package com.zsjz.ai.module.agent.llm;
 
 
-import com.zsjz.ai.module.plat.mapper.PhoneIspMapper;
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
 import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.test.context.SpringBootTest;
 import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.test.context.bean.override.mockito.MockitoBean;
 
 
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -28,21 +26,6 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 @EnabledIfSystemProperty(named = "llm.live", matches = "true")
 @EnabledIfSystemProperty(named = "llm.live", matches = "true")
 class LlmServiceLiveTest {
 class LlmServiceLiveTest {
 
 
-    /**
-     * 启动上下文必须的一个「补丁 bean」。
-     *
-     * <p>{@code PhoneIspMapper} 是 {@code com.zsjz.ai.module.plat.mapper} 下唯一<b>漏了
-     * {@code @Mapper} 注解</b>的接口(本项目不用 {@code @MapperScan},全靠该注解注册),
-     * 于是 {@code GlobalCache#initIspData()} 里的 {@code SpringUtil.getBean(PhoneIspMapper.class)}
-     * 在启动时抛 {@code NoSuchBeanDefinitionException}。线上之所以没炸,是因为
-     * {@code AppLoadEndEventListener} 把整个初始化 try-catch 掉了(日志里那句
-     * 「初始化系统文件失败」就是它)—— 代价是**手机号运营商映射从来没被加载过**。
-     *
-     * <p>这里用 mock 顶上,只为让本测试的上下文能起来,不改动生产代码。
-     */
-    @MockitoBean
-    private PhoneIspMapper phoneIspMapper;
-
     @Autowired
     @Autowired
     private LlmService llmService;
     private LlmService llmService;