cc hai 3 semanas
pai
achega
611488172a

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

@@ -223,6 +223,78 @@ attachment; filename="=?UTF-8?Q?=E9=80=9A=E8=AF=9D=E8=AE=B0=E5=BD=95.xlsx?="; fi
 - `errorMessageMode:'none'` 在 `responseInterceptorsCatch` 里是 **`Promise.resolve(response)`**(把错误"吞"成成功!),
   所以要么靠 `saveBlobResponse` 识别 JSON 型 blob,要么加 `skipErrorMessage:true` 让它 reject —— 本次两者都用了。
 
+---
+
+## ★★ 下载接口收紧:两个接口合并成 `/sys/downloadFile`,只收文件ID(2026-09-16 晚续)
+
+用户要求:① `/sys/openLocalFile` 吃"服务端任意绝对路径"的问题,`/sys/openOfficeFile` 同样;② 两个接口在后端**公用一个逻辑**;
+③ **业务逻辑写在 service,不要写在 controller**;④ 改成**传文件 id 过来查库取路径再下载**。
+
+### 后端
+| 文件 | 改动 |
+| --- | --- |
+| `SystemController` | 删掉 `/openLocalFile`、`/openOfficeFile` 与上一轮我放在 controller 里的 `DOWNLOAD_MEDIA_TYPES`/`buildDownloadResponse`/`fileSuffix`(连同 FileSystemResource/CacheControl/ContentDisposition/HttpHeaders/MediaType/IOException/StandardCharsets/Files/Path/Locale/Map 等 import 一起删)→ 只留一个**三行**端点:`@GetMapping("/downloadFile") public ResponseEntity<Resource> downloadFile(Long fid) { return systemService.downloadFile(fid); }` |
+| `SystemService` | 新增 `downloadFile(Long fid)`(查库→校验→组装 ResponseEntity 全在这里)、`resolveDownloadFile(Long)`、私有 `resolveFilePath(FileInfo)`、`fileSuffix`、`DOWNLOAD_MEDIA_TYPES`、`record DownloadFile(Path, String)`;imports 重新补上 web 相关类 |
+
+### ★ 关键实现点
+- **入参只认 `file_info.id`**:路径 100% 由 `fileInfoMapper.selectById(fid)` 得到,调用方无法注入路径 —— 这才是真正堵住任意文件读取,
+  光靠后缀白名单不够(本次仍保留 csv/xls/xlsx 白名单做纵深防御)。
+- **工作表行没有 filePath**(`PreTask` 只给文件根 `setFilePath`;`PreDataListener` 给工作表只 `setPid` + `setFileName`,见 line 297-299)
+  → `resolveFilePath` 必须**回退到 pid 指向的文件根**;没有这步,前端在"导入文件管理"里点工作表行的下载会失败。
+  (旁证:老前端代码里本来就有 `if (!filePath) warn('未获取到文件路径')` 的兜底。)
+- `file_info` 表在**案件级 DuckDB**(`QingJian/workspace/{caseId}/{xxxxx}`),不在 master PG —— 逐个只读扫过 workspace/1、3、4 与 db/5w8mf4kf:
+  50 张表都含 `file_info`,但**当前全部 0 行**(清洗完成后行会被删),所以数据侧无法验证 sheet 假设,只能靠代码事实。
+  DuckDB 里这些列是**驼峰**(`"filePath"`/`"fileName"`),查询必须加双引号。
+- URL 变更:`/sys/openLocalFile`、`/sys/openOfficeFile` → **`/sys/downloadFile?fid=`**(前端唯一调用方,已同步)。
+
+### 前端(3 文件)
+- `core/api/sys/login.ts`:两个 api 合并为 `downloadFileApi(fileId)` → `/sys/downloadFile`。
+- `core/utils/openOfficeFile.ts`:`downloadOfficeFileById` 改调 `downloadFileApi`(对调用方签名不变,3 个面板无需改)。
+- `case/views/data/importList.vue`:`handleDownloadFile` 从传 `record.filePath` 改为传 `record.id`,并改用 `downloadOfficeFileById` + `getOpenOfficeFileErrorMessage`(不再直接用 api/下载工具)。
+
+### 验证
+- 后端 `mvn -pl ai-server -B compile` → **BUILD SUCCESS**;前端 ESLint 0 错误、`vue-tsc` 全仓仍 103(无新增)
+- 全仓 grep:`openLocalFile`/`openOfficeFile`/`downloadLocalFileApi`/`downloadOfficeFileApi`/`resolveDownloadFile(String|ByFid)` **已无活引用**(只剩 util 文件名与注释)
+- **未做**:重启后端做运行期实测。★ 注意:验证时发现**本机后端正在运行(java PID 4328,监听 8980,案件 2 的 DuckDB 被锁)**,
+  那是**改动前**的构建,不重启不会有 `/sys/downloadFile`(前端点击下载会 404)。实测需重启 + `POST /case/open`。
+
+### 另:只读探库的副作用提醒
+用 duckdb_jdbc 1.5.5.1 **只读**打开案件库是安全的(未修改任何文件),但**正在被应用占用的库会报 "另一个程序正在使用此文件"**,
+别试图绕过;要读数据请先确认对应案件未开案,或走应用自身 API(如 `POST /dm/getFiles`)。
+
+---
+
+## 「下载按钮置灰」排查:不是 Electron,是数据没有来源文件(2026-09-16 晚续)
+
+用户反馈:调用这两个接口的前端位置按钮是灰的,怀疑 Electron 残留,要求移除。
+
+### 结论:与 Electron 无关
+全仓 grep `isElectron` 只出现在窗口最小化/最大化/关闭、AppLogo、ProductInfoModal 里,**下载链路零 Electron**(`blob` + `a[download]`)。
+按钮灰掉的真实条件是 `disabled: !resolveOfficeFileId(record)`(index.vue 两处、呼单/交易弹窗各一处)。
+
+### 真实原因(有实测证据)
+- `fileId` 是清洗管线写入的:`AbstractDataCleaner:80 this.fileId = sheet.getPid()`(= 文件根 id),
+  `GovernMapper.copyToCallRecord` 再把 `c.fileId`/`c.sheetId` 拷进 `call_record` → **真实导入数据必然有 fileId**。
+- 但**当前案件是直接灌进 DuckDB 的合成数据**:实测运行中的后端 `POST /cr/getCallRecord` 返回的行字段里
+  `fileId=null`、`fileName=null`、`sheetName=null`(`/trans/getTransRecord` 直接 0 行);且**所有案件库的 `file_info` 都是 0 行**
+  → 根本不存在可下载的源 xlsx,按钮自然是灰的。
+- 层级差异:level1(`/gt/treeTablePage`,动态表明细行)与 level2(person 聚合,走 `xxxMapper.selectPage` 返回实体)都能带 fileId;
+  **level3(`getTreeCallOperPage` 等自定义 SQL 在 GovernTreeMapper.xml 里,压根不 select fileId)永远没有 fileId**。
+
+### 本次改动(4 处去掉 disabled + 3 处提示语)
+`case/views/data/index.vue`(2 处按钮)、`call/views/components/CallRecordModal.vue`、`trans/views/components/TransRecordModal.vue`:
+- 删掉 `disabled: !fileId`(及随之无用的 `const fileId = resolveOfficeFileId(...)` 局部变量)→ 按钮不再灰
+- 点击时的守卫提示改为「当前记录没有关联的源文件,无法下载」——
+  **这个守卫分支原本就存在,但被 disabled 挡住永远走不到**;即作者本意就是"可点 + 提示"
+- 验证:ESLint 0 错误;`vue-tsc` 全仓仍 103(无新增)
+
+### 待用户定的后续(已抛给用户)
+- level3 聚合行若也要能下载 → 后端在这些聚合 SQL 里补 `fileId`(需定义"聚合行代表哪个源文件",非机械改动)
+- 应用里其它 Electron 残留(离线地图 `ElectronPmtilesSource`、窗口控制按钮、授权/升级/基站路径选择器、`useElectronWindow`)
+  **与下载无关**,移除需要 Web 替代方案(`<input type=file>`、浏览器目录选择受限等),未擅自改动。
+
+
+
 
 
 

+ 2 - 3
ai-frontend/src/call/views/components/CallRecordModal.vue

@@ -218,7 +218,7 @@
   const handleDownloadFile = async (record?: CallRecord) => {
     const fileId = resolveOfficeFileId(record);
     if (!fileId) {
-      createMessage.warning('未获取到文件ID,暂时无法下载文件');
+      createMessage.warning('当前记录没有关联的源文件,无法下载');
       return;
     }
 
@@ -239,11 +239,10 @@
     actions: (record: { raw?: CallRecord }) => {
       const rawRecord = record.raw;
       const query = buildJumpQuery(rawRecord);
-      const fileId = resolveOfficeFileId(rawRecord);
       return [
         {
           label: '下载文件',
-          disabled: !fileId,
+          // 不再按 fileId 置灰:没有来源文件时由 handleDownloadFile 明确提示原因
           onClick: () => void handleDownloadFile(rawRecord),
         },
         {

+ 7 - 9
ai-frontend/src/case/views/data/importList.vue

@@ -45,8 +45,7 @@
   import type { FormProps } from '@/core/components/Form';
   import type { FileInfo } from '@/types';
   import { importFileList, deletedFileList, preFile } from '@/case/api/govern/governApi';
-  import { downloadLocalFileApi } from '@/core/api/sys/login';
-  import { resolveDownloadErrorMessage, saveBlobResponse } from '@/core/utils/file/download';
+  import { getOpenOfficeFileErrorMessage, downloadOfficeFileById } from '@/core/utils/openOfficeFile';
   import { FileStatusEnum } from '@/case/types/enum';
   import componentSetting from '@/core/settings/componentSetting';
   import CleanLogModal from './components/CleanLogModal.vue';
@@ -418,20 +417,19 @@
     }
   }
 
-  /** 下载该文件(Web 化:浏览器另存,不再调起服务端本地应用打开) */
+  /** 下载该文件(Web 化:只传文件ID,服务端查库定位文件流,浏览器另存) */
   async function handleDownloadFile(record: Partial<FileInfo>) {
-    const filePath = String(record.filePath ?? '').trim();
-    if (!filePath) {
-      createMessage.warning('未获取到文件路径,暂时无法下载文件');
+    const fileId = String(record.id ?? '').trim();
+    if (!fileId) {
+      createMessage.warning('未获取到文件ID,暂时无法下载文件');
       return;
     }
 
     try {
-      const response = await downloadLocalFileApi(filePath);
       const fileName = String(record.fileName ?? '').trim();
-      await saveBlobResponse(response, fileName || '文件');
+      await downloadOfficeFileById(fileId, fileName || '文件');
     } catch (error: any) {
-      createMessage.error(await resolveDownloadErrorMessage(error, '文件下载失败'));
+      createMessage.error(getOpenOfficeFileErrorMessage(error, '文件下载失败'));
     }
   }
 

+ 4 - 5
ai-frontend/src/case/views/data/index.vue

@@ -2724,7 +2724,8 @@
   const handleDownloadOfficeFile = async (record: Recordable) => {
     const fileId = resolveOfficeFileId(record);
     if (!fileId) {
-      createMessage.warning('未获取到文件ID,暂时无法下载文件');
+      // 汇总/统计行没有单一来源文件(fileId 为空),如实提示而不是把按钮置灰
+      createMessage.warning('当前记录没有关联的源文件,无法下载');
       return;
     }
 
@@ -2744,13 +2745,12 @@
     fixed: 'right',
     align: 'center',
     customRender: ({ record }: any) => {
-      const fileId = resolveOfficeFileId(record);
+      // 不再按 fileId 置灰:汇总行没有来源文件时由 handleDownloadOfficeFile 明确提示原因
       return h(
         Button,
         {
           type: 'link',
           size: 'small',
-          disabled: !fileId,
           onClick: (event: MouseEvent) => {
             event.stopPropagation();
             void handleDownloadOfficeFile(record);
@@ -2949,7 +2949,6 @@
           ...sortableColumn,
           width: Math.max(Number(sortableColumn.width ?? 0) || 0, 180),
           customRender: ({ record, text }: any) => {
-            const fileId = resolveOfficeFileId(record);
             const rawDisplayText = text && typeof text !== 'object' ? String(text).trim() : text === 0 ? '0' : '';
 
             return h(
@@ -2965,12 +2964,12 @@
               },
               [
                 rawDisplayText ? h('span', rawDisplayText) : null,
+                // 不再按 fileId 置灰:汇总行没有来源文件时由 handleDownloadOfficeFile 明确提示原因
                 h(
                   Button,
                   {
                     type: 'link',
                     size: 'small',
-                    disabled: !fileId,
                     onClick: (event: MouseEvent) => {
                       event.stopPropagation();
                       void handleDownloadOfficeFile(record);

+ 5 - 10
ai-frontend/src/core/api/sys/login.ts

@@ -103,19 +103,14 @@ export const machineCodeApi = () =>
   defHttp.get<string>({ url: adminPath + '/sys/code', timeout: 10 * 1000 }, { errorMessageMode: 'none' });
 
 /**
- * 下载服务端本地文件(原 /sys/openLocalFile 的 Web 化改造)。
+ * 下载文件(原 /sys/openLocalFile、/sys/openOfficeFile 合并而来)。
+ *
+ * <p>入参只传文件ID,服务端路径由后端查库得到(不接受任意路径);
  * 以 blob 读取、返回原生响应以便从 Content-Disposition 取文件名。
  */
-export const downloadLocalFileApi = (filePath: string): Promise<AxiosResponse<Blob>> =>
+export const downloadFileApi = (fileId: string | number): 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 },
-  );
-
-/** 按文件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 },
+    { url: adminPath + '/sys/downloadFile', params: { fid: fileId }, responseType: 'blob', timeout: 3 * 60 * 1000 },
     { errorMessageMode: 'none', skipErrorMessage: true, isReturnNativeResponse: true },
   );
 

+ 4 - 2
ai-frontend/src/core/utils/openOfficeFile.ts

@@ -1,4 +1,4 @@
-import { downloadOfficeFileApi } from '@/core/api/sys/login';
+import { downloadFileApi } from '@/core/api/sys/login';
 import { resolveDownloadErrorMessage, saveBlobResponse } from '@/core/utils/file/download';
 
 const normalizeOfficeFileKey = (value: string) =>
@@ -53,12 +53,14 @@ export const resolveOfficeFileId = (source: unknown): string | undefined => {
 /**
  * 按文件ID下载文件(Web 端:浏览器另存为,不再调起服务端的本地应用程序打开)。
  *
+ * <p>只传文件ID,服务端路径由后端查库定位(表行没有 filePath 时后端自动回退到所属文件根)。
+ *
  * @param fileId       文件/工作表ID(file_info.id)
  * @param fallbackName 响应头里拿不到文件名时的兜底名
  */
 export const downloadOfficeFileById = async (fileId: string | number, fallbackName = '文件') => {
   try {
-    const response = await downloadOfficeFileApi(fileId);
+    const response = await downloadFileApi(fileId);
     await saveBlobResponse(response, fallbackName);
   } catch (error: any) {
     // 后端以 JSON 报错时(如文件不存在),把原始 message 提取出来

+ 2 - 3
ai-frontend/src/trans/views/components/TransRecordModal.vue

@@ -319,7 +319,7 @@
   const handleDownloadFile = async (record?: TransRecord) => {
     const fileId = resolveOfficeFileId(record);
     if (!fileId) {
-      createMessage.warning('未获取到文件ID,暂时无法下载文件');
+      createMessage.warning('当前记录没有关联的源文件,无法下载');
       return;
     }
 
@@ -362,7 +362,6 @@
     actions: (record: { raw?: TransRecord }) => {
       const rawRecord = record.raw;
       const query = buildJumpQuery(rawRecord);
-      const fileId = resolveOfficeFileId(rawRecord);
       const detailId = normalizeActionRecordId(rawRecord?.id);
       const actions = [
         props.showDetailAction
@@ -374,7 +373,7 @@
           : null,
         {
           label: '下载文件',
-          disabled: !fileId,
+          // 不再按 fileId 置灰:没有来源文件时由 handleDownloadFile 明确提示原因
           onClick: () => void handleDownloadFile(rawRecord),
         },
         {

+ 51 - 0
ai-server/src/main/java/com/zsjz/ai/common/config/LicenseFilter.java

@@ -0,0 +1,51 @@
+package com.zsjz.ai.common.config;
+
+import jakarta.servlet.*;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.util.List;
+
+@Slf4j
+@Component
+public class LicenseFilter implements Filter {
+
+    private static final List<String> IGNORE_PATH = List.of("/sys/code", "/sys/noNet", "/sys/checkAuth", "/sys/health");
+
+    @Override
+    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
+
+    }
+
+/*    @Override
+    public void doFilter(Context ctx, FilterChain chain) throws Throwable {
+
+        String method = ctx.method();
+        if ("GET".equalsIgnoreCase(method)) {
+            Map<String, String> valueMap = ctx.paramMap().toValueMap();
+            String body = Json.toStr(valueMap);
+            log.info("请求日志,请求路径:{},请求方式:{},请求参数:{}", ctx.pathNew(), method, body);
+        }
+        if ("POST".equalsIgnoreCase(method)) {
+            String body = ctx.body();
+            log.info("请求日志,请求路径:{},请求方式:{},请求参数:{}", ctx.pathNew(), method, body);
+        }
+
+        if (!IGNORE_PATH.contains(ctx.pathNew())) {
+            try {
+                CheckAuthStatus checked = LicenseUtil.checkAuthStatus();
+                if (!checked.status()) {
+                    log.error("授权验证失败!");
+                    ctx.outputAsJson(Json.toStr(R.failure(401, checked.message())));
+                    return;
+                }
+            } catch (Exception e) {
+                log.error("校验授权验证异常", e);
+                ctx.outputAsJson(Json.toStr(R.failure(ErrorEnum.UNAUTHORIZED.getCode(), ErrorEnum.UNAUTHORIZED.getMessage())));
+                return;
+            }
+        }
+        chain.doFilter(ctx);
+    }*/
+}

+ 8 - 66
ai-server/src/main/java/com/zsjz/ai/module/plat/controller/SystemController.java

@@ -2,30 +2,17 @@
 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;
-
 /**
  * 系统控制器
  * 提供系统授权、版本管理、缓存管理等系统级接口
@@ -36,7 +23,7 @@ import java.util.Map;
  * 最终一律 404;而前端 `main.ts` 期望 `Result` 包装(`res?.code === 200`),
  * 会导致启动健康检查空转 2 分钟并总是跳转到授权页。
  *
- * <p>例外:{@code /sys/openLocalFile}、{@code /sys/openOfficeFile} 是**文件下载**接口,
+ * <p>例外:{@code /sys/downloadFile} 是**文件下载**接口,只接受 {@code file_info.id},
  * 刻意不返回 {@code Result},响应体是文件流(前端按 blob 读取)。
  */
 @RestController
@@ -121,62 +108,17 @@ public class SystemController {
     }
 
     /**
-     * 下载本地文件(原「调用系统程序打开本地文件」的 Web 化改造)
-     * 支持 CSV、XLS、XLSX;以附件形式返回文件流,由浏览器保存或交给本地应用打开
+     * 下载文件(Web 化:按文件ID查库定位服务端文件,以附件流返回给浏览器)
      *
-     * @param filePath 文件在服务端的绝对路径
-     */
-    @GetMapping("/openLocalFile")
-    public ResponseEntity<Resource> openLocalFile(String filePath) {
-        return buildDownloadResponse(systemService.resolveDownloadFile(filePath));
-    }
-
-    /**
-     * 按文件ID下载文件(原「调用系统程序打开已归档表格」的 Web 化改造)
+     * <p>入参只接受 {@code file_info.id} —— 不接受服务端路径,避免任意文件读取;
+     * 具体查库、校验与响应组装在 {@link SystemService#downloadFile(Long)} 内完成。
+     * 本接口刻意不返回 {@code Result},响应体是文件流本身(前端按 blob 读取)。
      *
      * @param fid file_info.id(文件或工作表)
      */
-    @GetMapping("/openOfficeFile")
-    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);
+    @GetMapping("/downloadFile")
+    public ResponseEntity<Resource> downloadFile(Long fid) {
+        return systemService.downloadFile(fid);
     }
 
     /**

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

@@ -25,11 +25,20 @@ import com.zsjz.ai.module.plat.mapper.SystemInfoMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
+import org.springframework.core.io.FileSystemResource;
+import org.springframework.core.io.Resource;
 import org.springframework.data.redis.core.RedisTemplate;
+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.stereotype.Service;
 
 
 import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.StandardCopyOption;
@@ -39,6 +48,8 @@ import java.sql.Statement;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.time.temporal.ChronoUnit;
+import java.util.Locale;
+import java.util.Map;
 import java.util.Set;
 
 /**
@@ -244,46 +255,90 @@ public class SystemService extends ServiceImpl<SystemInfoMapper, SystemInfo> {
         FileUtil.del(PathConst.ORI_UPGRADE_PATH);
     }
 
+    /** 可下载文件扩展名 → 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"));
+
     /**
-     * 校验并解析待下载的本地文件。
+     * 按文件ID下载文件(Web 端文件下载的唯一入口,原「调用系统程序打开文件」的 Web 化改造)。
+     *
+     * <p>入参只接受 {@code file_info.id}:服务端路径全部由本方法查库得到,
+     * 调用方无法传任意路径,避免退化成任意文件读取接口。
      *
-     * <p>原实现是客户端形态的「调用系统程序打开文件」,Web 端改为把文件流返回给浏览器下载;
-     * 这里沿用原有的 csv/xls/xlsx 后缀白名单,避免该接口退化成任意文件读取。
+     * @param fid 文件或工作表ID
+     * @return 附件形式的文件流响应(刻意不做 Result 包装)
+     */
+    public ResponseEntity<Resource> downloadFile(Long fid) {
+        DownloadFile file = resolveDownloadFile(fid);
+        Path path = file.path();
+        long contentLength;
+        try {
+            contentLength = Files.size(path);
+        } catch (IOException e) {
+            // 校验通过后文件被清理(清洗完成后会递归删除临时目录)
+            throw new ServerException(404, "文件不存在或已被清理");
+        }
+        ContentDisposition disposition = ContentDisposition.attachment()
+                .filename(file.fileName(), StandardCharsets.UTF_8)
+                .build();
+        return ResponseEntity.ok()
+                .contentType(DOWNLOAD_MEDIA_TYPES.getOrDefault(fileSuffix(file.fileName()), MediaType.APPLICATION_OCTET_STREAM))
+                .contentLength(contentLength)
+                // 案件原始数据,禁止浏览器/代理缓存
+                .cacheControl(CacheControl.noStore())
+                .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
+                .body(new FileSystemResource(path));
+    }
+
+    /**
+     * 解析待下载文件:查库取路径 → 校验(存在、非目录、csv/xls/xlsx 白名单)。
      *
-     * @param filePath 文件在服务端的绝对路径
-     * @return 绝对路径 + 建议的下载文件名
+     * @param fid 文件或工作表ID
      */
-    public DownloadFile resolveDownloadFile(String filePath) {
+    public DownloadFile resolveDownloadFile(Long fid) {
+        if (fid == null) {
+            throw ServerException.spe("文件ID不能为空!");
+        }
+        FileInfo fileInfo = fileInfoMapper.selectById(fid);
+        if (fileInfo == null) {
+            throw ServerException.spe("文件不存在!");
+        }
+        String filePath = resolveFilePath(fileInfo);
         if (StrUtil.isBlank(filePath)) {
-            throw ServerException.spe("文件路径不能为空!");
+            throw ServerException.spe("该记录没有可下载的源文件!");
         }
         Path path = Path.of(filePath);
         if (Files.notExists(path) || Files.isDirectory(path)) {
-            throw ServerException.spe("文件不存在!");
+            throw ServerException.spe("文件不存在或已被清理!");
         }
         String suffix = FileUtil.getSuffix(path.toFile());
         if (StrUtil.isBlank(suffix) || !GlobalCache.suffixList.contains(suffix.toLowerCase())) {
             throw ServerException.spe("仅支持下载 csv、xls、xlsx 文件!");
         }
-        return new DownloadFile(path.toAbsolutePath(), path.getFileName().toString());
+        String fileName = StrUtil.isBlank(fileInfo.getFileName()) ? path.getFileName().toString() : fileInfo.getFileName();
+        return new DownloadFile(path.toAbsolutePath(), fileName);
     }
 
     /**
-     * 按文件ID(file_info.id)解析待下载文件,优先使用记录里的原始文件名。
-     *
-     * @param fid 文件/工作表ID
+     * 取文件在服务端的路径:工作表行自身没有 filePath(只有 pid 指向文件根),回退到所属文件根。
      */
-    public DownloadFile resolveDownloadFileByFid(Long fid) {
-        if (fid == null) {
-            throw ServerException.spe("文件ID不能为空!");
+    private String resolveFilePath(FileInfo fileInfo) {
+        if (StrUtil.isNotBlank(fileInfo.getFilePath())) {
+            return fileInfo.getFilePath();
         }
-        FileInfo fileInfo = fileInfoMapper.selectById(fid);
-        if (fileInfo == null) {
-            throw ServerException.spe("文件不存在!");
+        Long pid = fileInfo.getPid();
+        if (pid == null || pid.equals(fileInfo.getId())) {
+            return null;
         }
-        DownloadFile resolved = resolveDownloadFile(fileInfo.getFilePath());
-        String fileName = StrUtil.isBlank(fileInfo.getFileName()) ? resolved.fileName() : fileInfo.getFileName();
-        return new DownloadFile(resolved.path(), fileName);
+        FileInfo parent = fileInfoMapper.selectById(pid);
+        return parent == null ? null : parent.getFilePath();
+    }
+
+    private static String fileSuffix(String fileName) {
+        int idx = fileName == null ? -1 : fileName.lastIndexOf('.');
+        return idx < 0 ? "" : fileName.substring(idx + 1).toLowerCase(Locale.ROOT);
     }
 
     /**