# 2026-09-16(下午) ## 上传/清洗流程:sessionStorage 缓存清理时机(用户要求) 用户要求:「我发起调用清洗,或者从上传文件页面返回时,清空 sessionStore 内容/已上传文件缓存」。 经确认(AskUserQuestion):时机 = **发起清洗任务时 + 上传页点返回时**;范围 = **连同清洗流程缓存一起清**。 ### 改动(2 个文件) | 文件 | 改动 | | --- | --- | | `case/views/data/upload.vue` | 新增 `clearCleanFlowSessionCache()`:清 `case_clean_file_infos`(沿用 `isOwnBatchStorage()` 批次守卫)+ `case_clean_import_file_infos` + `case_clean_reclean_context` + `resetImportProgressSession()`(清 `case_clean_import_progress`、关 SSE、重置运行时状态)。调用点 3 处:`handleBack()` 无活动分支、`cleanupAndLeave()`、`onBeforeRouteLeave` 兜底(目标不是清洗页/清洗进度页时才清) | | `case/views/data/cleanProgress.vue` | `startClean()`:`doClean()` **成功后**清 `case_clean_file_infos` + `case_clean_import_file_infos` + `case_clean_reclean_context`(原第 736 行那行保留) | ### ★ 关键约束(以后改这块必看) - **进清洗流程前绝对不能清 `case_clean_file_infos`**:`cleaning.vue`(`loadStoredFileList`)与 `cleanProgress.vue`(`loadCleanFiles` 的兜底键)都要读它,清了就无法清洗。所以「发起清洗时清」只能落在 `startClean()` 里、`loadCleanFiles()`/`loadReCleanContext()` **之后**。 - `startClean()` 里 **不能** 调 `resetImportProgressSession()`:`initializeProgress()` 的顺序是 `resetRuntimeState → connectSse → testSse → startClean`,它会把刚建立的 SSE 关掉;且会清掉刚设置的 `cleanTaskStarted/currentCleanFiles`。要清进度键就用 `clearImportProgress(caseId)`。 - 清除放在 `doClean()` 成功之后而非之前:失败时缓存仍在,可重新发起;否则发起失败即死路(必须重传文件)。 - `case_clean_import_progress` **不清**:它是当前任务的进度快照(`currentCleanFiles` 自包含),清了刷新页面就无法恢复进度。 - 5 个文件各自硬编码了同一批 key(upload/cleaning/cleanProgress/importList + importProgressSession),没有共享常量模块 —— 后续如要改 key 需全量搜 `case_clean_`。 ### 顺手修(预防性,非本次需求) - `upload.vue`:`import { type Key } from 'vue'` 报 TS2305(该版本 vue 不导出 `Key`)→ 去掉导入,`expandedRowKeys`/`onExpandedRowsChange` 改用 `(string | number)[]`(与 antd Table 的 `Key` 一致,代码里只塞字符串)。 ### 校验 - `eslint src/case/views/data/{upload,cleanProgress}.vue --max-warnings 0` → **0 错误** (`cleanProgress.vue` 原有 1 处 prettier 报错(第 134 行 import 折行)用 `--fix` 修掉,只动了那一行) - `vue-tsc --noEmit --skipLibCheck` → `case/views/data/{upload,cleanProgress,cleaning}.vue` **0 错误**(全仓 103 个既有错误在 `src/core` 等,与本次无关) ### 环境备忘(本机 Bash shim 残缺,绕过方式) - 本机 shim 里 `ls/head/tail/sed/dirname/grep/uname/rm` **全部不可用**(`dirname: command not found`), → `./node_modules/.bin/eslint` 这类 shell 包装脚本必然失败(它内部用 sed/dirname)。 - **可用方式**:绝对路径调托管 node 直接执行 JS 入口: `/c/Users/cc/.workbuddy/binaries/node/versions/22.22.2-3/node.exe ./node_modules/eslint/bin/eslint.js ...` 同理 `./node_modules/vue-tsc/bin/vue-tsc.js`。`git` 可用;`rm` 用 node 的 `fs.unlinkSync` 兜底。 - 类型检查输出重定向到仓库内文件 + Grep 工具过滤,比试图用管道 grep 更可靠(用完记得删)。 --- ## ★★ 全量 Controller `@RequestBody` 缺失审计(2026-09-16 下午) 用户要求:「检查项目中所有 controller 中有些接口是不是没有加 @RequestBody」。 ### 结论(报告:`controller-request-body-audit.md`,仓库根) `ai-server` 45 个 controller / 98 个写端点(POST/PUT/PATCH)中,**76 个端点参数没有绑定注解**, 其中 **75 个已被确认为真实缺陷**(前端用 `defHttp.postJson` 即 JSON body 调用 → body 被丢弃、参数全 null), 1 个暂无前端调用方(`/trans/cashFlow/statTransCashCallBeforeAfterTimeLine`)。 已正确 21 个(含上一轮修的 `/chat/**`、`/dm/preFile`、`/case/create|open|updatePwd` 等)+ 1 个 multipart。 ### ★ 判定标准(这套判据以后可直接复用) - 前端 axios **默认 content-type 是 `application/x-www-form-urlencoded`**(`core/utils/http/axios/index.ts:275`), `defHttp.post` 的 `data` 会被 `qs.stringify` 成表单 —— **不加注解的 POJO 能正常绑定**; **只有 `defHttp.postJson`**(`Axios.ts:187`,显式 `application/json` + `data` 作 body)才要求后端有 `@RequestBody`。 - 所以「POST 端点没加 @RequestBody」本身不是 bug,**必须交叉核对前端调用方式**才能定性。 - 已排除会推翻结论的因素:项目**没有**自定义 `HandlerMethodArgumentResolver`/`WebMvcConfigurer`, 也**没有**重写 body→parameter 的 Filter/`HttpServletRequestWrapper`(全仓 grep 0 命中)→ 走 Spring 默认 ModelAttribute 绑定。 ### 需特殊修法的 4 个端点(不能无脑加注解) | 端点 | 现状 | 说明 | | --- | --- | --- | | `/pg/delete` | 后端 `delete(Long id)`,前端 body `{id}` | 直接加 `@RequestBody` 会 400(对象→Long);应改 `@RequestParam`+前端 params,或引入 IdDTO | | `/intimacy/modify` | `List`,前端发数组 | `@RequestBody List` 即可 | | `/pbi/personOrder` | `List`,前端发数组 | 同上 | | `/call/night/statSecondCallNight` | 参数误写成 `@Param("query")`(MyBatis 注解) | 去掉 `@Param` 换 `@RequestBody` | ### 其它发现 - `ResidentPopulationController` 只有类级 `@RequestMapping("/rp")`、**零个方法**(空壳类)。 - 9 个 controller 只有 GET 端点:System/Tower/Govern/Search/CleanErrorLog/AgentResult/AgentPythonFile/TransFastFundFlow/ResidentPopulation。 ### ★ 修复(用户催「一点活也没干」后当日执行完毕) - **75 个端点全部补上 `@RequestBody`**:32 个 controller 改动(74 个 POJO 参数前插注解 + `/pg/delete` 改 DTO + `statSecondCallNight` 的 MyBatis `@Param("query")` 替换),新增 DTO `common/model/person/dto/DeletePersonGroupDTO.java`(前端发 `{id}`,简单类型不能直接加注解)。 - 清理:删掉被替换后失效的 `import org.apache.ibatis.annotations.Param`(CallNightController)、 4 处与 `annotation.*` 通配符重复的显式 `RequestBody` import、3 个 controller 里未使用的 MyBatis `@Delete` import。 - **验证三连**:① 结构比对(修复前后:映射注解/URL **零改动**,签名除注解外零改动,新增行 = 75 签名行 + 32 import); ② `mvn -pl ai-server -B clean compile` → **BUILD SUCCESS**(621 源文件);③ 复扫 audit → `broken=0 / missingBindingAnnotation=0 / annotated=97 + multipart=1`。 - 报告 `controller-request-body-audit.md` 顶部加「〇、修复记录」,第 1~8 节保留修复前状态留档。 - 备份:`C:/Users/cc/AppData/Local/Temp/zsjz-audit/backup-controllers/`(45 个 controller 修复前副本)。 ★ 32 个文件中 **`DataProfileController.java` 在我修复前就与 HEAD 不同**(import 被改成 `annotation.*` 通配符, 非我所为,已保留)→ 该文件不能用 `git checkout` 回退(会连带丢掉那处改动),要用备份副本;其余 31 个可 `git checkout`。 - 未做:运行期端到端验证(起服务发 JSON body 请求确认筛选生效)。 ### ★★ 本机 Maven 调用方式(shim 太残,cygpath/bash 都不可用) `mvn.cmd`、`mvn-run.sh` 都跑不起来(shim 里连 `bash`/`cygpath` 都 command not found)。**可行解**:直接调 java + classworlds, 全用 Windows 路径,HOST 用托管 JDK(JAVA_HOME 是 BellSoft JDK 25,能编 release 17 的代码 + Lombok): ```bash cd /e/workspace/zsjz-ai && "/c/Program Files/BellSoft/LibericaJDK-25-Full/bin/java.exe" \ -classpath "D:\soft\apache-maven-3.9.12-bin\boot\plexus-classworlds-2.9.0.jar" \ "-Dclassworlds.conf=D:\soft\apache-maven-3.9.12-bin\bin\m2.conf" \ "-Dmaven.home=D:\soft\apache-maven-3.9.12-bin" \ "-Dmaven.multiModuleProjectDirectory=E:\workspace\zsjz-ai" \ org.codehaus.plexus.classworlds.launcher.Launcher -pl ai-server -B clean compile \ > E:/workspace/zsjz-ai/.build-log.txt 2>&1; echo "EXIT=$?" ``` (注意 plexus-classworlds 版本是 **2.9.0** 不是 2.8.0;输出重定向到文件再用 Grep 工具过滤,管道 grep 不可用。) ### 复用工具(已固化为 skill) `~/.workbuddy/skills/spring-requestbody-audit/`: `scripts/audit.cjs`(审计+交叉比对+生成报告)、`scripts/fix.cjs`(按精确偏移批量补注解,dry-run/--apply/--skip)、 `scripts/verify-fix.cjs`(结构比对,强制断言「URL/签名除注解外零改动」)。 SKILL.md 里写了完整 9 步修复流程与本机编译方式。 ★ 写这类脚本踩过的 3 个解析坑(务必照抄规避): 1. TS 泛型里可能含 `{`(`postJson<{ [key: string]: X }>`)→ 找参数对象必须从「`(` 之后」开始找 `{`; 2. **嵌套泛型** `>` / `>` 会让 `<[^>]*>` 正则整个失配 → 必须手写「跳过可选泛型块」的扫描; 3. 后端类级 `@RequestMapping("cr")` **不带前导斜杠**(45 个里只有这 1 个)→ 拼路径时要补 `/`,否则匹配不上前端 URL。 审计覆盖率自查法:统计前端 `defHttp.(postJson|post|put|patch|uploadFile|request)` 总数(本次 174)与解析出的调用点数(171), 差值必须能逐个解释(本次 3 个全是注释里的假命中)。 --- ## 错误提示带上接口地址(2026-09-16 晚,用户要求) 需求:「错误提示加上访问接口地址」→ 所有接口报错时,提示里要能一眼看出是哪个接口。 ### 改动(前端 3 文件,`ai-frontend/src/core/utils/http/axios/`) | 文件 | 改动 | | --- | --- | | `helper.ts` | 新增 `appendApiUrl(message, url)`:`'接口请求出错' + '/js/a/dm/doClean'` → `'接口请求出错(/js/a/dm/doClean)'`;url 为空或 message 已含该地址时不重复追加 | | `index.ts` | ① `transformRequestHook` 取 `res.config.url`,业务错误(`code !== 200`,含 401 与 `skipErrorMessage` 分支)统一走 `appendApiUrl`;② `responseInterceptorsCatch` 的 `urlPath` 增加 `|| config?.url` 兜底(超时/网络异常时没有 `response`,原来会取到空串);③ 超时/网络异常/`ERR_BAD_RESPONSE` 的 toast 带地址;④ `checkStatus` 调用传入 `urlPath` | | `checkStatus.ts` | `checkStatus(status, msg, mode, url?)` 新增第 4 个可选参数,400/403/404/500 等状态码提示统一 `appendApiUrl(errMessage, url)` | ### 关键设计点(以后改这里注意) - 格式用**中文全角括号后缀**而不是 `\n`:antd `message` 的 content 是 div,`\n` 会被折叠成空格。 - 追加位置放在「最终显示/抛出」之前,所以**服务端 message 与前端 i18n 文案(`sys.api.*`)都自动带上地址**, 且 `throw new Error(errorMessage)` 里的 message 也带地址 —— 调用方 `error.message` / `getRequestErrorMessage()` 无需改动。 - 全仓检查过没有任何地方做 `message === 'xxx'` 的精确匹配(只有 cleanProgress 里一段注释掉的代码),所以拼接安全。 - 保留 `/sys/health` 的静默特例(`urlPath.includes('/sys/health')`),健康检查轮询不会刷屏;其 URL 带 `?_t=` 时间戳也能命中。 - 覆盖范围:`defHttp` 的所有实例(含 `src/ai/api/http.ts` 的 `aiHttp`,它复用 defHttp);`errorMessageMode==='none'` 的请求仍然不弹提示(未改语义)。 - 提示内容不含 HTTP method(按「接口地址」字面要求);若以后需要区分同路径不同方法,可在 `appendApiUrl` 里加。 ### 验证 - `eslint`(3 文件,`--max-warnings 0`)→ 0 错误 - `vue-tsc --noEmit` → 这三个文件 **0 错误**,全仓错误总数仍为 103(与改动前一致,无新增) --- ## Result 包装复核(2026-09-16 晚,用户要求) 需求:「检查所有 controller 的接口返回结果是不是又缺 Result 包裹,有就加上」。 ### 审计口径与结果 全量扫描 45 个 controller → **165 个方法级端点**(用「方法级映射注解总数 = 165」做过完整性交叉校验,**无遗漏**): | 类别 | 数量 | | --- | --- | | 已 `Result` 包裹 | 151 | | 流式 / ResponseEntity(不适用) | 3(`/govern/sse`、`/chat/stream` 的 SseEmitter + `/py/files/**` 的 ResponseEntity) | | **刻意裸返回(保持不动)** | 11(`AgentChatController` 10 个 `/chat/**` + `AgentResultController` 的 `/chat/results/{resultId}`) | | **漏网缺陷(已修)** | 2 | ### 修掉的 2 个(`DataProfileController`) - `GET /dp/callStat`:`CallDataProfileStatDTO` → `Result` - `GET /dp/transStat`:`TransDataProfileStatDTO` → `Result` 前端 `person/api/dpApi.ts` 用 `defHttp.get<...>` 默认 transform 读 `{code,message,data}`, 裸返回必然走到 `code !== 200` 分支 → 画像页通话/交易统计直接报「接口请求出错」。 ### ★ 为什么上一轮批量脚本会漏掉这两个 它们的注解与方法签名之间夹了一行注释(Solon 迁移遗留): ```java @GetMapping("/callStat") //@Cache(key = "dp_callStat_${personName}", tags = StrConsts.CACHE_TAG_QING_JIAN) public CallDataProfileStatDTO callInfoStat(String personName) { ... } ``` 「紧跟映射注解去找方法签名」的解析逻辑遇到注释行就失配 → 整个端点被跳过。 **教训:任何注解/签名扫描都必须做完整性交叉校验**(映射注解总数 vs 解析端点数),否则静默漏项。 ### 刻意裸返回清单的判定依据(不要再"修"它们) - `/chat/**`:前端 `aiHttp.postRaw/getRaw/putRaw/deleteRaw`(`{ isTransformResponse: false }`) - `/chat/results/{resultId}`:同上,且 404 语义=结果过期 - `/py/files/**`:`ResponseEntity` 直出文件 - `/govern/sse`、`/chat/stream`:`SseEmitter` 流式 ### 验证 - 复扫:151 + 3 + 11 = 165 ✓ - `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`(`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>` | | `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 —— 本次两者都用了。 --- ## ★★ 下载接口收紧:两个接口合并成 `/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 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 替代方案(``、浏览器目录选择受限等),未擅自改动。 --- ## Electron 残留 → 纯 Web 迁移方案(2026-09-16 晚,用户要求列方案) 产出:`electron-migration-plan.md`(仓库根)。全仓 `electron|ipcRenderer|isElectron` 命中点逐个读源码确认用途, **共 14 项**:3 项直接删、6 项前端改造即可、5 项需后端配合;4 项 Web 天然做不到(给了降级方案)。 ### 清单要点(细节见文档) 1. 窗口最小化/最大化/关闭(`useElectronWindow` + 4 个组件)→ 直接删,可选保留全屏按钮 2. 版本号(AppLogo/ProductInfoModal `getVersion()`)→ 构建期常量 `import.meta.env` 3. **离线地图瓦片**(`ElectronPmtilesSource`,3 个地图组件)→ 换成满足 PMTiles `Source` 接口的 `HttpPmtilesSource`(后端 Range 接口,Spring `ResourceHttpRequestHandler` 原生支持 206)或 `FilePmtilesSource`(`` + `File.slice().arrayBuffer()`,**零后端改动**) 4. 离线地图目录选择(ipc `selectFolder`)→ 路径最终存在**服务端**(`/sys/mapLocal`),改为「服务端目录浏览弹窗」或「文本框+校验」 5. 基站包 `.bin` 选择 → 同上;**不要改成上传**(现有基站包 391MB) 6. 案件全局配置(ipc `read/writeCaseGlobalConfig`)→ 复用已有的 `/sys/getMaplocal`、`/sys/cellLocal`,或退化为 localStorage 7. 授权文件(ipc `selectFile` → `/sys/noNet` 收**服务端路径**)→ `` 读文本 + 后端改为收内容(license 仅 316B) 8. 升级包(ipc `selectFile` → `/sys/upgrade?path=`)→ 建议沿用服务端路径 + 目录浏览,而非上传大 zip 9. **图谱历史文件读写**(ipc `readTextFile`/`writeTextFile`,路径来自 `graph_his`,本质是服务端文件) → 挪到后端:新增读/存内容两个接口 10. 基站 xls 选择 + 打开目录 → 选文件复用已有的 `/dm/preFileUpload`(multipart);"打开本地目录" web 做不到,改页内列表 11. **头像**(`getPathForFile` 存本机路径 + `PersonAvatar` ipc 读图)→ 改为上传(`LocalFileStorageUtil` + `file-url.base-url`), ★ 唯一涉及**存量数据**的项:库里历史 `file:///C:/...` 路径浏览器读不到,需迁移或降级占位图 12. `governApi.getPackagedFallbackOrigin()`(`file:` 协议兜底 baseURL)→ 直接删 13. `types/global.d.ts` 的 `window.electron` 声明 → 全部迁完后删 14. `preFile` 的"Electron 本地路径"注释 → 代码实际只被 importList 用服务端路径调用,仅更新注释 ### 实施批次 P0(1/2/6/12/14,纯删改)→ P1(3-B/7/9/10,前端为主)→ P2(3-A/4/5/8,统一做"服务端目录浏览+校验")→ P3(11,存量数据)→ 收尾(13 + grep 归零)。 验收:`grep -ri "electron\|ipcRenderer\|isElectron" ai-frontend/src` 归零。 --- ## ★★ Electron 迁移批次 2:授权/升级/图谱历史/基站转换/窗口控制/版本号(2026-09-16 晚,全部完成) 用户说「继续做」后,一轮完成了迁移方案里的 **7、8、9、10、1、2、12** 共 7 项(12 顺带)。 ### 后端(3 文件) | 文件 | 改动 | | --- | --- | | `SystemController` | `/noNet` 改 `POST`(multipart,`@RequestPart("file") MultipartFile`);删 `GET /upgrade`(按路径),新增 `POST /sys/uploadUpgrade`(multipart);新增 `GET /sys/map/pmtiles`(HTTP Range)与 `GET /sys/map/style` | | `SystemService` | `noNet(MultipartFile)`:用 `getOriginalFilename()` 校验 `.xlts` 后缀,`Files.copy` 落盘到 `LICENSE_PATH_FILE`(启动时 `Files.createDirectories(LICENSE_PATH)`);`uploadUpgrade(MultipartFile)`:zip 落盘到 `ROOT_PATH/upgrade-package.zip` → 复用 `upgrade(path)` → finally 清理临时包;`mapPmtiles(rangeHeader)` / `mapStyle()` / 私有 `resolveMapFile` / `serveLocalFile`(`HttpRange.parseRanges` + `ResourceRegion`,多区间/非法/越界 → 416) | | `GraphController`/`GraphService` | 新增 `GET /graph/history/{id}/content` 与 `POST /graph/history/{id}/content`(按历史ID读写快照 JSON;`resolveGraphHisFile` 校验路径必须在 `GRAPH_HIS_PATH` 内且为 .json,防任意文件读写) | **注意**:`SystemService` 没有 `@Slf4j` 的 log —— 那是我加 log.error 编译失败后发现的(GraphService 也没有);这两个类里新代码要么别用 log,要么加注解。`ResourceRegion` 在 `org.springframework.core.io.support` 下。 ### 前端(10 文件) | 项 | 改动 | | --- | --- | | 7 授权 | `authorization/index.vue`:ipc 选择器 → 隐藏 `` + `licenseFile`/`licenseFileName`;`login.ts` 的 `offlineAuthorizationApi(file)` 改 `defHttp.uploadFile`(`ctxPath + adminPath + '/sys/noNet'`)并手动解包 Result(uploadFile 直接走 axiosInstance.request,**不经过 transformRequestHook**) | | 8 升级包 | `UpgradeModal.vue`:`` + 上传进度;`setting/api.ts` 新增 `uploadUpgradePackage(file, onProgress)`(uploadFile + 30min 超时),删 `upgradeSystem(path)` | | 9 图谱历史 | `graph/index.vue`:写 → `saveGraphHistoryContent(String(historyId), payload)`(historyId 来自 `createGraphHistory` 返回的 `id`);读 → `fetchGraphHistoryContent(remoteId)`;删 ipc 的 `getElectronIpcRenderer`/`writeGraphHistoryPayloadToFile`/`readGraphHistoryPayloadFromFile`(保留 `extractGraphHistoryFilePath` 用于 normalizeGraphHistoryItem 的元数据、`looksLike/normalizeGraphHistoryFilePath` 用于 payload 判别) | | 10 基站 | `baseStationQueryApi.ts`:删路径版 `convertBaseStationData`/`fetchBaseStationTemplatePath`,新增 `uploadBaseStationConvert(file, onProgress)`(blob + 原生响应)与 `downloadBaseStationTemplate()`;`aiBaseStation/index.vue`:隐藏 `` + `handleConvertFileChange`(上传→blob→`downloadByData` 下载,JSON 错误体解析)+ `handleFetchTemplate` 改下载模板;删 `openDirectoryByPath`/`resolveTemplatePath`/`doConvertFile` | | 1/2 窗口与版本 | 删 `WindowControls/Minimize/Maximize/Close.vue` + `useElectronWindow.ts` + `header/components/index.ts` 导出 + 三个页头的挂载(header/index.vue、authorization、case.vue);AppLogo/ProductInfoModal 版本号改 `__APP_INFO__.pkg.version`(vite define 已注入 `appPkg.version`) | | 12 file: 兜底 | `governApi.ts` 删 `DEFAULT_ELECTRON_API_URL` + `getPackagedFallbackOrigin()`,host 兜底链只剩同源 | ### ★ 踩坑记录(这批最费时间的三处) 1. **CRLF 匹配**:这批 .vue 是 CRLF,多行 old 字符串用 `\n` 全部失配 → 脚本必须先探测 EOL(`\r\n` vs `\n` 计数),统一转 LF 处理再按原 EOL 写回。 2. **prettier --fix 后的连锁**:eslint --fix 会把 script 里手工插入的行重排(如把 `(cond ? a : '')` 的括号去掉、把 import 折行),别把 --fix 前的预期当真,以 --fix 后的 grep 为准。 3. **误删恢复**:对 `aiBaseStation/index.vue` 的行过滤把 `getCarrierLabel` 连带删了 → 发现后立即恢复(用唯一锚点行重插)。 ### 验证 - 后端 `mvn -pl ai-server -B compile` → BUILD SUCCESS - 前端 eslint(10 个改动文件)0 错误;`vue-tsc` 全仓 103(无新增) - `HttpPmtilesSource` 10 项运行时断言全通过(本地 Range 服务) - 迁移方案文档总览表已逐项标 ✅ --- ## 实施(2026-09-16 晚):地图瓦片走方案A + 删除两个路径选择功能 用户决定:`离线地图目录选择` 删除、`基站库(.bin)选择` 删除、`离线地图瓦片用方案A`。 ### 后端(SystemController + SystemService) - 新增 `GET /sys/map/pmtiles`:从系统配置 `mapLocal` 目录读 `china.pmtiles`,**支持 HTTP Range** (无 Range → 200 全量 + `Accept-Ranges: bytes`;单区间 → 206 `ResourceRegion`;多区间/非法/越界 → 416) - 新增 `GET /sys/map/style`:返回配置目录里的 `behavior-spacetime-style.json`(无则 404,前端回退内置样式) - 逻辑全在 service:`mapPmtiles` / `mapStyle` / `resolveMapFile` / `serveLocalFile` / `rangeNotSatisfiable`;controller 仅转发 - ★ 两个接口**刻意不返回 Result**(响应体是文件内容),文件名是服务端常量白名单,不接受调用方传参 —— 已加入「刻意裸返回」清单 - ★ 坑:`ResourceRegion` 的包是 **`org.springframework.core.io.support.ResourceRegion`**(不是 `core.io`) ### 前端 - `core/utils/map/offlineMap.ts`:删 `ElectronPmtilesSource`,新增 `HttpPmtilesSource`(fetch + Range 头)、 `getOfflineMapPmtilesUrl()` / `getOfflineMapStyleUrl()` / `getBuiltinPmtilesUrl()` / `resolveOfflinePmtilesUrls()`; 删掉三个已无用的本地路径 URL 工具(`getOfflineMapPmtilesUrl` 旧的本地版 / `getOfflineMapFileUrl` / `getOfflineMapFilePath`) - 三个地图组件(`BaseStationQueryMap.vue`/`StationMapModal.vue`/`behaviorSpacetime/index.vue`): 瓦片源换 `HttpPmtilesSource`(服务端目录未配置则回退前端静态 `resource/map/china.pmtiles`,并把它也注册成源); 自定义样式读取从 ipc `readTextFile` 换成 `fetch /sys/map/style` - **删除**:`OfflineMapModal.vue`、`CellLocalModal.vue`、`core/utils/caseGlobalConfig.ts`(ipc 持久化)、 `SettingFooter.vue` 两个入口按钮/弹窗/引导监听、`components/index.ts` 两个导出、`caseConfigGuide.ts` 的 hydrate 调用与两个弹窗事件常量 ### ★ 删掉 UI 后路径怎么配(重要,写进文档了) 两条路径本来就存在**服务端**(`SystemConfManager` → `QingJian/conf/c5e503ad.json` 的 `mapLocal`/`cellLocal`): 1. 接口:`GET /js/a/sys/mapLocal?path=...`、`GET /js/a/sys/cellLocal?path=...` 2. 直接编辑 `QingJian/conf/c5e503ad.json`(**改完要重启后端**,配置是启动时读入内存的) ### 验证(都做了实测,不只是编译) - 后端 `mvn -pl ai-server -B compile` → BUILD SUCCESS - **Range 分支**:用真实 spring-web 6.2.19 复刻 `serveLocalFile` 逻辑跑 9 例(`0-99`/`100-`/`-100`/`999-999` → 206; `0-1,5-6`/`abc`/`2000-3000`/`-0` → 416;无 Range → 200)**全通过** (注:`bytes=2000-3000` 的 416 是靠 `ResourceRegion` 的 count<0 断言抛错兜住的,`HttpRange` 本身不报错) - **前端**:esbuild 打包真实 `offlineMap.ts` + 起本地 Range 服务,10 项断言(URL 拼装 / Range 头 / 返回字节 / 空参数不发请求 / 404 返回空)**全通过** - `eslint --max-warnings 0` 0 错误;`vue-tsc` 全仓仍 103(无新增) ### 遗留(可选清理) `cellLocal.ts` 里 `getCellLocalPath`/`getManualCellLocalPath`/`getServerCellLocalPath`/`setCellLocalPath`/`isValidCellLocalPath`/ `CELL_LOCAL_PATH_CHANGED_EVENT` 与 `offlineMap.ts` 里 `getManualOfflineMapDir`/`getServerOfflineMapDir`/`setOfflineMapDir`/`clearOfflineMapDir` 在删掉两个弹窗后已无引用(纯 localStorage 工具,无 Electron 代码),未删以控制改动面。 `getOfflineMapDir()` 仍保留读旧的 `OFFLINE_MAP_DIR_KEY` 作为兜底(兼容 Electron 时代留下的 localStorage)。