cc 3 weken geleden
bovenliggende
commit
22aa232032

+ 49 - 0
.workbuddy/memory/2026-09-16.md

@@ -175,5 +175,54 @@ public CallDataProfileStatDTO callInfoStat(String personName) { ... }
 - `mvn -pl ai-server -B clean compile` → **BUILD SUCCESS**
 - 审计脚本已归档到 skill:`scripts/audit-result-wrap.cjs`
 
+---
+
+## ★ /sys/openLocalFile + /sys/openOfficeFile:客户端「调起本机程序打开」→ Web 文件下载(2026-09-16 晚)
+
+### 背景
+这两个接口原是实现「服务端调起系统默认程序打开文件」(`ProcessBuilder("cmd","/c","start",path)` / `xdg-open`),
+是客户端形态的遗留。Web 端必须改成把文件流返回给浏览器下载。
+
+### 后端(2 文件)
+| 文件 | 改动 |
+| --- | --- |
+| `SystemService.java` | 删 `openLocalOfficeFile(String)`、`getExitCode(Path)`、`openOfficeFile(Long)`(连带删掉随之无用的 `java.io.IOException` import);新增 `resolveDownloadFile(String)` / `resolveDownloadFileByFid(Long)`(**沿用 csv/xls/xlsx 白名单**,避免退化成任意文件读取)与嵌套 `public record DownloadFile(Path path, String fileName)` |
+| `SystemController.java` | 两个端点改 `ResponseEntity<Resource>`(`FileSystemResource` + `ContentDisposition.attachment().filename(name, UTF_8)` + `contentLength` + `CacheControl.noStore()`);新增 `DOWNLOAD_MEDIA_TYPES`(csv→`text/csv;charset=UTF-8`、xls→`application/vnd.ms-excel`、xlsx→OOXML)与私有 `buildDownloadResponse` / `fileSuffix`;类注释里补了「这两个接口刻意不返回 Result」 |
+
+**URL 没变**(仍是 `/sys/openLocalFile?filePath=` 与 `/sys/openOfficeFile?fid=`),只改了响应形态。
+
+### 前端(7 文件)
+| 文件 | 改动 |
+| --- | --- |
+| `core/api/sys/login.ts` | `openLocalFileApi`/`openOfficeFileApi` → `downloadLocalFileApi`/`downloadOfficeFileApi`:`responseType:'blob'` + `isReturnNativeResponse:true` + `errorMessageMode:'none'` + `skipErrorMessage:true`,返回 `Promise<AxiosResponse<Blob>>` |
+| `core/utils/file/download.ts` | 新增 `resolveDownloadFileName(disposition, fallback)`、`saveBlobResponse(res, fallbackName)`(含「响应体其实是错误 JSON」的识别)、`resolveDownloadErrorMessage(error, fallback)`(blob 错误体解析 + Network Error/超时中文化);私有 `decodeRfc2047` |
+| `core/utils/openOfficeFile.ts` | `openOfficeFileById` → `downloadOfficeFileById(fileId, fallbackName)`;删 `isOpenOfficeFileSuccess`(下载要么成功要么抛错);`getOpenOfficeFileErrorMessage` 兜底文案改「文件下载失败」 |
+| `case/views/data/importList.vue` | `handleOpenFile` → `handleDownloadFile`(用 `downloadLocalFileApi` + `saveBlobResponse`);操作列「打开/打开」→「下载/下载」 |
+| `case/views/data/index.vue` | `handleOpenOfficeFile` → `handleDownloadOfficeFile`;2 处按钮文案「打开文件」→「下载文件」 |
+| `call/views/components/CallRecordModal.vue` | 同上(handler + 菜单项「打开文件」→「下载文件」) |
+| `trans/views/components/TransRecordModal.vue` | 同上 |
+
+### ★ 关键结论:Spring 的 Content-Disposition 双写法(实测 spring-web 6.2.19)
+`ContentDisposition.attachment().filename("通话记录.xlsx", UTF_8)` 实际产出:
+```
+attachment; filename="=?UTF-8?Q?=E9=80=9A=E8=AF=9D=E8=AE=B0=E5=BD=95.xlsx?="; filename*=UTF-8''%E9%80%9A%E8%AF%9D%E8%AE%B0%E5%BD%95.xlsx
+```
+→ 前端**优先解析 `filename*=`**(RFC 5987),兜底分支必须能解 **RFC 2047** 的 `=?UTF-8?Q?...?=`
+(Q 编码:`_`→空格、`=XX`→字节;B 编码:base64;都要按 UTF-8 字节解码,别用 `decodeURIComponent` 直接套)。
+
+### 验证
+- 后端 `mvn -pl ai-server -B compile` → **BUILD SUCCESS**
+- 前端 ESLint(7 个改动文件,`--max-warnings 0`)→ 0 错误;`vue-tsc` 全仓错误数仍 103(无新增)
+- **文件名解析做了真机级验证**:用 spring-web 6.2.19 真实生成响应头(`javac/java -cp` 跑 `CdTest`),
+  把 9 个真实/边界响应头喂给**真实的前端函数**(esbuild 打包时 stub 掉 axios 与 base64Conver)→ **9/9 通过**
+- 未做:起服务后的浏览器端实测(点击下载、大文件、中文文件名落盘)
+
+### 复用要点(下次做 Blob 下载)
+- 项目已有 `core/utils/file/download.ts` 的 `downloadByData`(创建 a[download] + revokeObjectURL),无需重复造。
+- `defHttp` 读 blob 必须 `isReturnNativeResponse: true`,否则 `transformRequestHook` 会按 JSON 处理。
+- `errorMessageMode:'none'` 在 `responseInterceptorsCatch` 里是 **`Promise.resolve(response)`**(把错误"吞"成成功!),
+  所以要么靠 `saveBlobResponse` 识别 JSON 型 blob,要么加 `skipErrorMessage:true` 让它 reject —— 本次两者都用了。
+
+
 
 

+ 8 - 11
ai-frontend/src/call/views/components/CallRecordModal.vue

@@ -34,9 +34,8 @@
   import componentSetting from '@/core/settings/componentSetting';
   import { useMessage } from '@/core/hooks/web/useMessage';
   import {
+    downloadOfficeFileById,
     getOpenOfficeFileErrorMessage,
-    isOpenOfficeFileSuccess,
-    openOfficeFileById,
     resolveOfficeFileId,
   } from '@/core/utils/openOfficeFile';
   import { maskCertNo } from '@/core/utils/sensitive';
@@ -215,20 +214,18 @@
     });
   };
 
-  const handleOpenFile = async (record?: CallRecord) => {
+  /** 下载关联的归档表格(Web 化:浏览器另存,不再调起本地应用打开) */
+  const handleDownloadFile = async (record?: CallRecord) => {
     const fileId = resolveOfficeFileId(record);
     if (!fileId) {
-      createMessage.warning('未获取到文件ID,暂时无法打开文件');
+      createMessage.warning('未获取到文件ID,暂时无法下载文件');
       return;
     }
 
     try {
-      const response = await openOfficeFileById(fileId);
-      if (!isOpenOfficeFileSuccess(response)) {
-        createMessage.warning((response as any)?.message || '打开文件失败');
-      }
+      await downloadOfficeFileById(fileId);
     } catch (error: any) {
-      createMessage.error(getOpenOfficeFileErrorMessage(error, '打开文件失败'));
+      createMessage.error(getOpenOfficeFileErrorMessage(error, '文件下载失败'));
     }
   };
 
@@ -245,9 +242,9 @@
       const fileId = resolveOfficeFileId(rawRecord);
       return [
         {
-          label: '打开文件',
+          label: '下载文件',
           disabled: !fileId,
-          onClick: () => void handleOpenFile(rawRecord),
+          onClick: () => void handleDownloadFile(rawRecord),
         },
         {
           label: '跳转',

+ 12 - 15
ai-frontend/src/case/views/data/importList.vue

@@ -45,7 +45,8 @@
   import type { FormProps } from '@/core/components/Form';
   import type { FileInfo } from '@/types';
   import { importFileList, deletedFileList, preFile } from '@/case/api/govern/governApi';
-  import { openLocalFileApi } from '@/core/api/sys/login';
+  import { downloadLocalFileApi } from '@/core/api/sys/login';
+  import { resolveDownloadErrorMessage, saveBlobResponse } from '@/core/utils/file/download';
   import { FileStatusEnum } from '@/case/types/enum';
   import componentSetting from '@/core/settings/componentSetting';
   import CleanLogModal from './components/CleanLogModal.vue';
@@ -249,9 +250,9 @@
         },
       },
       {
-        label: '打开',
-        title: '打开',
-        onClick: handleOpenFile.bind(this, record),
+        label: '下载',
+        title: '下载',
+        onClick: handleDownloadFile.bind(this, record),
       },
     ],
   };
@@ -417,24 +418,20 @@
     }
   }
 
-  async function handleOpenFile(record: Partial<FileInfo>) {
+  /** 下载该文件(Web 化:浏览器另存,不再调起服务端本地应用打开) */
+  async function handleDownloadFile(record: Partial<FileInfo>) {
     const filePath = String(record.filePath ?? '').trim();
     if (!filePath) {
-      createMessage.warning('未获取到文件路径,暂时无法打开文件');
+      createMessage.warning('未获取到文件路径,暂时无法下载文件');
       return;
     }
 
     try {
-      const response = await openLocalFileApi(filePath);
-      const result = String((response as any)?.result ?? '').toLowerCase();
-      const code = String((response as any)?.code ?? '').toLowerCase();
-      const success = result === 'true' || result === 'success' || code === '200' || code === '0';
-
-      if (!success) {
-        createMessage.warning((response as any)?.message || '打开文件失败');
-      }
+      const response = await downloadLocalFileApi(filePath);
+      const fileName = String(record.fileName ?? '').trim();
+      await saveBlobResponse(response, fileName || '文件');
     } catch (error: any) {
-      createMessage.error(getErrorMessage(error, '打开文件失败'));
+      createMessage.error(await resolveDownloadErrorMessage(error, '文件下载失败'));
     }
   }
 

+ 11 - 13
ai-frontend/src/case/views/data/index.vue

@@ -607,9 +607,8 @@
   import CallRecordModal from '@/call/views/components/CallRecordModal.vue';
   import TransRecordModal from '@/trans/views/components/TransRecordModal.vue';
   import {
+    downloadOfficeFileById,
     getOpenOfficeFileErrorMessage,
-    isOpenOfficeFileSuccess,
-    openOfficeFileById,
     resolveOfficeFileId,
   } from '@/core/utils/openOfficeFile';
   import {
@@ -2721,20 +2720,19 @@
     callRecordModalOpen.value = true;
   };
 
-  const handleOpenOfficeFile = async (record: Recordable) => {
+  /** 下载归档表格文件(Web 化:浏览器另存,不再调起本地应用打开) */
+  const handleDownloadOfficeFile = async (record: Recordable) => {
     const fileId = resolveOfficeFileId(record);
     if (!fileId) {
-      createMessage.warning('未获取到文件ID,暂时无法打开文件');
+      createMessage.warning('未获取到文件ID,暂时无法下载文件');
       return;
     }
 
     try {
-      const response = await openOfficeFileById(fileId);
-      if (!isOpenOfficeFileSuccess(response)) {
-        createMessage.warning((response as any)?.message || '打开文件失败');
-      }
+      const fallbackName = String(record?.fileName ?? '').trim();
+      await downloadOfficeFileById(fileId, fallbackName || '文件');
     } catch (error: any) {
-      createMessage.error(getOpenOfficeFileErrorMessage(error, '打开文件失败'));
+      createMessage.error(getOpenOfficeFileErrorMessage(error, '文件下载失败'));
     }
   };
 
@@ -2755,10 +2753,10 @@
           disabled: !fileId,
           onClick: (event: MouseEvent) => {
             event.stopPropagation();
-            void handleOpenOfficeFile(record);
+            void handleDownloadOfficeFile(record);
           },
         },
-        { default: () => '打开文件' },
+        { default: () => '下载文件' },
       );
     },
   });
@@ -2975,10 +2973,10 @@
                     disabled: !fileId,
                     onClick: (event: MouseEvent) => {
                       event.stopPropagation();
-                      void handleOpenOfficeFile(record);
+                      void handleDownloadOfficeFile(record);
                     },
                   },
-                  { default: () => '打开文件' },
+                  { default: () => '下载文件' },
                 ),
               ],
             );

+ 14 - 8
ai-frontend/src/core/api/sys/login.ts

@@ -3,6 +3,7 @@
  * No deletion without permission, or be held responsible to law.
  * @author ThinkGem
  */
+import type { AxiosResponse } from 'axios';
 import { defHttp } from '@/core/utils/http/axios';
 import { UserInfo } from '@/types/store';
 import { ErrorMessageMode, Result } from '@/types/axios';
@@ -101,16 +102,21 @@ export const authorizationInfoApi = () =>
 export const machineCodeApi = () =>
   defHttp.get<string>({ url: adminPath + '/sys/code', timeout: 10 * 1000 }, { errorMessageMode: 'none' });
 
-export const openLocalFileApi = (filePath: string) =>
-  defHttp.get<Result>(
-    { url: adminPath + '/sys/openLocalFile', params: { filePath }, timeout: 10 * 1000 },
-    { errorMessageMode: 'none', isTransformResponse: false },
+/**
+ * 下载服务端本地文件(原 /sys/openLocalFile 的 Web 化改造)。
+ * 以 blob 读取、返回原生响应以便从 Content-Disposition 取文件名。
+ */
+export const downloadLocalFileApi = (filePath: string): Promise<AxiosResponse<Blob>> =>
+  defHttp.get<AxiosResponse<Blob>>(
+    { url: adminPath + '/sys/openLocalFile', params: { filePath }, responseType: 'blob', timeout: 3 * 60 * 1000 },
+    { errorMessageMode: 'none', skipErrorMessage: true, isReturnNativeResponse: true },
   );
 
-export const openOfficeFileApi = (fileId: string | number) =>
-  defHttp.get<Result>(
-    { url: adminPath + '/sys/openOfficeFile', params: { fid: fileId }, timeout: 10 * 1000 },
-    { errorMessageMode: 'none', isTransformResponse: false },
+/** 按文件ID下载文件(原 /sys/openOfficeFile 的 Web 化改造) */
+export const downloadOfficeFileApi = (fileId: string | number): Promise<AxiosResponse<Blob>> =>
+  defHttp.get<AxiosResponse<Blob>>(
+    { url: adminPath + '/sys/openOfficeFile', params: { fid: fileId }, responseType: 'blob', timeout: 3 * 60 * 1000 },
+    { errorMessageMode: 'none', skipErrorMessage: true, isReturnNativeResponse: true },
   );
 
 export const offlineAuthorizationApi = (license: string) =>

+ 98 - 0
ai-frontend/src/core/utils/file/download.ts

@@ -1,4 +1,5 @@
 // import { openWindow } from '..';
+import type { AxiosResponse } from 'axios';
 import { dataURLtoBlob, urlToBase64 } from './base64Conver';
 import { defHttp } from '@/core/utils/http/axios';
 
@@ -129,3 +130,100 @@ export async function downloadByUrl({
   // openWindow(url, { target });
   return true;
 }
+
+/** 把字节串按 UTF-8 解码(RFC 2047 的 Q/B 两种编码最终都是字节流) */
+function decodeUtf8Bytes(bytes: string): string {
+  return new TextDecoder('utf-8').decode(Uint8Array.from(bytes, (c) => c.charCodeAt(0)));
+}
+
+/** 解码 RFC 2047 编码字(Spring 会在 filename="=?UTF-8?Q?...?=" 里用这种写法) */
+function decodeRfc2047(value: string): string {
+  const matched = value.match(/^=\?UTF-8\?([QB])\?(.+)\?=$/i);
+  if (!matched) return value;
+  const [, mode, payload] = matched;
+  try {
+    if (mode.toUpperCase() === 'B') {
+      return decodeUtf8Bytes(atob(payload));
+    }
+    const bytes = payload
+      .replace(/_/g, ' ')
+      .replace(/=([0-9A-F]{2})/gi, (_m, hex) => String.fromCharCode(parseInt(hex, 16)));
+    return decodeUtf8Bytes(bytes);
+  } catch {
+    return value;
+  }
+}
+
+/**
+ * 从 Content-Disposition 响应头解析下载文件名。
+ *
+ * <p>兼容 Spring 生成的两种写法:RFC 5987 的 `filename*=UTF-8''%E4%B8%AD%E6%96%87.xlsx`
+ * 优先,其次 ISO-8859-1/RFC 2047 的 `filename="=?UTF-8?Q?...?="`;
+ * 都解析不到时回退到传入的默认名。
+ */
+export function resolveDownloadFileName(disposition?: string | null, fallback = 'download'): string {
+  const raw = String(disposition ?? '');
+  if (!raw) return fallback;
+
+  const encoded = raw.match(/filename\*\s*=\s*([^;]+)/i);
+  if (encoded) {
+    const value = encoded[1].trim().replace(/^["']|["']$/g, '');
+    const sepIdx = value.indexOf("''");
+    const text = sepIdx === -1 ? value : value.slice(sepIdx + 2);
+    try {
+      const decoded = decodeURIComponent(text);
+      if (decoded) return decoded;
+    } catch {
+      if (text) return text;
+    }
+  }
+
+  const plain = raw.match(/filename\s*=\s*([^;]+)/i);
+  if (plain) {
+    const value = plain[1].trim().replace(/^["']|["']$/g, '');
+    if (value) return decodeRfc2047(value);
+  }
+  return fallback;
+}
+
+/** 后端异常时会以 JSON 返回(ResponseType 为 blob 时需手动识别),取出其中的 message */
+async function readBlobJsonMessage(blob: Blob): Promise<string> {
+  if (!blob.type?.includes('json')) return '';
+  try {
+    const parsed = JSON.parse(await blob.text());
+    return String(parsed?.message ?? '').trim();
+  } catch {
+    return '';
+  }
+}
+
+/**
+ * 把「文件流响应」保存为浏览器下载。
+ *
+ * <p>配合 `defHttp` 的 `responseType: 'blob'` + `isReturnNativeResponse: true` 使用:
+ * 从响应头取文件名,若响应体其实是后端错误 JSON 则抛出可读错误。
+ */
+export async function saveBlobResponse(res: AxiosResponse<Blob>, fallbackName = 'download'): Promise<void> {
+  const blob = res?.data;
+  if (!(blob instanceof Blob)) {
+    throw new Error('文件下载失败:响应内容异常');
+  }
+  const errorMessage = await readBlobJsonMessage(blob);
+  if (errorMessage) throw new Error(errorMessage);
+  downloadByData(blob, resolveDownloadFileName(res?.headers?.['content-disposition'], fallbackName));
+}
+
+/**
+ * 归一化下载失败的错误信息:blob 响应体里的后端 message 优先,其次 error.message
+ */
+export async function resolveDownloadErrorMessage(error: any, fallback = '文件下载失败'): Promise<string> {
+  const data = error?.response?.data;
+  if (data instanceof Blob) {
+    const message = await readBlobJsonMessage(data);
+    if (message) return message;
+  }
+  const raw = String(error?.response?.data?.message || error?.message || '').trim();
+  if (/Network Error/i.test(raw)) return '网络异常,请检查网络连接';
+  if (/timeout/i.test(raw)) return '请求超时,请稍后重试';
+  return raw || fallback;
+}

+ 22 - 9
ai-frontend/src/core/utils/openOfficeFile.ts

@@ -1,6 +1,11 @@
-import { openOfficeFileApi } from '@/core/api/sys/login';
+import { downloadOfficeFileApi } from '@/core/api/sys/login';
+import { resolveDownloadErrorMessage, saveBlobResponse } from '@/core/utils/file/download';
 
-const normalizeOfficeFileKey = (value: string) => value.trim().toLowerCase().replace(/[_\-\s]/g, '');
+const normalizeOfficeFileKey = (value: string) =>
+  value
+    .trim()
+    .toLowerCase()
+    .replace(/[_\-\s]/g, '');
 
 const OFFICE_FILE_ID_KEY_SET = new Set(['fileid']);
 
@@ -45,13 +50,21 @@ export const resolveOfficeFileId = (source: unknown): string | undefined => {
   return undefined;
 };
 
-export const isOpenOfficeFileSuccess = (response: any) => {
-  const result = String(response?.result ?? '').toLowerCase();
-  const code = String(response?.code ?? '').toLowerCase();
-  return result === 'true' || result === 'success' || code === '200' || code === '0';
+/**
+ * 按文件ID下载文件(Web 端:浏览器另存为,不再调起服务端的本地应用程序打开)。
+ *
+ * @param fileId       文件/工作表ID(file_info.id)
+ * @param fallbackName 响应头里拿不到文件名时的兜底名
+ */
+export const downloadOfficeFileById = async (fileId: string | number, fallbackName = '文件') => {
+  try {
+    const response = await downloadOfficeFileApi(fileId);
+    await saveBlobResponse(response, fallbackName);
+  } catch (error: any) {
+    // 后端以 JSON 报错时(如文件不存在),把原始 message 提取出来
+    throw new Error(await resolveDownloadErrorMessage(error, '文件下载失败'));
+  }
 };
 
-export const getOpenOfficeFileErrorMessage = (error: any, fallback = '打开文件失败') =>
+export const getOpenOfficeFileErrorMessage = (error: any, fallback = '文件下载失败') =>
   error?.response?.data?.message || error?.message || fallback;
-
-export const openOfficeFileById = (fileId: string | number) => openOfficeFileApi(fileId);

+ 8 - 11
ai-frontend/src/trans/views/components/TransRecordModal.vue

@@ -58,9 +58,8 @@
   import { Button, Radio } from 'ant-design-vue';
   import { useMessage } from '@/core/hooks/web/useMessage';
   import {
+    downloadOfficeFileById,
     getOpenOfficeFileErrorMessage,
-    isOpenOfficeFileSuccess,
-    openOfficeFileById,
     resolveOfficeFileId,
   } from '@/core/utils/openOfficeFile';
   import QueryTableModal from '@/case/views/dm/components/QueryTableModal.vue';
@@ -316,20 +315,18 @@
     });
   };
 
-  const handleOpenFile = async (record?: TransRecord) => {
+  /** 下载关联的归档表格(Web 化:浏览器另存,不再调起本地应用打开) */
+  const handleDownloadFile = async (record?: TransRecord) => {
     const fileId = resolveOfficeFileId(record);
     if (!fileId) {
-      createMessage.warning('未获取到文件ID,暂时无法打开文件');
+      createMessage.warning('未获取到文件ID,暂时无法下载文件');
       return;
     }
 
     try {
-      const response = await openOfficeFileById(fileId);
-      if (!isOpenOfficeFileSuccess(response)) {
-        createMessage.warning((response as any)?.message || '打开文件失败');
-      }
+      await downloadOfficeFileById(fileId);
     } catch (error: any) {
-      createMessage.error(getOpenOfficeFileErrorMessage(error, '打开文件失败'));
+      createMessage.error(getOpenOfficeFileErrorMessage(error, '文件下载失败'));
     }
   };
 
@@ -376,9 +373,9 @@
             }
           : null,
         {
-          label: '打开文件',
+          label: '下载文件',
           disabled: !fileId,
-          onClick: () => void handleOpenFile(rawRecord),
+          onClick: () => void handleDownloadFile(rawRecord),
         },
         {
           label: '跳转',

+ 68 - 9
ai-server/src/main/java/com/zsjz/ai/module/plat/controller/SystemController.java

@@ -2,15 +2,30 @@
 package com.zsjz.ai.module.plat.controller;
 
 import com.zsjz.ai.common.config.Result;
+import com.zsjz.ai.common.exception.ServerException;
 import com.zsjz.ai.common.model.plat.entity.SystemInfo;
 import com.zsjz.ai.module.plat.service.SystemService;
 
 import org.springframework.beans.factory.annotation.Autowired;
 
+import org.springframework.core.io.FileSystemResource;
+import org.springframework.core.io.Resource;
+import org.springframework.http.CacheControl;
+import org.springframework.http.ContentDisposition;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Locale;
+import java.util.Map;
+
 /**
  * 系统控制器
  * 提供系统授权、版本管理、缓存管理等系统级接口
@@ -20,6 +35,9 @@ import org.springframework.web.bind.annotation.RestController;
  * 当成"视图名"去转发(日志表现为 {@code View name [sys/health]} → {@code Forwarding to [...]}),
  * 最终一律 404;而前端 `main.ts` 期望 `Result` 包装(`res?.code === 200`),
  * 会导致启动健康检查空转 2 分钟并总是跳转到授权页。
+ *
+ * <p>例外:{@code /sys/openLocalFile}、{@code /sys/openOfficeFile} 是**文件下载**接口,
+ * 刻意不返回 {@code Result},响应体是文件流(前端按 blob 读取)。
  */
 @RestController
 @RequestMapping("/sys")
@@ -103,21 +121,62 @@ public class SystemController {
     }
 
     /**
-     * 打开本地Office文件
-     * 支持CSV、XLS、XLSX格式文件
+     * 下载本地文件(原「调用系统程序打开本地文件」的 Web 化改造)
+     * 支持 CSV、XLS、XLSX;以附件形式返回文件流,由浏览器保存或交给本地应用打开
      *
-     * @param filePath 文件路径
+     * @param filePath 文件在服务端的绝对路径
      */
     @GetMapping("/openLocalFile")
-    public Result<Void> openLocalFile(String filePath) {
-        systemService.openLocalOfficeFile(filePath);
-        return Result.succeed();
+    public ResponseEntity<Resource> openLocalFile(String filePath) {
+        return buildDownloadResponse(systemService.resolveDownloadFile(filePath));
     }
 
+    /**
+     * 按文件ID下载文件(原「调用系统程序打开已归档表格」的 Web 化改造)
+     *
+     * @param fid file_info.id(文件或工作表)
+     */
     @GetMapping("/openOfficeFile")
-    public Result<Void> openOfficeFile(Long fid) {
-        systemService.openOfficeFile(fid);
-        return Result.succeed();
+    public ResponseEntity<Resource> openOfficeFile(Long fid) {
+        return buildDownloadResponse(systemService.resolveDownloadFileByFid(fid));
+    }
+
+    /** 可下载文件扩展名 → Content-Type */
+    private static final Map<String, MediaType> DOWNLOAD_MEDIA_TYPES = Map.of(
+            "csv", new MediaType("text", "csv", StandardCharsets.UTF_8),
+            "xls", MediaType.parseMediaType("application/vnd.ms-excel"),
+            "xlsx", MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"));
+
+    /**
+     * 组装下载响应:附件(attachment)+ RFC 5987 文件名(中文名不乱码)+ 内容长度,且禁止中间层缓存。
+     *
+     * <p>注意:本接口刻意不返回 {@code Result} 包装 —— 响应体是文件流本身。
+     */
+    private static ResponseEntity<Resource> buildDownloadResponse(SystemService.DownloadFile file) {
+        Path path = file.path();
+        String fileName = file.fileName();
+        long contentLength;
+        try {
+            contentLength = Files.size(path);
+        } catch (IOException e) {
+            // 校验通过后文件被清理(清洗完成后会递归删除临时目录)
+            throw new ServerException(404, "文件不存在或已被清理");
+        }
+        ContentDisposition disposition = ContentDisposition.attachment()
+                .filename(fileName, StandardCharsets.UTF_8)
+                .build();
+        return ResponseEntity.ok()
+                .contentType(DOWNLOAD_MEDIA_TYPES.getOrDefault(fileSuffix(fileName), MediaType.APPLICATION_OCTET_STREAM))
+                .contentLength(contentLength)
+                // 案件原始数据,禁止浏览器/代理缓存
+                .cacheControl(CacheControl.noStore())
+                .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
+                .body(new FileSystemResource(path));
+    }
+
+    private static String fileSuffix(String fileName) {
+        int idx = fileName == null ? -1 : fileName.lastIndexOf('.');
+        return idx < 0 ? "" : fileName.substring(idx + 1).toLowerCase(Locale.ROOT);
     }
 
     /**

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

@@ -30,7 +30,6 @@ import org.springframework.stereotype.Service;
 
 
 import java.io.File;
-import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.StandardCopyOption;
@@ -246,12 +245,15 @@ public class SystemService extends ServiceImpl<SystemInfoMapper, SystemInfo> {
     }
 
     /**
-     * 打开本地Office文件(CSV、Excel等)
-     * 调用系统命令启动默认应用程序打开文件
+     * 校验并解析待下载的本地文件。
      *
-     * @param filePath 文件路径
+     * <p>原实现是客户端形态的「调用系统程序打开文件」,Web 端改为把文件流返回给浏览器下载;
+     * 这里沿用原有的 csv/xls/xlsx 后缀白名单,避免该接口退化成任意文件读取。
+     *
+     * @param filePath 文件在服务端的绝对路径
+     * @return 绝对路径 + 建议的下载文件名
      */
-    public void openLocalOfficeFile(String filePath) {
+    public DownloadFile resolveDownloadFile(String filePath) {
         if (StrUtil.isBlank(filePath)) {
             throw ServerException.spe("文件路径不能为空!");
         }
@@ -261,49 +263,33 @@ public class SystemService extends ServiceImpl<SystemInfoMapper, SystemInfo> {
         }
         String suffix = FileUtil.getSuffix(path.toFile());
         if (StrUtil.isBlank(suffix) || !GlobalCache.suffixList.contains(suffix.toLowerCase())) {
-            throw ServerException.spe("仅支持打开 csv、xls、xlsx 文件!");
-        }
-        try {
-            int exitCode = getExitCode(path);
-
-            if (exitCode != 0) {
-                log.error("打开文件失败,退出码: {}", exitCode);
-                throw ServerException.spe("打开失败,检查是否有可用的应用程序!");
-            }
-        } catch (Exception e) {
-            log.error("打开本地文件失败, filePath={}", filePath, e);
-            throw ServerException.spe("当前系统不支持打开该文件类型!");
+            throw ServerException.spe("仅支持下载 csv、xls、xlsx 文件!");
         }
+        return new DownloadFile(path.toAbsolutePath(), path.getFileName().toString());
     }
 
-    private static int getExitCode(Path path) throws IOException, InterruptedException {
-        ProcessBuilder pb;
-
-        if (LicenseUtil.isWin()) {
-            // Windows
-            pb = new ProcessBuilder("cmd", "/c", "start", path.toAbsolutePath().toString());
-        } else {
-            // Linux 和其他 Unix-like 系统
-            pb = new ProcessBuilder("xdg-open", path.toAbsolutePath().toString());
+    /**
+     * 按文件ID(file_info.id)解析待下载文件,优先使用记录里的原始文件名。
+     *
+     * @param fid 文件/工作表ID
+     */
+    public DownloadFile resolveDownloadFileByFid(Long fid) {
+        if (fid == null) {
+            throw ServerException.spe("文件ID不能为空!");
         }
-
-        // 合并错误流和输出流
-        pb.redirectErrorStream(true);
-
-        // 可选:直接输出到控制台
-        //pb.inheritIO();
-
-        Process process = pb.start();
-        return process.waitFor();
-    }
-
-    public void openOfficeFile(Long fid) {
         FileInfo fileInfo = fileInfoMapper.selectById(fid);
         if (fileInfo == null) {
             throw ServerException.spe("文件不存在!");
         }
-        String filePath = fileInfo.getFilePath();
-        openLocalOfficeFile(filePath);
+        DownloadFile resolved = resolveDownloadFile(fileInfo.getFilePath());
+        String fileName = StrUtil.isBlank(fileInfo.getFileName()) ? resolved.fileName() : fileInfo.getFileName();
+        return new DownloadFile(resolved.path(), fileName);
+    }
+
+    /**
+     * 待下载文件:服务端绝对路径 + 下载时展示的文件名
+     */
+    public record DownloadFile(Path path, String fileName) {
     }
 
     /**