2026-09-16.md 36 KB

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<Intimacy>,前端发数组 @RequestBody List<Intimacy> 即可
/pbi/personOrder List<PersonBasicInfo>,前端发数组 同上
/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):

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. 嵌套泛型 <Page<X>> / <Record<string, any>> 会让 <[^>]*> 正则整个失配 → 必须手写「跳过可选泛型块」的扫描;
  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 增加 `
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<CallDataProfileStatDTO>
  • GET /dp/transStat:TransDataProfileStatDTO → Result<TransDataProfileStatDTO> 前端 person/api/dpApi.ts 用 defHttp.get<...> 默认 transform 读 {code,message,data}, 裸返回必然走到 code !== 200 分支 → 画像页通话/交易统计直接报「接口请求出错」。

★ 为什么上一轮批量脚本会漏掉这两个

它们的注解与方法签名之间夹了一行注释(Solon 迁移遗留):

@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<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 —— 本次两者都用了。

★★ 下载接口收紧:两个接口合并成 /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>、浏览器目录选择受限等),未擅自改动。

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(<input type=file> + 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 收服务端路径)→ <input type=file> 读文本 + 后端改为收内容(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 选择器 → 隐藏 <input type=file accept=".xlts"> + licenseFile/licenseFileName;login.ts 的 offlineAuthorizationApi(file) 改 defHttp.uploadFile(ctxPath + adminPath + '/sys/noNet')并手动解包 Result(uploadFile 直接走 axiosInstance.request,不经过 transformRequestHook)
8 升级包 UpgradeModal.vue:<input type=file accept=".zip"> + 上传进度;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:隐藏 <input type=file> + 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)。