# 2026-09-15 ## 编写仓库级 AI Agent 协作指南 - 产出:`E:\workspace\zsjz-ai\AI_AGENT.md`(仓库根,12 节) - 内容:项目定位(清鉴 = 纪检监察/经侦智能资金数据研判平台)、仓库结构、前后端技术栈、 后端分层落位约定、主/从数据源(PG master + DuckDB slave)、Agent 子系统(AgentScope 工具/SSE 协议/硬约束)、 dm 清洗模块、前端域目录约定、常用命令、Do/Don't、已知待清理项、提交检查清单。 - 关键区分:`ai-server/src/main/resources/prompts/AGENTS.md` 与 `QingJian/agent/AGENTS.md` 是**产品运行时 AI 人格提示词**,不是编码助手约定,勿混改。 - 发现的问题(已写入文档第 11 节): 1. `common/config/Result.java` 有未使用且无依赖的 `import io.milvus.param.R;` 2. `Result` 的 SUCCEED_CODE/FAILURE_CODE 非 final 3. `app.yml` 为 Solon 遗留配置,已失效;`context-path: "!/js/a/"` 语义可疑 4. ai-server 未见 @MapperScan ## 环境备忘 - Bash 工具在本机异常(`dirname: command not found`,PATH 损坏),改用 Glob/Read/Grep/PowerShell。 - PowerShell 的 stdout 在该环境下未能回传,目录列表类操作优先用 Glob。 --- ## AI 数据分析应用:旧 AI 清除 + 全新实现(前端 3 页 + 后端补口) ### 旧 AI 代码移除 - 前端:删 `src/cms/`(11 文件);`App.vue` 重写(去掉 AI 悬浮面板全部状态/函数/样式); `menu.json` 删「AI 助手」IFRAME 节点(10261);`package.json` 删 `@jeesite/cms-lib`。 - 后端:删 `templates/chat.html`、`static/js/chat.js`、`marked.min.js`、`static/css/chat.css`、 `theme.css`、`static/layui/`;`static/` 目录整体移除。 - 文档:`AI_AGENT.md` §6 前端消费方改为 `ai-frontend/src/ai/api/`。 ### ★ 关键发现:`context-path: "!/js/a/"` 导致 ai-server 无法启动 实测(直接 new `TomcatServletWebServerFactory().setContextPath(v)`,Spring Boot 3.5.16): - `!/js/a/` → IllegalArgumentException: ContextPath must start with '/' and not end with '/' - `/js/a/` → 同样被拒(**尾部斜杠不合法**) - `/js/a` → 通过 已改为 `context-path: /js/a`。**结论:接口实际前缀就是 `/js/a`**, 前端 = `urlPrefix(/js) + adminPath(/a)`,与 `defHttp` 约定一致。 (此前文档把它标为"语义可疑",现已实证。) ### 后端补充(`mvn -pl ai-server compile` 通过) - `sql/agent_chat_session_add_user_id.sql`:加 `user_id` + 索引,**不回填**(回填会把历史会话判成他人会话) - `AgentChatSession` 加 `userId`;`assertOwner` 改比 `userId`(NULL 历史行宽放+告警); `listSessions` 加 `(user_id = ? OR user_id IS NULL)`;`createSession` 写入 `userId` - 新增 `AgentResultController`(`/chat/results/{resultId}`,裸返回,404=已过期) - 新增 `AgentPythonFileController`(`/py/files/{wsId}/{execId}/{file}`,白名单+normalize 防穿越) - `PythonExecutor.IMAGE_URL_PREFIX`:`/api/v1/py/files/` → `/js/a/py/files/` - 顺手修 `SqlResultStore` 既有缺陷:`sql` 未存入 `CachedResult`,导致 `PageData.sql` 恒 null ### 前端新模块 `src/ai/`(27 文件) - `api/`(types/http/chatApi/modelApi)、`utils/`(constants/scanner/parseMessageContent/reportExport/echartsSetup/useAiChart)、 `store/chatStream.ts`、`styles/ai.less`、`components/`(6 个 blocks + 9 个业务组件)、`views/`(3 页) - 菜单:`menu.json` 加一级分组 10263 + 子节点 10264/10265/10266; `routeHelper.ts` 的 `explicitDynamicViewMap` 加 3 条显式映射 ### ★ 两个技术要点(后续改动务必注意) 1. **表格是裸 JSON 混在 markdown 正文**(后端提示词明确禁止围栏)→ 必须字符级花括号配平扫描 (`scanner.ts`),不能只按围栏切分。流式未配平时用头部特征 `{"resultId"` 提前出 pending 占位块。 增量策略:已完成块冻结(尾部 8KB 窗口外)+ 只重扫尾部 + 尾部块按 key 复用对象 (保持 `data` 引用稳定,否则图表重复 setOption、表格分页被重置)。 2. **UnoCSS 扫不到 `menu.json`**(`content.pipeline.include` 只含 `**/*.{vue,tsx,ts}`), 而 `Icon` 组件是运行时拼 `i-:` 类名 → 菜单图标不会生成 CSS(项目既有隐患)。 已在 `uno.config.ts` 加 `safelist` 显式列出。 ### 其他踩坑 - `streamdown-vue/style.css` 全项目零引入,不引 markdown 无样式(已在 MarkdownBlock 引入) - `streamdown-vue` 默认只放行 http/https 图片,Python 图走同源 `/js/a/py/files/` → 必须放行 `/` 前缀 - `@relation-graph/vue` 的 `RGOptions` 键名是 `defaultLineTextOffset_y`(下划线), `defaultJunctionPoint` 是枚举不是字符串;数据靠 `graph.setJsonData()` 注入 - 项目 echarts 只注册 Bar/Line/Pie/Map/PictorialBar/Radar + SVGRenderer → 散点/关系图会**静默空白** - 本机 Maven:`mvn` shell 脚本会把 `/d/...` 传给 Windows java 导致 classworlds 找不到; 本地仓库是 `D:\soft\repository`;`ai-server/pom.xml` 的 `` 为空, 需先 `mvn -N install` 安装父 POM 才能 `-pl ai-server` 编译 - katex 未在 `package.json` 声明(只在 pnpm 虚拟存储),顶层无法解析其 CSS → 数学公式无样式 ### 验证 - 后端 `mvn -pl ai-server compile`:BUILD SUCCESS - 前端 `pnpm type:check` 中 `src/ai/**`:**0 错误**(项目其他模块有大量既有类型错误) - `eslint ./src/ai/**` + 改动文件:0 问题 - `pnpm build`:成功,三页各自独立懒加载 chunk - **dev server 逐模块冒烟**:3 视图 + 15 组件 + 3 utils/store 全部 200,无 transform 错误 - **UnoCSS 白名单生效**:产物 CSS 中 `i-mdi:history` 等含真实内联 SVG ### 追加:结果集归属隔离 + 文档同步 - `SqlResultStore` 加 `ownerKey`(登录 userId 字符串),`get(..., ownerKey)` 校验归属, 不匹配/未登录一律 null → 404(刻意不区分"不存在"与"无权限")。 链路:`doStream(userId)` → `getOrCreateAgent` → `buildAgent(..., ownerKey)` → `SqlAnalysisTool(..., ownerKey)`。 agent 实例本就按 `u{userId}-m{modelId}` 池化,所以把 userId 绑进工具是安全的。 - `AI_AGENT.md` 同步:目录树 `cms/`→`ai/`;§6 控制器清单(4 个)+ 工具名纠正 (实际是 `execute_python` / `get_current_workspace` / `get_user_selected_workspace`, 原文写的 `python_analysis` / `workspace_info` 是错的)+ 新增"前端渲染管线"映射表; §8 新增"新增页面/菜单的四个坑";§11 context-path 项标记为已实证修复。 ### 环境备忘(补充) - 本机 8980 **没有服务在跑**(`curl --noproxy '*'` 返回 000;不加 `--noproxy` 会走沙箱代理得到 502 假象)。 - 仓库根目录有一个 `electric-beacon-darwin-pwxCEck4.md`(我的计划/完成记录快照,已被 git add), `~/.workbuddy-ai/plans/` 下的同名文件被系统工具重新生成过 —— 两者内容不同,注意别搞混。 --- ## ★★ 后端真实联调:发现两个"整仓无法启动"级别的阻塞并修掉一个 PG `192.168.0.109:5432` 与 Redis 都可达,于是把 ai-server 真跑起来了。过程中发现: ### 阻塞 1(已修):mappers XML 全量残留 `com.qingjian` 包名 - `src/main/resources/mappers/` 下 **85 个 XML 全是 `com.qingjian.*` 命名空间**, 而 Java 侧 0 处引用 `com.qingjian`,有 **85 个 `com.zsjz.*` Mapper 接口**(一一对应) → 包名迁移时漏改了 XML。 - MyBatis 启动即炸:`Failed to parse mapping resource: mappers/ai/SkillMapper.xml` → `Could not resolve type alias 'com.qingjian.common.model.ai.dto.AiStatCallFrequencyTop10'`。 **即 ai-server 在本仓库根本无法启动。** - 分析(脚本):144 处唯一引用中 **130 处可机械映射** `com.qingjian.X → com.zsjz.ai.X`(目标类均存在); **14 处无法映射的全部落在 `mappers/ai/`**(6 个不存在的 DTO + 8 个不存在的 Mapper), 该目录是 QingJian 旧 AI 模块,现 AI 能力在 `module/agent`(MyBatis-Plus BaseMapper,无需 XML)。 - **修复**:删 `mappers/ai/`(8 文件);其余 77 个文件、160 处引用 `com.qingjian` → `com.zsjz.ai`。 文件均在 git 跟踪且改动前无本地修改 → 可 `git checkout` 完全回退。 - 之后 `mvn -pl ai-server clean compile` BUILD SUCCESS,**应用成功启动**。 ### 阻塞 2(未修,属环境/仓库缺口):缺平台库结构与客户端运行时资产 - 启动日志已确认:`Tomcat started on port 8980 (http) with context path '/js/a'` → **context-path 修复正确**,此前实证的 `/js/a` 得到运行时验证。 - 但 `AppLoadEndEventListener`(CommandLineRunner)随后失败: `PSQLException: 关系 "table_info" 不存在`。 - 库结构勘查(写了 JDBC 小工具扫多个库): | 库 | 表数 | table_info/table_field | agent_* | |---|---|---|---| | **zsjz-ai**(application-dev.yaml 指向它) | 14 | ✗ | ✓ | | etl-2 | 21 | ✓ | ✗ | | lightbot | 48 | ✗ | ✗ | | testai | 2 | ✗ | ✗ | **没有任何库同时具备两者** —— 平台库结构在 `etl-2`,agent 结构在 `zsjz-ai`。 - 启动链还依赖本地运行时资产:`GlobalCache.initCache()` 需 `table_info`+`table_field`、 `CaseDataCache` 需 `person_lib_no`、`initCaseRocksDbData()` 需 RocksDB 目录、 `TowerUtils.init()` 需基站库文件 —— `PathConst.ROOT_PATH = user.dir`, 说明 **ai-server 是按"客户端应用"设计的**,裸跑仓库目录跑不通。 - 结论:完整端到端联调需要「补齐平台库结构 + 客户端运行时资产」, 超出本次任务范围,未擅自改共享库。**已把 `sql/agent_chat_session_add_user_id.sql` 应用到 zsjz-ai**(additive,见下)。 ### 已应用的 DB 变更(唯一一处写共享库) - 对 `zsjz-ai` 执行了 `ADD COLUMN user_id BIGINT` + `COMMENT` + `CREATE INDEX idx_agent_chat_session_user_case`。 均为 additive/幂等;执行前该列不存在、索引不存在。 - 存量数据现状:4 个会话,`user_id` 全 NULL、`case_id` 全有值 → **印证了"NULL 宽放"的设计**(回填 case_id 会把这些会话判成他人会话而全部 403)。 - 库中还有 `agent`(row_id=1 "数刃")、`agent_model`(3 条:embedding 1 + llm 2,默认是 Ollama qwen)、 `agent_model_provider`(4 条:Ollama/OpenAI/通义千问/DeepSeek)。 ### 由联调反查出的 3 个前端真实缺陷(已修) 1. **模型类型语义搞错**:`agent_model.type` 是**用途类型** `llm`/`embedding`, 不是厂商协议(协议在 `agent_model_provider.type`)。我原先把表单选项写成 openai/dashscope/ollama/… → 已改为 llm/embedding。 2. **状态枚举值搞错**:实际是 `StatusEnum` 的 ACTIVE/SUSPENDED/ARCHIVED/DISABLED/DELETED, 我原先按 `'0'` 判断停用 → 已改为枚举映射 + 配色。 3. **聊天模型选择器会把 embedding 模型列出来**:库里确实有一条 embedding 模型, 选中后必然失败 → 已过滤 `type === 'llm'`(默认模型选取也同步过滤)。 另加一道保护:把 embedding 设为默认模型时前端拦截并提示 (后端 `getDefaultModelId()` 只按 `default_model=true AND status=ACTIVE` 过滤,**不区分 type**, 这是个后端待改进点)。 --- ## ★ 核心逻辑独立验证:196 项断言,挖出 2 个真实 bug 并修掉 项目**没有测试框架**(无 vitest/jest),但 `esbuild` 是 devDependency,够用。 做法:esbuild `--alias`(或 JS API 的 `alias` + `onResolve` 插件)把依赖 `import.meta.env` / axios 的模块换成桩,打包成 CJS 后跑 Node 断言。 (`constants.ts` 在模块作用域调 `useGlobSetting()`;`http.ts` 会拉进 axios+antdv+pinia 整条链。) **Windows 路径必须 `cygpath -w`,否则 node/esbuild 把 `/c/...` 解析到当前盘符。** ### 已验证(196 项) - **`utils/scanner.ts` 56 项**:围栏(```/~~~/四个反引号/未闭合→pending)、 **裸 JSON 表格识别**(字符串内花括号、多表格、未配平→pending)、HTML/SVG 块、 `isTablePayload`/`findJsonEnd`、增量扫描(冻结块**同一对象引用**、key 稳定、`data` 引用稳定、 短内容回退全量、流式表格组装)、导出还原。 - **`api/chatApi.ts` 的 SSE 解析 25 项**:mock `globalThis.fetch` + `ReadableStream`, 覆盖跨 chunk 分帧、**中文/emoji 多字节被切断**、`\r\n`、多行 data、8 种事件、 error 帧、非 SSE 错误响应、末帧无结尾空行、请求体/凭据、Abort 语义。 - **`store/chatStream.ts` 流式状态机 68 项**:`pinia` 在 Node 下用 `setActivePinia(createPinia())` 跑,chatApi 用可编程替身驱动回调。覆盖 init 幂等、自动建会话+乐观插入+流式中状态、token 累积、**节流 100ms 中途解析**、 工具步骤按 `toolCallId` 打补丁、intent/suggestions、会话元信息本地更新、 错误帧/传输异常/已有内容不被覆盖、`stop()` 触发 abort、会话 CRUD、 `selectSession` 反序列化 `toolEvents`、本地消息不调接口、`reset`、无工作空间不发送。 - **`utils/reportExport.ts` 19 项**:单表 sheet 结构与表头、多表分 sheet、 sheet 名唯一/带序号/≤31 字符/不含 Excel 非法字符、无表格返回 null、 缺列定义按行键推断、空行集、撞名去重、空值与缺键容错。 - **`SqlResultStore` 28 项**(后端,直接 `java -cp target/classes` 跑 main,无需 Spring/DB): ownerKey 归属隔离(本人可读/他人与未登录不可读/**不泄露存在性**/`ownerKey=null` 历史结果宽放)、 `sql` 回传、分页 clamp(超界/0/负数)、自定义 pageSize、空结果、LRU 淘汰、 并发(小规模正确性 + 高竞争下隔离不变量)。 ### 打包上的三个坑 1. `--node-paths`(裸包名从项目 node_modules 解析)**只有 JS API 支持**,CLI 没有; 所以需要它时写 `.cjs` 脚本 `require('绝对路径/esbuild')`(脚本在仓库外,ESM 无法用 NODE_PATH)。 2. 重定向**相对导入**(如 store 内部的 `'../api/chatApi'`)CLI `--alias` 匹配不了, 要用 JS API 的 `onResolve` 插件。 3. esbuild CLI 失败时错误可能被吞(`output: [null,null,null]`), 直接调 `.pnpm/@esbuild+win32-x64@*/.../esbuild.exe` 看真实报错。 ### 挖出并修掉的真实 bug 1. `scanner.ts`:围栏正文多带一个结尾换行 → 代码块多一个空行、导出/复制带多余换行。 2. `chatApi.ts`:**末帧没有结尾空行时整帧被丢弃**(SSE 规范允许末帧无空行; `createParser` 只按 `\n\n` 分帧,EOF 时没有冲刷缓冲区)→ 会丢 token 文本或 done 事件。 已改为返回 `{push, flush}`,读取循环结束后 `parse.push(decoder.decode())` + `parse.flush()`。 ### 我自己的测试用例也错了 7 处(记录一下,避免下次重复) - mock 异步流瞬间 resolve → `await sleep(10)` 时 `send()` 已收尾,观察不到"流式中"状态 (mock 必须中途挂起);`api.calls[0]` 未必是目标调用(前面还有 listSessions); 分页把第 2 页当第 1 页断言;并发用例 1600 次 put 必然触发 LRU(50) 淘汰(与隔离无关); 多行 `data:` 切在了非法 JSON 位置;等等。 --- ## ★★★ 移除 sa-token 用户体系 + 端到端打通(本轮) 用户明确:"项目已移除用户体系。移除 sa-token 相关代码。" ### 1. 移除 sa-token(后端 13 处调用) - 3 个 Controller 删 `import cn.dev33.satoken.stp.StpUtil` 与 13 处 `StpUtil.getLoginIdAsLong()` - `AgentChatService` 11 个方法签名去掉 `Long userId`;`AgentChatServiceImpl` 同步, `assertOwner` → `requireSession`(只保留 404,去掉归属判断),`listSessions` 去掉 user_id 过滤, `createSession` 去掉 `setUserId` - `AgentChatSession` 删 `userId` 字段;`ChatSessionVO` 删 `userId`;`AgentChatResult` 去掉 ownerKey - `AgentService.getOrCreateAgent` 去掉 userId,**池化 key 改为 `w{ws}-a{agent}-m{model}`** (原 key `u{userId}-m{modelId}` 漏了 workspace/agent → 切换案件会复用旧 agent 实例,属真实缺陷) - `SqlResultStore` 去掉 ownerKey 归属隔离(保留 `sql` 回传修复) - 删除 `sql/agent_chat_session_add_user_id.sql` - **`sa-token` 不在任何 pom.xml 中声明**(靠传递依赖),所以删完 import 无需改 pom - AgentScope 侧仍需"运行时用户标识",统一取常量 `RUNTIME_USER_ID = "default"` (用于 `RuntimeContext.userId()` / `AgentStateStore` 记忆槽位 / usage 统计) ### 2. ★ 又一个硬阻塞:`LicenseFilter` 吞掉所有请求 `common/config/LicenseFilter.java` 的 `doFilter()` **方法体是空的**,从未调用 `chain.doFilter`, 而它是 `@Component implements Filter` → Spring Boot 注册到 `/*`。启动日志实证: `Mapping filters: ..., licenseFilter urls=[/*] order=2147483647`。 **后果:所有 HTTP 请求(含不存在的路径)都返回 200 + 空体,后端"能启动但完全不服务"。** 该类无任何其他引用 → 直接删除。修复后不存在的路径正确返回 404。 ### 3. ★ `com/zsjz/ai/common/` 目录被删除事件(已恢复) 会话中途发现 `ai-server/src/main/java/com/zsjz/ai/common/` 下 **243 个文件从工作区消失** (HEAD 中有、工作区 0 个)。非我的 `git rm` 所致(那次只删了 LicenseFilter 一个文件)。 排查:无 git stash、`Code/User/History` 无该项目快照、JetBrains LocalHistory 为二进制格式。 **处理:`git checkout -- ` 从索引恢复 243 文件**,并**重新应用**两个已知未提交改动: - `PathConst.RUN_QINGJIAN_PATH = ROOT_PATH.resolve(APP_NAME)`(HEAD 里是 `= ROOT_PATH`) - `AppLoadEndEventListener` 里注释掉 `CaseDataCache.initCache()` 恢复后 `common/` 相对 HEAD 仅剩这 3 处预期差异(含 LicenseFilter 删除),编译 BUILD SUCCESS。 **若用户侧有其它未提交改动在该目录内,需其自行核对。** ### 4. 启动方式(关键) - **必须从仓库根启动**:`PathConst.ROOT_PATH = user.dir`,而 `spring-boot:run` 默认以 `ai-server` 为工作目录(会命中残缺的 `ai-server/QingJian/`)。需显式: `-Dspring-boot.run.workingDirectory=E:/workspace/zsjz-ai` - 启动日志确认:`Tomcat started on port 8980 with context path '/js/a'` + `Started App` + `initCache success`(table_info/table_field 已就位)+ `请先设置基站数据库路径!`(TowerUtils 未配,不致命) ### 5. ★ `case_info` 表的三处 schema 不匹配(SQLite DDL 套到 PG 造成,非我引入) | 问题 | 现象 | 修复 | | --- | --- | --- | | `id` NOT NULL 但无自增/默认 | `IdType.AUTO` 插入留空 → 违反非空约束 | `ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY` | | 列名 `db_Path` 为驼峰(带引号创建) | 未加引号查询折叠成 `db_path` → 字段不存在 | `RENAME COLUMN "db_Path" TO db_path` | | `db` 是 `varchar(8)` | 代码写 `randomString(8)+id`(9+ 字符)→ 值太长 | `ALTER COLUMN db TYPE varchar(64)` | `case_info` 当时 0 行,改动零数据风险。 **全库同类问题扫描结果(未修,仅报告)**: - 驼峰列还有 1 个:`meta_raw_sheet.lineNo` - 缺自增 id 的表还有 9 个:`agent_chat_session`/`agent_message`/`agent_model`/`agent_model_provider`/ `lunar`/`meta_raw_file`/`meta_raw_sheet`/`table_field`/`table_info` (其中 agent_* 由 Java 侧生成 ID,无影响;`lunar`/`meta_raw_*` 若走 AUTO 插入会失败) ### 6. ★ 端到端验证结果(API 级核心链路全通) - 建案:`POST /case/create` → id=2,`QingJian/workspace/2/pYRn5q3L2` 生成,`case_info` 落库 - 开案:`POST /case/open?id=2&pwd=123456` → 200(注册 DuckDB slave + CaseDataCache) - 灌合成数据(DuckDB 单写者,须先 `GET /case/exit` 释放文件锁): `person_basic_info` 8 / `call_record` 280 / `trans_record` 199 / `rel_node` 24 / `rel_edge` 16 - 建会话:`POST /chat/sessions {workspaceId:2, modelId:DeepSeek}` → 200 - **SSE 流式(233KB)事件统计**:`intent`×1、`token`×1071、`tool_call`×13、`tool_input`×13、 `tool_result`×13、`suggestions`×1、`done`×1 —— **无 error** - `intent` 帧含意图分类 + 增强问题 + entities;正文**按契约输出裸 JSON 表格载荷** (resultId + sql + columns + rows),数据与灌入数据一致 - 结果集翻页:`page=1/pageSize=3` → totalRows=5/totalPages=2;`page=2` 返回余量 2 行; `page=999` clamp 到末页 2;不存在的 resultId → **HTTP 404** + 友好提示;`sql` 正确回传 - **默认模型(Ollama `10.66.66.66:8080`)不可达**;`api.deepseek.com:443` 可达且已配 Key → 验证时用 `modelId=3742141657084284816` 指定 DeepSeek ### 7. 待办 / 未完成 - UI 级验证(起 vite dev + 浏览器自动化截图)未做 - 未验:` ```echarts `/` ```graph ` 围栏渲染、`execute_python` 出图、模型 CRUD、中断落库 - 遗留垃圾:`QingJian/workspace/1/`(首次建案失败留下的空目录) - 前端 `ChatSession.id` 等 Long 字段后端序列化为**字符串**(防 JS 精度丢失), 而 TS 类型声明为 `number` → `aiAnalysis` 的 `openFromQuery` 用 `Number(raw)` 与 `s.id` 比较会失配 (从聊天历史"继续对话"跳转可能定位不到会话),**待修** --- ## ★★★ 2026-09-16 上午:AI Agent 功能测试(14 项) ### 后端启动注意 上一轮的后端在 2026-09-15 23:29 随会话结束被终止(日志无关闭信息)。重启后需**重新 `/case/open`** (slave DuckDB 数据源是进程内状态,不持久化)。 ### 通过项(14) | # | 测试 | 结果 | | --- | --- | --- | | 1 | 图表渲染 `render_chart` | ✅ ```echarts 围栏 817 字符,数据口径正确 | | 2 | 关系图谱 `render_graph` | ✅ ```graph 围栏 4591 字符 + 28 行表格 | | 3 | 会话 CRUD(重命名/置顶/收藏/详情/消息列表) | ✅ 全 200 且状态生效 | | 4 | 模型 CRUD(增/改/设默认/删) | ✅ 全 200 | | 5 | 中断落库 | ✅ 12s kill → `messageType=interrupted` | | 6 | 用户消息落库(修复后回归) | ✅ 1 user + 1 assistant | | 7 | SSE 错误路径(无效/非法 sessionKey、空消息、无效 modelId) | ✅ 全部返回规范 error 帧 | | 8 | 路径穿越防护(`..%2f`、`..%5c`、`--path-as-is`) | ✅ 400/404,无文件泄露 | | 9 | 模型污染修复回归 | ✅ 无效 modelId 后会话 model_id 未被写坏 | | 10 | 多轮记忆延续 | ✅ 追问正确引用上一轮结论(280/199) | | 11 | 案件隔离(新建空案件问同一问题) | ✅ 所有表返回 0 行,未串案件2数据 | | 12 | 结果集翻页 + 404 | ✅(上一轮已验) | | 13 | 真实载荷扫描器验证(4 个真实 SSE 输出) | ✅ 全部正确识别 | | 14 | 前端 `type:check` + ESLint | ✅ 0 错误 | ### 环境限制(非缺陷) - **`execute_python` 未注册**:日志 `Python 执行环境不可用(command=python,需安装依赖: pip install pandas duckdb matplotlib)` → 该 Python 环境缺依赖。故 Python 出图 + `/py/files/**` 无法验证。 启用方式:给 `etlProperties.python.command` 指向的 Python 装 pandas/duckdb/matplotlib。 ### 本轮发现并修复的 3 个真实缺陷 1. **用户消息从不落库**(高影响):`doStream` 只落 assistant,`messageMapper.insert(userMessage)` 仅在非流式的 `/chat/messages` 里 → 前端走流式,刷新后用户看不到自己的提问、会话转录缺一半。 **修复**:新增 `persistUserMessage()`,在 `doStream` 解析会话后落库一次(含 messageCount+1)。 2. **无效 modelId 会永久写坏会话**(高影响):`doStream` **先回写 `session.model_id` 再校验模型是否存在**, 一次带无效 modelId 的请求即把会话钉死在坏模型上,后续不带 modelId 的每轮都失败。 **修复**:把回写移到 `getOrCreateAgent()`(内含模型存在性校验)**之后**。 已修数据:`UPDATE agent_chat_session SET model_id=3742141657084284816 WHERE model_id=999999`(1 行)。 3. **围栏包裹的表格载荷被渲染成代码块**(中影响):后端契约明确「禁止把结果 JSON 放入 ```json 或 ```sql 块」, 但实测模型约 1/4 概率违反(包进 ``` 或 ```json)。前端 scanner 原先把围栏内容一律判为 code 块 → 退化成不可翻页的代码块。 **修复**:`scanner.ts` 围栏分支加容错——若围栏体是合法表格载荷(`isTablePayload`)且 lang 为 ``/json/sql,则按 `table` 渲染。已用 4 个真实 SSE 输出验证(含那个被围栏包住的用例)。 扫描器断言从 56 项增至 64 项。 ### 本轮发现但**未修**的问题(需你决策) 1. **Agent 具备 shell 执行能力**:Python 测试中 Agent 自行调用了 AgentScope 内置的 `execute`(shell,11 次)、`write_file`、`glob_files`、`list_files`。对处理案情数据的产品, LLM 能执行任意 shell 命令是显著的安全/范围问题,建议限制 HarnessAgent 的内置工具集。 2. **`/sys/**` 全部 404**:`SystemController` 是 `@Controller` 而**所有方法都没有 `@ResponseBody`** → 返回 `void`/`String`/实体时被当成视图名去转发(日志:`View name [sys/health]` → `Forwarding to [sys/health]` → 404)。且前端 `main.ts` 期望 `Result` 包装(`res?.code === 200`)。 影响:`waitForBackendReady()` 重试 60×2s=**卡 2 分钟**;`resolveStartupRoute()` 拿不到 `code===200` → **总是跳转 `/authorization`**,前端无法进入应用。 (注:LicenseFilter 修复前该接口返回空 200,同样不满足 `code===200`,故此问题一直存在。) 3. `/chat/sessions` 缺 `workspaceId` 参数返回 **500**(`MissingServletRequestParameterException` 未被 GlobalExceptionHandler 处理),宜为 400。 ### 测试脚本(可复用,均在 temp 目录) - `agent_test.py`:发起 SSE 提问 + 解析事件统计/围栏/裸 JSON 表格/图片引用 - `real-payload.cjs`:把真实 SSE 的 token 正文喂给 scanner,验证块识别(防契约漂移) - `PgMsg*.java` / `PgSess*.java`:核对消息落库与会话状态 - 关键测试数据:案件 2 = 有数据(8人/280通话/199交易/24节点16边),案件 3 = 空库对照 --- ## ★★★ 2026-09-16 上午(续):UI 级验证 ### 浏览器工具链 - `agent-browser` 的**守护进程在本机起不来**:`open`/`eval`/`snapshot`/`doctor` 全部挂死 (`~/.agent-browser/default.pid` 写了进程号但进程已死、端口未监听)。 Node 派生能力已排除(托管 node22 与系统 node24 都能正常 spawn detached 子进程)。 - **改用 `playwright-core` + agent-browser 已下载的 Chrome**: `chromium.launch({executablePath: 'C:\\Users\\cc\\.agent-browser\\browsers\\chrome-153.0.8010.47\\chrome.exe'})` (playwright-core 装在 temp 目录,不下载浏览器,秒级可用)。 - 脚本:`ui-stage1.cjs`(开案+进 AI 页)、`ui-stage2.cjs`(提问+渲染校验)、`ui-probe-case.cjs`(DOM 探测) - 截图目录:`C:\Users\cc\AppData\Local\Temp\ai-verify\shots\` ### ★ 授权门:本机许可证就在仓库里 前端启动强校验 `/sys/checkAuth` 必须返回 `code:200`(`main.ts` 的 `shouldRunStartupAuthCheck` 只豁免授权页本身,`skipAuthCheck` 查询参数**并未被采纳**)。 - 机器码 `A9DEBA7C48078E4B...`(`GET /sys/code`) - 许可证文件 **`lib/windows/license_刘备_A9DEBA7C.xlts`** 文件名后缀与机器码前缀**完全匹配** → 复制到 `QingJian/license/license.xlts` 后 `/sys/checkAuth` 返回 `{"code":200,"data":"试用"}` - 另:`system_info` 表为空 → `/sys/info` 的 `data` 为 null;但前端 `isAuthorizationExpired(undefined)` 返回 false(不拦截),所以不影响进入应用。 ### UI 阶段发现并修复的 4 个真实缺陷 1. **`/sys/**` 全部 404**(前文已记)→ `SystemController` 改 `@RestController` + 全部返回 `Result`。 修复后 `/sys/health`、`/sys/info`、`/sys/checkAuth` 均 `code:200`。 2. **`/case/*` 返回裸对象/数组** → 前端 defHttp 默认 transform 期望 `{code,message,data}`, 实际抛「接口请求出错,请稍后重试!」且案件列表渲染为空。 **修复**:`CaseInfoController` 全部返回 `Result`;`/case/open` 额外返回已开案信息。 3. **`/case/create|open|updatePwd` 参数绑定失败** → 前端以 **JSON body** 提交, 后端却是简单参数(form/query)→ 日志 `parameters={}` → 500。 **修复**:改为 `@RequestBody`(新建 `OpenCaseDTO`;create 用 `@RequestBody CaseInfo`; updatePwd 用 `@RequestBody UpdateCasePwdDTO`)。 **注意**:`/case/open` 现在只认 JSON body,`curl -d "id=2&pwd=..."` 不再可用。 4. **`/chat/sessions` 缺 `workspaceId` 返回 500** → `GlobalExceptionHandler` 补 `MissingServletRequestParameterException` → 400。 ### 案件页 DOM 结构(写自动化必知) 案件列表**不是表格**,是 antd 卡片: ``` .ant-card(标题 .ant-card-head-title 内含案件名) └ ul.ant-card-actions > li > span > div.flex > button "打开案件" ``` 密码弹窗:`input[type="password"]` + `.ant-modal-footer button.ant-btn-primary`。 ### UI 已确认正常的部分 - 应用自动跳转 `/case`,标题「清鉴线索调查工具」,左侧菜单完整 (案件数据/数据画像/通信分析/交易分析/活动轨迹分析/行为时空分析/对象关系分析/基站查询/AI 数据分析/模型管理/聊天历史) - AI 数据分析页渲染正常;未开案时显示**空状态提示**「请先打开一个案件,AI 数据分析将基于当前案件的数据空间工作。」 - 模型选择器显示「本地模型(默认)」;输入区提示「Enter 发送 · Shift+Enter 换行」 ### ★ UI 端到端验证结果(全部通过) | 项 | 证据 | | --- | --- | | 流式逐字输出 | 21s 完成,中间态截图有「正在理解问题」→ 逐段文本 | | **交互表格 + 分页** | 表头[号码/机主姓名/通话次数/作为本方/作为对方] + 5 行数据;分页器「共 5 行 · 50 条/页」 | | **ECharts 图表** | canvas 850×340 `painted=true`;堆叠柱状图 + 图例 + 坐标轴 + 保存图片/配置 | | **关系图谱** | 852×494;meta「节点 8 · 关系 20」;8 个节点 DOM 带姓名与分类;边标签「11笔/¥3260…」 | | 工具调用面板 | 「3 次工具调用 · 检索表结构、校验图表配置、执行 SQL 查询」+ 每步可展开「详情」 | | **导出下载** | 菜单 Markdown/Excel/PDF;实下 `.xlsx`(16938B,合法 OOXML) 与 `.md`(3646B,含表格 JSON+echarts 围栏,往返无损) | | 模型选择器 | 下拉仅 `["本地模型(默认)","ds"]` —— **证实只列 llm 模型,embedding 未出现** | | 会话列表 | 显示案件 2 的全部会话 + 新建会话即时出现,条数含 user 消息(落库修复生效) | | 错误渲染 | 模型不可达时气泡显示 `⚠️ HTTP transport error...` + 复制/导出/删除 | | 控制台 | 修复后错误数 1(仅 antd `rowKey` 弃用警告) | ### ★ UI 阶段又修掉 2 个真实前端缺陷(都在 GraphBlock.vue) 1. **图谱整体渲染失败**:`graphOptions` 里写了 `lineUseTextPath: true`,当前 `@relation-graph/vue` 版本**不支持该属性并直接抛错** (`Graph options do not support setting "lineUseTextPath" ... Please use "defaultLineTextOnPath"`), 导致图谱不出图 + 连带 60+ 个 Vue `Cannot set properties of null (setting '__vnode')` 错误。 **修复**:改名为库要求的 `defaultLineTextOnPath`。修复后控制台错误 67 → 1。 2. **图谱节点姓名不可见(白字白底)**:CSS `.ai-node { color: var(--node-color) }` 是**文字颜色**, 而模板把节点**填充色** `node.color`(= `#ffffff`)绑给了它 → 姓名渲染了但看不见。 **修复**:在 `jsonData` 的 `data` 里显式写 `accentColor: color`,模板改绑 `node.data?.accentColor`(不依赖图谱库内部字段名)。修复后节点显示姓名 + 分类。 ### 暗色模式:当前工程不可达(非缺陷) - 开关组件 `AppDarkModeToggle.vue` 已导出但**未在任何模板挂载**(`showDarkModeToggle: true` 形同虚设) - 主题插件在 `build/plugins/index.ts:45` **被注释掉** - 故 `data-theme=dark` 无运行时暗色 CSS 可加载。手动加 `dark` 类后 body 变深色 (`rgb(44,52,74)`)但 AI 容器仍为白底 —— 因为 `--ai-*` 是 Less 编译期变量。 **结论**:暗色未启用,无法验证;若将来启用(恢复插件 + 挂载开关),需用生产构建复验 AI 页配色。 ### 本轮后端又修的契约问题(同属"裸响应 vs Result") | 文件 | 问题 | 修复 | | --- | --- | --- | | `SystemController` | `@Controller` 无 `@ResponseBody` → 全部 404 | 改 `@RestController` + 返回 `Result` | | `CaseInfoController` | 返回裸对象/数组 + 入参用简单参数 | 返回 `Result`;create/open/updatePwd 改 `@RequestBody`(新增 `OpenCaseDTO`) | | `GovernTreeController` | 返回裸 List/Map | 全部 `Result` 包装 | | `GlobalExceptionHandler` | 缺 `MissingServletRequestParameterException` | 补 → 400 | **注意**:`/case/open` 现在只认 JSON body。 ### UI 验证补全(模型管理页 / 建案流程) | 项 | 结果 | | --- | --- | | 模型管理页 3 行 | ✅ 类型列「对话模型/对话模型/**向量模型**」、状态列「**活跃**」(枚举映射生效) | | 向量模型「设为默认」拦截 | ✅ 弹窗「无法设为默认模型…请选择「对话模型」作为默认。」 | | 通过 UI 表单建案 | ✅ 新案件出现在列表;**0 接口错误、0 控制台错误**(验证 `/case/create` 的 `@RequestBody` 改造) | | 案件页/模型页控制台 | ✅ 0 错误;AI 页仅 1 条 antd `rowKey` 弃用警告 | ### 自动化探测的坑(供下次复用) - **antd Table 有两种结构**:可滚动表是「表头表 + 表体表」两张(`.ant-table-header` / `.ant-table-body`), 不可滚动表只有一张(行在 `.ant-table-tbody tr`)。统计行数要兼容两者,否则误判 0 行。 - `page.locator('button:has-text("x"), text=y')` 这种**混用 CSS 与文本引擎**的写法会报错, 要用 `page.getByText('y')`。 - 等待流式结束用「`停止生成` 按钮消失」,别用"文本长度稳定"。 ### 遗留测试产物 - 通过 UI 建的案件「UI建案验证-266000」(空库),以及案件 3「AI验证案件-空库对照」, 均为验证产物,可按需删除(密码都是 123456)。 --- ## ★★★ 全量 Controller Result 包装审计与修复(2026-09-16) ### 审计结论 写脚本扫描全部 45 个 controller:**147 个端点中 122 个未用 `Result` 包装**(涉及 40 个文件)。 再扫前端 250 个调用点:**248 个期望 `Result`**(defHttp 默认 transform), 只有 2 个显式 `isTransformResponse:false`(`/sys/info`、`/sys/openLocalFile`)。 ⇒ 这些未包装端点在前端几乎全是坏的(抛「接口请求出错,请稍后重试!」)。 ### 修复方式 写脚本 `wrap-result.cjs` 批量转换,规则: - 只改「带 `@XxxMapping` 的 public 方法」 - `void` → `Result` + 末尾补 `return Result.succeed();` - 其它 `X` → `Result`,所有 `return expr;` → `return Result.succeed(expr);` - **跳过**:`SseEmitter` / `Flux<...>` / `ResponseEntity<...>` / 已是 `Result<...>` - **跳过 AI 模块 3 个 controller**(`/chat/**`、`/chat/results/**`、`/py/files/**` 前端 `aiHttp` 显式按裸响应读) **结果:未包装端点 122 → 14**,剩下的 14 个正是刻意保持裸响应的 (`/chat/**` 11 个 + `/chat/results/**` 1 + `/py/files/**` 1 + `/govern/sse` 1)。 编译 BUILD SUCCESS。 ### ★ 脚本踩的 4 个坑(重要,下次别重复) 1. **原始类型不能做泛型参数**:`boolean` → `Result` 编译失败,必须装箱 (`boolean→Boolean`/`int→Integer`/`long→Long`/`double→Double`/`char→Character`…)。 2. **`indexOf` 定位返回类型会命中 URL 里的同名子串**: `@GetMapping("/createGraphHis") public GraphHis createGraphHis(...)` → URL 被改成 `/createResult`。**必须用精确偏移,改完逐文件比对映射注解**。 3. **文件是 CRLF**:`/(^import .*;\n)/m` 匹配不到(行尾是 `\r\n`)→ 必须写 `\r?\n`。 4. **`return` 语句多消耗了一个 `;`** → 出现 `return Result.succeed(x);;`。 处理完表达式后要 `i = j + 1` 跳过原分号。 ### 安全流程(这次靠它兜住) 1. **改前完整备份**所有 controller 到 temp(`backup-controllers/`) 2. 批量转换 3. **编译**(暴露了 30 处错误 → 逐类修脚本) 4. **与备份逐文件比对映射注解与方法名**(`audit-diff.cjs`)→ 找出 URL 被污染那 1 处 5. 重新审计(确认只剩预期的 14 个未包装) 6. 重启后**抽样冒烟**:`/sdc/list`、`/dm/getGovernConfList`、`/graph/graphHis`、`/gt/tree`、 `/case/list`、`/models`、`/models/providers`、`/cleanLog/list` 全部返回 `{code,message,data}`; `/chat/sessions` 仍是**裸数组** ✓;SSE `/chat/stream` 正常出帧 ✓ ### 当前阻塞(环境,非代码) **DeepSeek API Key 已失效**:`Authentication Fails, Your api key: ****dfsf is invalid`(HTTP 401)。 08:55 时还能用,之后失效 —— 需要用户更新 Key 才能继续跑 LLM 对话验证。 默认模型 Ollama(`10.66.66.66:8080`)本就不可达。 ### 运行状态(2026-09-16 09:30 用户要求停止) - **后端(8980)与 Vite dev(3100)均已停止**,端口已释放;无 java/vite/无头 Chrome 残留进程。 - 下次要跑:从仓库根 `mvn -pl ai-server spring-boot:run -Dspring-boot.run.workingDirectory=E:/workspace/zsjz-ai`, 启动后**必须重新 `POST /js/a/case/open`**(JSON body)注册 slave 数据源。 --- ## 移除「聊天历史」菜单(2026-09-16 09:45,用户要求) 用户要求去掉独立的「聊天历史」菜单。共改 4 处: 1. `core/store/modules/menu.json`:删掉 AI 分组(`/10263`)下的 `/aiChatHistory/index` 子项 (分组 redirect 仍是 `/aiAnalysis/index`,未受影响) 2. `core/router/helper/routeHelper.ts`:删掉 `'/aiChatHistory/index'` 的组件映射 (**AI 模块的视图只走这张映射表,不走通用 glob**,删了就不会再有引用) 3. **删除** `ai/views/aiChatHistory/index.vue`(该文件已被 git 跟踪,已 git rm) 4. `ai/views/aiAnalysis/index.vue`:删掉因此失效的 `openFromQuery()`(读 `?sessionId=` 定位会话) 以及随之无用的 `useRoute` 导入与 `route` 变量 **验证**:`menu.json` JSON 合法且 AI 菜单变为 `['AI 数据分析','模型管理']`; 全仓 `grep aiChatHistory|聊天历史` **0 命中**;`i-mdi:history` 无残留引用; `src/ai/**` 类型检查与 ESLint **0 错误**;改动的 3 个文件均不在 `src/core` 既有类型报错列表中。 **注意**:`pnpm type:check` 全量跑会报出 **`src/core/**` 的既有错误**(`getToken`/`getUserInfo`/ `getSessionTimeout` 等在用户体系移除后失配,约 10+ 处),这是**历史遗留、非本次引入**; 检查时用 `grep "^src/ai/"` 过滤即可。 --- ## ★★★ 案件数据上传:Electron 本地路径 → 纯 Web multipart 上传(2026-09-16) 用户要求:移除 Electron 相关内容,改纯 Web 上传(多选文件 + 文件夹,xls/xlsx/csv,带进度), 后端改**单文件**上传并解析,解析完查结果**复用原结果页**,后续流程与参数保持不变。 ### 改造前的关键事实(决定了方案) - 原链路前端只把**本地绝对路径数组**发给 `POST /dm/preFile`(`{filePath: string[], isDir}`), 后端拿路径读**服务端/客户端本机文件** → Web 化必须真正上传字节。 - **`filePath` 在清洗阶段会被再次读取**(`CleanTask:87` 打开文件、`DmService.hasSourceFile` 过滤) → 上传文件**必须落盘持久化**,且 `filePath` 要回填为服务端可读的绝对 Path。 - 清洗完成后会**递归删除整个 `QingJian/tmp`** → 上传文件**绝不能放 tmp 下**, 否则「重新清洗」会失效。落盘目录:`QingJian/workspace/{caseId}/upload/{batchId}/`。 - `PreTask`/`PreDataListener` 本来就接收 `Path` 入参 → **落盘成 Path 即可整套复用解析逻辑**。 - ⚠️ `DmController` **所有 POST 端点都缺 `@RequestBody`**(Solon→Spring 迁移遗留) → 现有 `preFile` 本来就是坏的,必须先补。 ### 后端改动(ai-server) | 文件 | 改动 | | --- | --- | | `DmController.java` | 给 preFile/doClean/getFiles/deletedFiles/saveGovernCon 补 `@RequestBody`;**新增 `POST /dm/preFileUpload`**(单文件 MultipartFile) | | `common/model/dm/dto/DeletedFilesDTO.java` | **新增**:`deletedFiles` 前端发 `{ids:[]}`,原签名是简单参数绑定不上 | | `DmService.java` | 新增上传落盘 + 调 PreTask 解析的方法;后缀白名单 xls/xlsx/csv;`filePath` 回填服务端绝对路径、`fileSize` 回填真实大小 | | `StateManager.java` | 新增 `isCaseOpened()`(避免在业务代码里硬编码哨兵值 `getCaseId()==888888`) | **注意**:`getCaseId()` 返回 `Integer`,**未开案时是哨兵值 888888 而非 null**。 ### 前端改动(ai-frontend) | 文件 | 改动 | | --- | --- | | `case/api/govern/governApi.ts` | 新增 `preFileUpload(file, onProgress, data)`,走 `defHttp.uploadFile`(底层 axios,可传 `onUploadProgress`) | | `case/views/data/index.vue` | 上传弹窗整体重写(见下) | `index.vue` 具体改法: - **删掉全部 Electron 依赖**:`extractUploadFilePath` / `extractUploadFolderPath`(读 `window.electron.getPathForFile`)、 `handleSelectFile`(`ipcRenderer.invoke` 选文件夹)、`beforeUpload` / `beforeUploadFolder`、 `processUploadFilePaths` / `queueUploadFilePaths` / `normalizeUploadPaths` / `pendingUploadPaths` / `uploadBatchTimer` / `selecting` / `uploadProcessing`。 - 把 `Upload.Dragger` 换成**原生拖拽区**(`@dragover/@dragleave/@drop`)+ 两个隐藏 input (`[multiple][accept=.xls,.xlsx,.csv]` 与 `[webkitdirectory]`)。 - 文件夹:选择走 `webkitdirectory`;**拖拽走 `webkitGetAsEntry()` 递归遍历** `createReader().readEntries()` (注意 readEntries 每次只返回最多 100 条,要循环读到空为止)。 - 后缀校验 `isValidUploadFile`,不合规的过滤掉并提示条数。 - 进度:`uploadProgressMap`(单文件 %)+ `overallProgress`(按 (各文件进度和)/总数 计算)+ `uploadStatuses`(pending/uploading/done/error)。 - **逐文件串行调用** `preFileUpload`,累加返回的 `FileInfo[]`,全部结束后 `sessionStorage.setItem('case_clean_file_infos', ...)` → `emitter.emit(CASE_IMPORT_FILE_REFRESH_EVENT)` → `go(PageEnum.BASE_MATCH)` —— **下游(结果页/匹配页)完全没动**。 ### 验证结果 - 后端 `mvn compile` + `mvn package -DskipTests` **均 BUILD SUCCESS** - 前端 `pnpm type:check`:`index.vue` / `governApi.ts` **无新增错误** (残留 3 处是既有:`CheckedType`×2、`Type instantiation`×1) - 前端 ESLint(`--max-warnings 0`)**0 错误**(prettier/vue 格式问题已用 `--fix` 修掉) - 测试进程已全部清理(端口 8980 释放,无 java / 无头 Chrome 残留) ### 本次踩到的环境坑(复用价值高) 1. **Git Bash 后台跑 `spring-boot:run` 会随 shell 退出被杀**:日志显示已到 `Tomcat started on port 8980` + `Started App` + `ACCEPTING_TRAFFIC`,但 `netstat` 查不到、 进程也没了。**改用 `mvn package -DskipTests` 打 jar 后 `java -jar` 启动**即可正常监听 (但同样要避免后台被杀,测试完及时清理)。 2. `mvn` 在本机 Git Bash 下直接用会报 `找不到或无法加载主类 org.codehaus.plexus.classworlds.launcher.Launcher` → 必须用 `bash /c/Users/cc/AppData/Local/Temp/cptest/mvn-run.sh`(它显式传 classworlds jar 与 `-Dmaven.home`)。 3. `pip install openpyxl` 装到了系统 Python,但 `python` 命令走的是另一个解释器 → 用 **`python -m pip install`** 才装到当前 `python` 上。