cc 2 周之前
父節點
當前提交
f833bbf32a

+ 68 - 39
.workbuddy-ai/memory/2026-09-20.md

@@ -93,61 +93,90 @@ SSE 首帧下发的是 `data:/mcp/message?sessionId=…`(**相对路径,不
 「DB 配置 → AgentModelFactory → HTTP SSE → 分片拼接 → usage 提取 → 错误码映射」。
 真厂商端点的 `LlmServiceLiveTest` 因本机无可用模型**未验证**。
 
-**顺带发现(未修)**:`com.zsjz.ai.module.plat.mapper.PhoneIspMapper` 是该包下唯一漏 `@Mapper`
-注解的接口,本项目不用 `@MapperScan` ⇒ `GlobalCache#initIspData()` 启动时抛
-`NoSuchBeanDefinitionException`,被 `AppLoadEndEventListener` try-catch 吞掉(日志「初始化系统文件失败」),
-**代价是手机号运营商映射从未加载**。测试里用 `@MockitoBean` 绕过。
+**★ 一次诊断更正(重要)**:`@SpringBootTest` 加载上下文失败,曾被误判为
+「`PhoneIspMapper` 漏了 `@Mapper`」——**这个结论是错的**。实测该 Mapper 的 `@Mapper` 一直都在,
+`module/plat/mapper` 包下 8 个 mapper 全都有。
+
+真因是 **`@DS("slave")`**:`AppLoadEndEventListener#run()` 裸调
+`GlobalCache#initCaseRocksDbData()`(**无 try-catch**),其中 `initIspData()` 调
+`PhoneIspMapper.selectList()`;该 Mapper 带 `@DS("slave")`(查**案件库**),
+测试环境没开案 → `CaseRoutingDataSource` 抛 `ServerException(400, "请先打开案件后再操作数据")`。
+日志里 bean 是**创建成功**的(`Creating MapperFactoryBean with name 'phoneIspMapper'`),
+报错发生在 SQL 执行阶段。前置的 `CardIssuerBankMapper` 不炸,是因为它只有 `@Mapper`、没有 `@DS`,走 master。
+
+另注:`AppLoadEndEventListener` 里被 try-catch 吞掉的**只有 `initDir()` 建目录**
+(日志「初始化系统文件失败」是它),`initCache()` / `initCaseRocksDbData()` 都是裸调用。
+`LlmServiceLiveTest` 是全项目**唯一**的 `@SpringBootTest`,所以只有它需要
+`@MockitoBean PhoneIspMapper` 这个补丁。
 
 **环境提示**:探测本地端口必须 `curl --noproxy '*'`,否则 `http_proxy` 会把请求转给代理并返回 502。
 
 ---
 
-## 上传文件后缀放开 + 非表格文件登记为「无法读取」
+## 上传文件后缀:只有 csv / xls / xlsx 真正上传(需求中途改过,以下是最终口径)
 
-**需求**:上传时允许的后缀范围扩大,加上常见文件(txt / md / doc / docx / pdf 等);
-**只要不是 xls / xlsx / csv 就默认上传失败,但仍记录到文件表中,状态记为「无法读取」**。
-范围限定:只改「案件数据上传」(`case/views/data/upload.vue` + `DmService`)。
+**需求演进**:最初是「放开到常见后缀 + 非表格文件登记为无法读取」,
+最终明确为:**前后端都不限制上传后缀;后端只校验后缀不是 csv / xls / xlsx 就跳过文件、
+不执行上传(不落盘),但要在文件表插一条「不支持解析」的记录**。
 
-**核心设计 —— 两级白名单**(这是最容易搞混的地方):
-| 常量 | 含义 | 内容 |
-|---|---|---|
-| `GlobalCache.uploadSuffixList`(新增) | **可上传** | txt csv md doc docx pdf xls xlsx |
-| `GlobalCache.suffixList`(既有) | **可解析** | xls xlsx csv |
+**最终实现**:
+- **前端不限制**:`upload.vue` 删掉 `VALID_UPLOAD_EXTS` / `filterValidFiles` / `accept` 属性,
+  任何文件都能进上传队列;文案统一为「xls / xlsx / csv 会自动解析,其余格式仅登记为『不支持解析』」
+- **后端只有一个校验集合**:`GlobalCache.suffixList = ["xls","xlsx","csv"]`
+  (中途加过的第二个白名单 `uploadSuffixList` 已删除,别再引入)
+- 后缀不是这三种 → `DmService#preProcessUploadedFile` **在 `saveUploadedFile` 之前**就返回,
+  **不落盘、不解析**,只调新增的 `buildUnsupportedFileInfo` + `saveParsedFileInfos` 插一条
+  `fileStatus = FILE_UNSUPPORTED_FAIL`(前端显示「文件格式不正确」)的记录
 
-两者之差(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 去重** —— 去重是为了省解析算力,它们不解析;去重反而让用户第二次上传什么都看不到
+**实现要点**:
+- 失败记录 `filePath` **刻意留空**(文件没落盘)→ `SystemService#resolveDownloadFile` 会明确
+  拒绝下载(「该记录没有可下载的源文件!」),而不是指向一个不存在的路径
+- `fileSize` 用 `MultipartFile#getSize()`(浏览器实际传上来的字节数)
+- **不做 MD5 去重**(不解析,去重反而让用户第二次上传时列表里什么都看不到)
+- `fileType` 记真实后缀,前端「名称」列显示成 `报告.docx · docx`
 
 **关键实现依据(先验证再动手)**:
 - `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 做了数组检查
+- 边界核查:`deletedFiles`(按 fileId 删业务表,失败记录删 0 行不报错)、
+  `clearUploadBatch`(按 batchId 删)、`cleanProgress.vue` 的 `findBatchId`(对 children 做了数组检查)、
+  `getFiles`(**无状态过滤**,失败记录 pid=0 会被当根节点正常显示)
 
 **顺带修掉一个由本次改动引入的边界问题**:
-失败节点也会进 `resultTrees`,而顶部「手动清洗」按钮原本只判断 `resultTrees.length` ——
-用户只传一个 docx 时按钮会亮起,点进去是空的手动清洗页。
-已改为新增 `hasCleanableSheet`(要求至少一个根节点有 children),并同步 `handleManualClean` 的守卫。
-上传完成的 toast 也改为按「真正解析出 sheet 的数量」提示,避免对失败文件谎报「成功解析 N 个文件」。
+失败记录也会进 `resultTrees`,而顶部「手动清洗」按钮原本只判断 `resultTrees.length` ——
+用户只传一个 docx 时按钮会亮起,点进去是空页面。已新增 `hasCleanableSheet`(要求至少一个根节点有 children)
+并同步 `handleManualClean` 守卫;上传完成 toast 也改为按「真正解析出 sheet 的数量」计数。
+
+**验证**:后端 `mvn compile` + `test-compile` 均 BUILD SUCCESS;前端 `vue-tsc` 中 upload.vue 零错误
+(全项目 89 个既有错误均在 trans 等模块,与本次无关);eslint 通过。
+
+**未动的地方**(按「不要改其他问题」的要求):
+- `DmService#isSupportedPreviewFile`(`/preFile` 接口、Electron 遗留的文件夹扫描)仍用 `suffixList` 三种。
+  它只负责「哪些文件值得解析」,不是上传入口;Web 端「选择文件夹」走 `preFileUpload`,不受影响。
+- `SystemService#resolveDownloadFile` 现只判「后缀非空」,不限白名单(能传就能下)。
+
+
+### 运行时验证:DmServiceUploadSuffixTest(5 例全绿)
 
-**验证**:后端 `mvn compile` BUILD SUCCESS;前端 `vue-tsc` 中 upload.vue 零错误
-(全项目 89 个既有错误均在 trans 等模块,与本次无关);eslint + prettier 通过。
+新增 `ai-server/src/test/java/com/zsjz/ai/module/dm/service/DmServiceUploadSuffixTest.java`,
+纯 Mockito、无需 Spring 上下文 / 案件库(不支持后缀在 `saveUploadedFile` 之前就 return 了,
+整条路径不碰 `StateManager`、不碰 DuckDB),秒级跑完。
 
-**下载口径已同步修掉**:`SystemService#resolveDownloadFile` 原本用 `suffixList`(可解析)校验,
-会出现「能传不能下」。已改用 `uploadSuffixList`,错误文案改为「该文件类型不支持下载:{文件名}」。
-前端下载按钮没有后缀条件(由后端校验),无需改动。
+```bash
+cd /e/workspace/zsjz-ai && bash /tmp/mvnw.sh -o -pl ai-server test \
+  -Dtest='DmServiceUploadSuffixTest' -DfailIfNoTests=false -B -Dstyle.color=never
+# Tests run: 5, Failures: 0, Errors: 0, Skipped: 0 -- BUILD SUCCESS
+```
 
-**遗留(未改)**:
-- `DmService#isSupportedPreviewFile`(`/preFile` 接口、Electron 遗留路径的文件夹扫描)仍只认三种后缀。
-  它只负责「哪些文件值得解析」,不是上传入口;Web 端「选择文件夹」走 `preFileUpload`,故保持不变。
+**测试技巧(可复用)**:验证「表格后缀确实会走到落盘分支」不需要真实案件库 ——
+`saveUploadedFile` 第一行就是 `if (!StateManager.instance().isCaseOpened()) throw ServerException.spe("请先打开案件后再上传文件!")`,
+所以**「未开案时抛这个异常」本身就是进入了上传分支的证据**,再用
+`verify(fileInfoMapper, never()).insert(anyCollection())` 反证没有写文件表。
 
+**踩到的编译细节**:`FileInfoMapper extends BaseMapper<FileInfo>`,本身没有 `insert`;
+`fileInfoMapper.insert(records)` 传的是 `List<FileInfo>`,用的是 MyBatis-Plus 3.5.7+ 的
+`BaseMapper#insert(Collection<T>)` 默认方法(不是 `insert(T)`)。Mockito 侧要用 `anyCollection()` 匹配。
+另:`FileInfo#children` 字段**有默认值** `new ArrayList<>()`(`@TableField(exist=false)`),
+所以失败节点 `getChildren().isEmpty()` 不会 NPE。

+ 7 - 47
ai-frontend/src/case/views/data/upload.vue

@@ -8,9 +8,7 @@
         </Button>
         <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 class="page-header__actions">
@@ -43,14 +41,7 @@
         </div>
 
         <!-- 隐藏的文件输入 -->
-        <input
-          ref="fileInputRef"
-          type="file"
-          multiple
-          :accept="ACCEPT_EXTS"
-          style="display: none"
-          @change="handleFileInputChange"
-        />
+        <input ref="fileInputRef" type="file" multiple style="display: none" @change="handleFileInputChange" />
         <input
           ref="folderInputRef"
           type="file"
@@ -75,7 +66,7 @@
             <div class="upload-dragger-desc">
               {{
                 uploadMode === 'single'
-                  ? '支持批量选择文件;xls / xlsx / csv 会自动解析,txt / md / doc / docx / pdf 会上传但登记为「无法读取」'
+                  ? '支持批量选择任意文件;xls / xlsx / csv 会自动解析,其余格式仅登记为「不支持解析」'
                   : '支持选择整个文件夹,系统会自动读取符合条件的文件'
               }}
             </div>
@@ -194,19 +185,6 @@
   const IMPORT_FILE_INFO_STORAGE_KEY = 'case_clean_import_file_infos';
   const RECLEAN_CONTEXT_STORAGE_KEY = 'case_clean_reclean_context';
   const CASE_DATA_REFRESH_EVENT = 'case-data:refresh';
-  /**
-   * 允许上传的文件后缀(仅作「能不能进上传队列」的初筛)。
-   *
-   * ⚠️ 与后端的**可解析**白名单不是一回事:后端只解析 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';
 
@@ -299,22 +277,6 @@
     return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
   };
 
-  const isValidUploadFile = (file: File) => {
-    const name = file.name.toLowerCase();
-    return VALID_UPLOAD_EXTS.some((ext) => name.endsWith(ext));
-  };
-
-  const filterValidFiles = (files: File[]) => {
-    const valid = files.filter(isValidUploadFile);
-    const invalid = files.filter((f) => !isValidUploadFile(f));
-    if (invalid.length) {
-      createMessage.warning(
-        `已过滤 ${invalid.length} 个文件,仅支持 ${VALID_UPLOAD_EXTS.map((e) => e.slice(1)).join(' / ')}`,
-      );
-    }
-    return valid;
-  };
-
   const handleDragOver = () => {
     dragOver.value = true;
   };
@@ -362,12 +324,10 @@
     return files;
   };
 
-  /** 文件加入上传列表并自动开始上传 */
+  /** 文件加入上传列表并自动开始上传(不按后缀初筛:能不能解析由后端判定并回写状态) */
   const addFiles = (files: File[]) => {
     if (!files.length || isAbandoned.value) return;
-    const valid = filterValidFiles(files);
-    if (!valid.length) return;
-    selectedFiles.value = [...selectedFiles.value, ...valid];
+    selectedFiles.value = [...selectedFiles.value, ...files];
     void startUpload();
   };
 
@@ -569,11 +529,11 @@
       if (!isAbandoned.value && parsedCount) {
         createMessage.success(
           unreadableCount
-            ? `成功解析 ${parsedCount} 个文件;另有 ${unreadableCount} 个文件格式无法读取,已在右侧列表标出`
+            ? `成功解析 ${parsedCount} 个文件;另有 ${unreadableCount} 个文件后缀不支持解析,已在右侧列表标出`
             : `成功解析 ${parsedCount} 个文件,可在右侧查看结果`,
         );
       } else if (!isAbandoned.value && unreadableCount) {
-        createMessage.warning(`已上传 ${unreadableCount} 个文件,但格式无法读取,未做解析(见右侧列表)`);
+        createMessage.warning(`${unreadableCount} 个文件后缀不支持解析,未上传(见右侧列表)`);
       } else if (!isAbandoned.value && !selectedFiles.value.length) {
         createMessage.warning('未识别到可处理文件,请检查文件格式是否正确');
       }

+ 5 - 0
ai-server/pom.xml

@@ -115,6 +115,11 @@
             <version>${agentscope.version}</version>
         </dependency>
 
+        <dependency>
+            <groupId>org.springframework.ai</groupId>
+            <artifactId>spring-ai-tika-document-reader</artifactId>
+        </dependency>
+
         <dependency>
             <groupId>org.springframework.ai</groupId>
             <artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>

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

@@ -48,22 +48,6 @@ public class GlobalCache {
      */
     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("csv", "xls", "xlsx");
     /**
      * 中国手机号国际区号 正则
      */

+ 5 - 4
ai-server/src/main/java/com/zsjz/ai/module/dm/controller/DmController.java

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

+ 30 - 36
ai-server/src/main/java/com/zsjz/ai/module/dm/service/DmService.java

@@ -151,16 +151,17 @@ public class DmService {
      * {@code doClean}({@code CleanTask})与「重新清洗」都需要按 {@code filePath}
      * 重新打开源文件,落到 tmp 会导致清洗后源文件丢失。
      *
-     * <p><b>非 xls / xlsx / csv 的文件不再被拒收</b>:以前这里直接抛
+     * <p><b>非 xls / xlsx / csv 的文件不再被拒收,但也不落盘</b>:以前这里直接抛
      * 「不支持的文件类型」把用户挡在门外,用户既看不到自己传了什么,也看不到为什么没解析。
-     * 现在改为<b>照常落盘 + 登记一条 {@link FileSheetStateEnum#FILE_UNSUPPORTED_FAIL} 记录</b>,
-     * 与表格文件共用同一张 {@code file_info} 表、同一个批次,前端右侧列表会直接显示失败状态。
+     * 现在改为<b>跳过上传、只在 {@code file_info} 登记一条
+     * {@link FileSheetStateEnum#FILE_UNSUPPORTED_FAIL}(不支持解析)记录</b>,
+     * 与表格文件共用同一张表、同一个批次,前端右侧列表会直接显示失败状态。
      *
-     * @param file    上传的单个文件;仅 xls / xlsx / csv 会被解析,其余格式登记为「文件格式不正确」
+     * @param file    上传的单个文件;仅 xls / xlsx / csv 会被保存并解析,其余后缀只登记不落盘
      * @param batchId 批次ID;为 null 时自动生成,同批文件应由前端回传首次的 batchId
      * @param reClean 1=重新清洗(跳过 MD5 去重)
      * @return 该文件的树形结构:文件根节点(pid=0)内嵌 children sheet 列表;
-     *         非表格文件返回无 children 的失败节点;重复文件返回 null
+     *         不支持解析的后缀返回无 children 的失败节点;重复文件返回 null
      */
     public FileInfo preProcessUploadedFile(MultipartFile file, Long batchId, Integer reClean) {
         if (file == null || file.isEmpty()) {
@@ -169,31 +170,21 @@ public class DmService {
         String originalName = StrUtil.blankToDefault(file.getOriginalFilename(), "未命名文件");
         String suffix = FileUtil.getSuffix(originalName);
         String lowerSuffix = suffix == null ? null : suffix.toLowerCase();
+        Long currentBatchId = batchId != null ? batchId : IdUtil.getSnowflakeNextId();
 
-        // 第一级:可上传白名单。不在清单里的直接拒收 —— 前端已按同一清单过滤,
-        // 这里防的是绕过前端直接调接口(拖拽、脚本)把无关文件塞进来。
-        // 必须放在落盘**之前**,否则会留下一个永远没人认领的垃圾文件。
-        if (lowerSuffix == null || !GlobalCache.uploadSuffixList.contains(lowerSuffix)) {
-            throw ServerException.spe("不支持的文件类型:" + originalName
-                    + "(仅支持 " + String.join(" / ", GlobalCache.uploadSuffixList) + ")");
+        // 后缀校验:只有 csv / xls / xlsx 才真正上传(落盘 + 解析)。
+        // 其余后缀一律**跳过上传**(不落盘、不解析),但仍在 file_info 留一条「不支持解析」的记录 ——
+        // 这样用户在文件列表里能看到「传过这个文件、以及为什么没解析」,而不是像以前那样
+        // 被一句「不支持的文件类型」弹窗打发掉、列表里一点痕迹都没有。
+        if (lowerSuffix == null || !GlobalCache.suffixList.contains(lowerSuffix)) {
+            FileInfo unsupported = buildUnsupportedFileInfo(originalName, suffix, file.getSize(), currentBatchId);
+            saveParsedFileInfos(unsupported);
+            log.info("上传文件后缀不支持解析,已登记(未落盘): name={}, batchId={}", originalName, currentBatchId);
+            return unsupported;
         }
 
-        Long currentBatchId = batchId != null ? batchId : IdUtil.getSnowflakeNextId();
         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,否则会被全部过滤)
         if (reClean == null || reClean != 1) {
             List<Path> kept = filterExistsMd5Files(List.of(saved));
@@ -233,29 +224,33 @@ public class DmService {
     }
 
     /**
-     * 构造「无法读取」的文件节点:文件已落盘,但格式不在可解析白名单内。
+     * 构造「不支持解析」的文件节点:文件**未落盘**,只在 {@code file_info} 留一条记录。
+     *
+     * <p>后缀不是 csv / xls / xlsx 时,上传会被跳过(不保存文件、不解析、不建 DuckDB 预览表),
+     * 但仍登记这条记录,让用户在文件列表里能看到「传过这个文件、以及为什么没解析」。
+     * {@code fileType} 记真实后缀(如 {@code docx}),前端「名称」列会显示成 {@code 报告.docx · docx}。
      *
-     * <p>不解析、不建 DuckDB 预览表,只留一条 {@code file_info} 记录,让用户在文件列表里
-     * 能看到「传过这个文件、以及为什么没解析」。{@code fileType} 记真实后缀(如 {@code docx}),
-     * 前端「名称」列会显示成 {@code 报告.docx · docx}。
+     * <p>{@code filePath} 刻意留空 —— 文件根本没落盘,留空能让
+     * {@code SystemService#resolveDownloadFile} 明确拒绝下载(「该记录没有可下载的源文件!」),
+     * 而不是指向一个根本不存在的路径。
      *
      * <p>字段对齐 {@code PreTask#initDefaultFileInfo}:计数一律 0、{@code pid=0}(文件根节点)、
-     * 无 children,因此前端 {@code buildPreviewTree} 之外无需特殊处理,直接当普通根节点渲染。
+     * 无 children,因此前端无需特殊处理,直接当普通根节点渲染。
      *
      * @param originalName 原始文件名
-     * @param suffix       小写文件后缀(不含点),调用方保证非空
-     * @param saved        落盘后的绝对路径
+     * @param suffix       原始后缀(不含点),可为 null(无后缀文件)
+     * @param fileSize     上传的字节数;文件未落盘,这里记的是浏览器实际传上来的大小
      * @param batchId      批次ID
      * @return 失败状态的文件根节点
      */
-    private FileInfo buildUnreadableFileInfo(String originalName, String suffix, Path saved, Long batchId) {
+    private FileInfo buildUnsupportedFileInfo(String originalName, String suffix, long fileSize, Long batchId) {
         FileInfo info = new FileInfo();
         info.setId(IdUtil.getSnowflakeNextId());
         info.setPid(0L);
         info.setFileName(originalName);
-        info.setFilePath(saved.toString());
-        info.setFileType(suffix);
+        info.setFileType(StrUtil.isBlank(suffix) ? "unknown" : suffix.toLowerCase());
         info.setFileStatus(FileSheetStateEnum.FILE_UNSUPPORTED_FAIL);
+        info.setFileSize(fileSize);
         info.setDataNum(0);
         info.setSuccessNum(0);
         info.setFailNum(0);
@@ -264,7 +259,6 @@ public class DmService {
         // 非表格文件没有分隔符概念,写 COMMA 只是为了满足列约束(与 PreTask 的默认值一致)
         info.setDelimiter(DelimiterEnum.COMMA);
         info.setCreateDateTime(LocalDateTime.now());
-        backfillFileSize(info, saved);
         return info;
     }
 

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

@@ -1,10 +1,12 @@
 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.Test;
 import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
 import org.springframework.beans.factory.annotation.Autowired;
 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.assertNotNull;
@@ -26,6 +28,27 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 @EnabledIfSystemProperty(named = "llm.live", matches = "true")
 class LlmServiceLiveTest {
 
+    /**
+     * 启动上下文必须的一个「补丁 bean」。
+     *
+     * <p><b>原因与 {@code @Mapper} 无关</b>(别被这个坑再骗一次 —— 实测该 Mapper 的
+     * {@code @Mapper} 一直都在,且包内 8 个 mapper 全都有)。真实原因是:
+     * {@code AppLoadEndEventListener#run} 裸调 {@code GlobalCache#initCaseRocksDbData()}(**无 try-catch**),
+     * 其中 {@code initIspData()} 调 {@code PhoneIspMapper.selectList()};而该 Mapper 带
+     * {@code @DS("slave")}(查的是**案件库**),测试环境没有打开的案件,
+     * {@code CaseRoutingDataSource} 直接抛
+     * {@code ServerException(400, "请先打开案件后再操作数据")},导致上下文加载失败。
+     *
+     * <p>前置的 {@code CardIssuerBankMapper} 不炸,是因为它只有 {@code @Mapper}、没有 {@code @DS},
+     * 走默认 master(PG) —— 这正好解释了为什么偏偏炸在 PhoneIspMapper 上。
+     *
+     * <p>本类是全项目<b>唯一</b>使用 {@code @SpringBootTest} 的测试(其余 12 个都是纯 mock 单测),
+     * 所以只有这里需要这个补丁:mock 掉之后 {@code selectList} 返回空列表,不再触发数据源路由。
+     * 仅影响测试,生产代码未改动。
+     */
+    @MockitoBean
+    private PhoneIspMapper phoneIspMapper;
+
     @Autowired
     private LlmService llmService;
 

+ 136 - 0
ai-server/src/test/java/com/zsjz/ai/module/dm/service/DmServiceUploadSuffixTest.java

@@ -0,0 +1,136 @@
+package com.zsjz.ai.module.dm.service;
+
+import com.zsjz.ai.common.enums.FileSheetStateEnum;
+import com.zsjz.ai.common.exception.ServerException;
+import com.zsjz.ai.common.model.dm.entity.FileInfo;
+import com.zsjz.ai.module.dm.mapper.FileInfoMapper;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.nio.charset.StandardCharsets;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyCollection;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+/**
+ * {@link DmService#preProcessUploadedFile} 的「后缀分流」契约测试。
+ *
+ * <p>锁死的行为:**只有 csv / xls / xlsx 真正上传(落盘 + 解析);
+ * 其余后缀不落盘、不解析,只在 {@code file_info} 登记一条「不支持解析」的记录**。
+ *
+ * <p>为什么不需要 Spring 上下文 / 真实案件库:
+ * <ul>
+ *   <li>不支持的后缀在 {@code saveUploadedFile} **之前**就返回了,整条路径不碰
+ *       {@code StateManager}、不碰 DuckDB;</li>
+ *   <li>反过来,「表格后缀会走到落盘」这一点用<b>未开案时抛「请先打开案件」</b>来反证 ——
+ *       异常本身就是「确实进了上传分支」的证据。</li>
+ * </ul>
+ * 因此本类可以纯 mock 跑,秒级完成。
+ */
+class DmServiceUploadSuffixTest {
+
+    private static final long BATCH_ID = 20260920L;
+
+    /** 与被测服务同包,直接 new;只有 fileInfoMapper 需要注入 */
+    private FileInfoMapper fileInfoMapper;
+
+    private DmService newService() {
+        DmService service = new DmService();
+        fileInfoMapper = mock(FileInfoMapper.class);
+        ReflectionTestUtils.setField(service, "fileInfoMapper", fileInfoMapper);
+        return service;
+    }
+
+    private static MockMultipartFile upload(String fileName, String content) {
+        return new MockMultipartFile("file", fileName, null, content.getBytes(StandardCharsets.UTF_8));
+    }
+
+    // ==================== 不支持解析的分支 ====================
+
+    @Test
+    @DisplayName("docx:不落盘不解析,只登记一条「不支持解析」记录")
+    void unsupportedSuffixIsRegisteredOnly() {
+        DmService service = newService();
+
+        FileInfo result = service.preProcessUploadedFile(upload("报告.docx", "hello"), BATCH_ID, null);
+
+        assertNotNull(result, "不支持的后缀也要返回节点,前端才能在列表里显示失败原因");
+        assertEquals(FileSheetStateEnum.FILE_UNSUPPORTED_FAIL, result.getFileStatus());
+        assertEquals("报告.docx", result.getFileName());
+        assertEquals("docx", result.getFileType(), "fileType 记真实后缀,前端名称列会显示成「报告.docx · docx」");
+        assertEquals(Long.valueOf(0L), result.getPid(), "必须是文件根节点,否则列表里渲染不出来");
+        assertEquals(Long.valueOf(5L), result.getFileSize(), "文件未落盘,大小取浏览器实际传上来的字节数");
+        assertEquals(Long.valueOf(BATCH_ID), result.getBatchId());
+        assertTrue(result.getChildren().isEmpty(), "不支持解析的文件不会有 sheet 子节点");
+        assertNull(result.getFilePath(), "文件未落盘,filePath 必须为空 —— 下载接口据此拒绝下载");
+
+        verify(fileInfoMapper).insert(anyCollection());
+    }
+
+    @Test
+    @DisplayName("无后缀文件:同样登记为「不支持解析」,fileType 落成 unknown")
+    void fileWithoutSuffixIsRegisteredOnly() {
+        DmService service = newService();
+
+        FileInfo result = service.preProcessUploadedFile(upload("README", "x"), BATCH_ID, null);
+
+        assertEquals(FileSheetStateEnum.FILE_UNSUPPORTED_FAIL, result.getFileStatus());
+        assertEquals("unknown", result.getFileType());
+        assertNull(result.getFilePath());
+        verify(fileInfoMapper).insert(anyCollection());
+    }
+
+    @Test
+    @DisplayName("txt / pdf / md:一律只登记不落盘")
+    void otherCommonSuffixesAreRegisteredOnly() {
+        for (String name : new String[]{"日志.txt", "起诉书.pdf", "说明.md", "数据.json", "打包.zip"}) {
+            DmService service = newService();
+
+            FileInfo result = service.preProcessUploadedFile(upload(name, "x"), BATCH_ID, null);
+
+            assertEquals(FileSheetStateEnum.FILE_UNSUPPORTED_FAIL, result.getFileStatus(), name + " 应登记为不支持解析");
+            assertNull(result.getFilePath(), name + " 不应落盘");
+            verify(fileInfoMapper).insert(anyCollection());
+        }
+    }
+
+    // ==================== 会真正上传的分支(用未开案异常反证) ====================
+
+    @Test
+    @DisplayName("xlsx / xls / csv:进入上传分支(未开案时抛「请先打开案件」,且不写文件表)")
+    void tableSuffixesEnterUploadPath() {
+        for (String name : new String[]{"数据.xlsx", "数据.xls", "数据.csv", "大写.CSV"}) {
+            DmService service = newService();
+
+            ServerException e = assertThrows(ServerException.class,
+                    () -> service.preProcessUploadedFile(upload(name, "a,b"), BATCH_ID, null),
+                    name + " 应进入落盘分支");
+
+            assertTrue(e.getMessage().contains("请先打开案件"),
+                    name + " 的报错应来自 saveUploadedFile 的开案校验,实际: " + e.getMessage());
+            // 走了上传分支就不该在文件表里留「不支持解析」的记录
+            verify(fileInfoMapper, never()).insert(anyCollection());
+        }
+    }
+
+    @Test
+    @DisplayName("空文件:直接拒绝,不登记也不落盘")
+    void emptyFileIsRejected() {
+        DmService service = newService();
+
+        ServerException e = assertThrows(ServerException.class,
+                () -> service.preProcessUploadedFile(upload("空.xlsx", ""), BATCH_ID, null));
+
+        assertTrue(e.getMessage().contains("上传文件为空"));
+        verify(fileInfoMapper, never()).insert(anyCollection());
+    }
+}