2026-09-17.md 18 KB

2026-09-17

★★ 全局接口返回空白:LicenseFilter 空壳过滤器吞掉所有请求(已修)

用户报「GET /sys/health 返回内容空白」。排查后发现不是单个接口的问题,是全站接口都返回空响应。

现象与实测(关键证据)

请求 实测响应
GET /js/a/sys/health 200 + Content-Length: 0,无 Content-Type
GET /js/a/sys/info / /js/a/sys/code / /js/a/agent/model/list 同上
GET /js/a/definitely-not-exist-xyz(不存在的路径) 同样是 200 + Content-Length: 0 ← 决定性证据
GET /sys/health(漏了 context-path) 正常 404(Tomcat 层,未进 context)

「不存在的路径也不返回 404」说明请求根本没进 DispatcherServlet,被 Servlet 过滤器链截断了。 Controller 里 Result.succeed("dsfsd") 从头到尾没被执行。

根因

ai-server/src/main/java/com/zsjz/ai/common/config/LicenseFilter.java(2026-09-16 commit 6114881 新增):

@Component                       // ← Spring Boot 自动注册为全局 Filter,URL 规则默认 /*
public class LicenseFilter implements Filter {
    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) {
        // 方法体为空:没有 chain.doFilter(req, res)
    }
}

两个因素叠加:@Component + implements jakarta.servlet.Filter → 全局注册;doFilter 不放行 → 链终止, Tomcat 直接提交空 200。日志无任何异常,所以从日志侧完全看不出来。

该文件是 Solon → Spring Boot 迁移的残留:真正的授权逻辑整段被注释掉,用的还是 Solon API (Context、ctx.pathNew()、ctx.outputAsJson()、R.failure()),这些在 Spring Boot 下不存在, 所以只能注释、留了个空壳,没意识到空壳的副作用。IGNORE_PATH 里的 /sys/health 白名单也因此失去作用。

修复(用户选定:透传但保留文件)

LicenseFilter.doFilter 补上 chain.doFilter(request, response);类头补了长注释说明踩坑点与 「@Component + Filter 必须放行」的硬约束;IGNORE_PATH 保留并标注「供将来恢复全局拦截时使用」。

排查手法(可复用)

  1. curl -s -i 看是否有 Content-Type —— 正常 Spring MVC 响应一定有;200 + Content-Length: 0 + 无 Content-Type 是「被过滤器吞掉」的指纹。
  2. 请求一个不存在的路径:返回 404 = 已进 Spring;返回 200 空 = 卡在过滤器链。
  3. 全仓 grep implements Filter|OncePerRequestFilter|FilterRegistrationBean 找可疑过滤器(本项目只有这 1 个)。

环境备忘

  • 本机后端正在运行(监听 8980,PID 16376),且运行的是改动前的构建 → 验证修复必须重启。
  • curl 记得加 --noproxy '*'(否则走沙箱代理拿到假象);上下文路径是 /js/a。
  • 用户自行重启验证,未做运行期实测。

相关

  • skill qingjian-ai-dev 已新增该陷阱(§9(h) 第 5 条是同一接口 /sys/health 的另一个原因: @Controller 无 @ResponseBody 导致 404 转发,两者别混淆)。

★★ 手动清洗后 tableRuleList 的 zf 字段被丢弃(2026-09-17,已定位)

用户报:上传页点「手动清洗」,带过去的数据缺少 zf 字段;而上传页表格的接口是返回了的。

实测证据(真机调接口,不是推断)

开案:POST /js/a/case/open body {"id":2,"pwd":"123456"}(案件 2 密码就是 123456)。 上传:POST /js/a/dm/preFileUpload?batchId=999000111222&reClean=1(multipart,file 字段; 带 reClean=1 跳过 MD5 去重,否则重复文件返回 data:null)。 响应:data.children[0].tableRuleList 30 条规则,30 条都带 zf 键(本例全为 0), 其中 MONEY 字段有 3 个:transAmount / transBalance / otherBalance(zf 均 0)。 → 用户说的「上传接口返回了 zf」属实。

根因:cleaning.vue 的 buildTableRuleListFromState 只给「交易金额」写 zf

ai-frontend/src/case/views/data/cleaning.vue:3782:

const zf = isTransAmountTargetField(field) ? (recognitionAmountZfEnabled.value ? 1 : 0) : undefined;
  • 非「交易金额」字段 → zf: undefined → JSON.stringify 直接把键丢掉;
  • 同函数里 required/matched/matchedRequired/directionConf 都走 pickFirstDefinedRuleFlagValue(模板字段值, 上一条规则值, 兜底) 的三级回退, 只有 zf 是异类:既不读 previous.zf 也不读 templateFieldRecord.zf。

触发时机(很关键):进清洗页即触发 —— loadFileTree() 选中首个节点 → switchNodeState() → finally 里 syncActiveNodeStructure() → syncNodeStructure() → node.tableRuleList = buildTableRuleListFromState(...) + persistFileListToSessionStorage() (cleaning.vue:3863 / 3006 / 3057)。所以一进清洗页,case_clean_file_infos 就被重写, 首个 sheet 的非交易金额规则 zf 全部消失。切节点/点保存同理。

影响:后端会 NPE(不只是少个字段)

ai-server/.../clean/impl/cleaner/ 里 4 处(TransStdDataCleaner:71、TransStdDataCleaner1:79、 TransInOutDataCleaner2:74、TransInOutDataCleaner5:70)都是:

switch (rule.getFieldType()) {
    case MONEY -> { ...; if (rule.getZf() == 1) { ... } }   // Integer 与 int 比较 → 自动拆箱
}

getZf() 返回 Integer,缺失时是 null → 自动拆箱 NPE。 而 AbstractDataCleaner:84 的 tableRules 就是 sheet.getTableRuleList() 过滤 fileColIndex != null, 即前端发什么就判什么。所以 transBalance / otherBalance 这类 MONEY 字段只要被映射了、 zf 又被前端抹掉,清洗就会抛 NPE(或 正负转换 静默失效)。

修复(用户选定:前端 + 后端都改;交易金额保持「开关优先」)

前端 cleaning.vue:

  • 新增 resolveStoredZfFlag(templateField, previous):模板字段 zf → 上一条规则 zf → 兜底 0, 保证始终返回整数;
  • getRawRuleFlagValue 的 key 联合类型加 'zf';
  • buildTableRuleListFromState 里 const zf = ... 改为 「交易金额 → recognitionAmountZfEnabled ? 1 : 0;其余 → resolveStoredZfFlag(templateFieldRecord, previous)」, 并把该声明下移到 templateFieldRecord 之后(原来在它之前,拿不到模板字段)。

后端:AbstractDataCleaner 新增 protected static boolean isZfEnabled(TableRuleDTO rule)(Integer.valueOf(1).equals(rule.getZf())), TransStdDataCleaner:71、TransStdDataCleaner1:79、TransInOutDataCleaner2:74、TransInOutDataCleaner5:70 4 处 if (rule.getZf() == 1) → if (isZfEnabled(rule))。

验证(都做了)

  • 后端 mvn -pl ai-server -B compile → BUILD SUCCESS
  • 前端 eslint cleaning.vue --max-warnings 0 → 0 错误(先有一处 prettier 折行报错,已手工改单行)
  • vue-tsc --noEmit → cleaning.vue 0 错误,全仓仍 103(与既有基线一致,无新增)
  • 用真实上传响应跑前后对比脚本(C:/Users/cc/AppData/Local/Temp/zfcheck.cjs): 修复前 roundTrip 后 0/30 条带 zf(3 个 MONEY 字段全丢);修复后 30/30 条带 zf、 全为整数、再重建一次结果完全一致(幂等)。 ★ 脚本第一版写错过:{zf: undefined} 的对象自身仍有 zf 键,必须补 JSON.parse(JSON.stringify(x)) 这一步才能复现丢键,否则会误判"没丢"。

同类地雷批量清理(2026-09-17,用户说"继续"后执行)

AbstractDataCleaner 抽出三个 helper:isFlagOn(Integer)(通用 0/1 判断)、 isZfEnabled(TableRuleDTO)、isRequired(TableRuleDTO),后两者都走 isFlagOn。

  • 29 个清洗器 / 58 处 rule.getRequired() == 1 → isRequired(rule)(用 Node 脚本按精确子串替换, 逐文件断言命中数=2、替换后长度 = 原长 − 命中数×(FROM.length−TO.length),确保除目标子串外零改动)
  • GlobalCache:204 field.getMatched() == 1(TableField.matched 是 Integer)→ Integer.valueOf(1).equals(...)
  • FunRadix:33 getRadixType() == 0(Integer,默认 1)→ Integer.valueOf(0).equals(...),null 走 else 与默认值 1 语义一致
  • RagSchemaService:263/277 已有 != null && 前置判断,安全,刻意未动
  • 验证:mvn -pl ai-server -B clean compile → BUILD SUCCESS(622 源文件); 残留 getRequired() == 1 仅剩 AbstractDataCleaner 的 javadoc 示例; CRLF 保留(tr -cd '\r' | wc -c == wc -l,抽查 3 个文件)

遗留(未改,已报用户,属别的模块)

全仓还有一批「可空 Integer 直接 == 数字」且无 null 前置判断的写法: CaseDataCache:55/57(PersonLibNo.libType)、SpecialDateService:37/69、IntimacyService:119、 TrackExpressInfoService:72/82、TransFundFlowService:368/434/499/563(TransFlowGraphQuery.level)、 DmService:748、AgentChatServiceImpl:214。这些字段在各自业务里大多必填,属潜伏风险, 动之前要先确认字段是否真会为 null。 已带 != null && 前置判断的是安全的:DataProfileService:225、TransRecordService:87/140、 TableInfoController:34/35、DmService:287/380/597、TrackTogetherLiveService:165。 ★ 排查手法:grep -rn "get[A-Za-z0-9_]*() *[!=]= *[0-9]" src/main/java --include=*.java 再回查字段声明类型。

结论

前端应保证每条规则都带整数 zf(非交易金额字段沿用上传接口/模板的值,兜底 0), 而不是只给交易金额写、其余置 undefined。已按「前端 + 后端都改」落地。

复用要点

  • table_field.zf(平台库,etl-2 / zsjz-ai 都有)语义 = 「开启正负转换」,只配在 transAmount / MONEY 字段上(实测 16 行 zf=1,field_name_en 全是 transAmount)。
  • PreDataListener.getTableRuleList()(line 400-440)里 zf 取自主模板字段 (entityToDto(mainTable)),而 fieldNameCn/directionConf/matched/required 会被子模板同名字段覆盖 —— zf 不在覆盖列表里,这是个潜在不一致点。

清洗流程三个页面统一为「独立全屏路由」(不套 LAYOUT)

背景

上传 → 手动清洗 → 清洗导入是同一条流程。此前只有 upload.vue 是独立全屏路由, cleaning.vue / cleanProgress.vue 挂在 DataRoute(component: LAYOUT)下, 进流程后菜单/页签会重新冒出来,与上传页割裂。

改动 1:ai-frontend/src/core/router/routes/index.ts

  • 从 DataRoute.children 删除 cleaning、cleanProgress 两个子路由(连同 currentActiveMenu)
  • 仿 CaseUploadRoute 新增 CaseCleaningRoute / CaseCleanProgressRoute, path 用 PageEnum.BASE_CLEANING(/data/cleaning) / BASE_CLEAN_PROGRESS(/data/cleanProgress), meta 保留 title + hideMenu: true + hideTab: true
  • 两者加进 basicRoutes(放在 CaseUploadRoute 之后、DataRoute 之前)
  • 路由 name 未变(CaseCleaning / CaseCleanProgress),全仓无其它引用

改动 2:两个页面的根样式(脱离 LAYOUT 必须改,否则高度错)

  • cleaning.vue:height/max-height: calc(100vh - 100px) → 100vh。 那个 100px 是布局 header + tabs 的高度,脱离 LAYOUT 后不存在,扣掉会平白少一截。
  • cleanProgress.vue:min-height/height: 100% → 100vh。 父级不再是有确定高度的布局内容容器,100% 会退化成 auto,页面塌成内容高度。
  • 两页背景由 #fff 改为 #f0f2f5(与 upload.vue 一致)。因为页内面板本身是 「白底 + 边框 + 阴影」的卡片(.left-panel/.middle-panel/.right-panel、.progress-card), 白底铺满会糊成一片;灰底才能让卡片浮起来。
  • 两页都没有暗色主题样式块(upload.vue 有),保持原样未补。

关键判断依据(后续改路由前先看这些)

  • basicRoutes 是直接传给 createRouter({ routes: basicRoutes }) 的静态路由, 所以独立全屏路由刷新/直链也能命中,不依赖 permissionGuard 的动态注入。
  • hideMenu 的过滤在 core/router/helper/menuHelper.ts:41(if (node.meta.hideMenu) return;), 与是否套 LAYOUT 无关,删掉 currentActiveMenu 无副作用。
  • core/store/modules/multipleTab.ts:32 的 HIDDEN_FLOW_TAB_PATHS 按 path 判断, 不依赖路由层级,无需同步修改;hideTab: true 也让 addTab 直接 early-return。
  • LAYOUT 内页面原高度参考:.jeesite-layout-content 有 padding: 12px 12px 0 且高度是 JS 算的 (useContentViewHeight),所以布局内页面用 calc(100vh - 100px),全屏页必须用 100vh。
  • upload.vue 的 onBeforeRouteLeave 靠 to.path 比对 BASE_CLEANING/BASE_CLEAN_PROGRESS 决定是否保留 sessionStorage 缓存 —— 路径没变,逻辑不受影响。

验证(全部实测通过)

  • eslint --max-warnings 0 三个改动文件 → 0 错误
  • vue-tsc --noEmit --skipLibCheck → 全仓 103(与基线一致),三个改动文件 0 错误
  • UI 级验证(%TEMP%/ai-verify/ui-route-flow2.cjs + ui-route-flow3.cjs,用 playwright-core 复用 agent-browser 下载的 chrome,1680×1000 视口):
    • 深链直开 /data/cleaning、/data/cleanProgress、/data/upload → .jeesite-default-layout / .jeesite-layout-content / .ant-layout-sider 全为 0, 页面根 getBoundingClientRect().height == window.innerHeight,背景 rgb(240,242,245);
    • 对照组深链直开 /data/index → 三者均为 1(有布局),证明判定方法有效;
    • 真实点击:/data/index 点「选择文件」→ /data/upload?mode=single(无布局、1000px 撑满) → 点「返回」→ /data/index(布局恢复、上传页已卸载);
    • 全程 0 条控制台错误、无 4xx/5xx。
  • ★ 踩坑:agent-browser 守护进程在本机起不来(详见技能文档 §9.5(f)), 应直接用 playwright-core;我一开始没看技能文档,白试了三轮。

环境备忘(本次新得)

  • 后端 context-path 是 /js/a,健康检查完整地址 = http://127.0.0.1:8980/js/a/sys/health → {"code":200,"message":"","data":null}。直接访问 /sys/health 会 404,别误判后端没起来。
  • 前端 dev server 在 3100。
  • ★ 路由模式分环境:routeHelper.ts:151 按 VITE_ROUTE_WEB_HISTORY 决定 —— .env.development = true(history,真实 URL 形如 http://localhost:3100/data/cleaning), .env.production = false(hash),.env.tomcat = true。 所以本地调试用不带 # 的路径;写验证脚本时改 location.hash 是无效的(history 模式不响应), 必须用 page.goto(BASE + '/data/xxx') 或真实点击。
  • 案件数据页 /data/index 的「导入数据 / 导入文件」是 页签(button.import-tabs__item), 真正的上传入口是右上角 「选择文件」按钮(handleUploadNavigate('single') → /data/upload?mode=single)。

模型管理页:对话/向量分类 Tab + 各自默认(2026-09-17)

需求:模型管理页按用途类型分 Tab,且 llm 与 embedding 各自维护一个默认模型。

后端(编译通过 + 27 项纯逻辑断言通过):

  • 新增 common/enums/ModelTypeEnum(LLM="llm" / EMBEDDING="embedding",fromCode 把 null/未知值归一为 LLM)。
  • AgentModelServiceImpl#setDefaultModel 由「清空全表默认」改为只清同类型默认; createModel / updateModel(defaultModel=true) 同样走 clearDefaultOfType,避免同类型多个默认。
  • getDefaultModelId() 语义收紧为默认对话模型(type=llm,含 type IS NULL 历史行); 新增 getDefaultModelId(String type)。两个 wrapper 的构建抽成包级静态方法 buildClearDefaultWrapper / buildDefaultQueryWrapper,便于脱离 Spring/DB 断言。
  • EmbeddingModelFactory.resolveDefault() 改用枚举常量 + 次级排序(create_at DESC)保证确定性。
  • ★ 验证手法:dependency:build-classpath -Dmdep.outputFile=... 导出依赖 → 用 target/classes + 该 classpath 跑 main → TableInfoHelper.initTableInfo(assistant, AgentModel.class) 初始化后打印 wrapper.getCustomSqlSegment(),断言 and/or 括号组合。不用起 Spring、不用连库。

前端:

  • ai/views/aiModel/index.vue:a-tabs 两个页签(对话模型 / 向量模型 + 数量角标),表格按 tab 过滤; 去掉冗余「类型」列;「设为默认」不再拦截 embedding(类型已隔离),已是默认时置灰为「已是默认」; summary 同时显示两类默认;新增按钮文案跟随当前 tab。
  • ai/components/ModelFormModal.vue:新增 defaultType prop(新增时类型跟随当前 tab); 默认开关文案按类型区分,并提示「同类只能有一个默认」。
  • UI 级验证(playwright-core):tabs=["对话模型2","向量模型1"]、列名无「类型」、 默认角标「默认对话」在「本地模型」上、弹窗类型默认「向量模型」、0 控制台错误 / 0 个 4xx。
  • ⚠️ 当时后端进程(PID 6980)跑的是改动前的代码,需重启后端类型隔离才生效(本次未擅自重启)。
  • ⚠️ 后端常驻进程是 IDEA 以 Debug 模式启动的(jps 可见 com.zsjz.ai.App + -agentlib:jdwp
    • IDEA captureAgent),不要杀:会丢进程内 DuckDB 案件状态、打断调试会话。改完后端代码需用户在 IDEA 里重启。
  • ★ 不重启后端的验证手法:JDBC 连库 → setAutoCommit(false) → 跑与代码等价的 SQL → 断言 → rollback()。 本次据此在真实 agent_model 上验证默认模型类型隔离(10 项全过,含"旧逻辑会误清另一类型"的对照),数据零变更。