stability-usability-report.md 33 KB

稳定性与易用性分析与优化报告(ai-server)

审计日期:2026-09-30 范围:ai-server/src/main/java(约 860 文件、53 个 Controller、216 个端点)+ 配置 + 文档 方法:3 轮全仓扫描(容错与资源管理 / 异常处理与可观测性 / API 易用性与开发者体验),Top 发现经人工源码核实。 前置说明:并发问题已在 concurrency-issues-and-fixes.md 闭环(29 项,27 项已修复),本报告不含并发类问题。 结论计数:稳定性 17 项(高 3 / 中 8 / 低 6)| 可观测性 12 项(高 3 / 中 6 / 低 3)| 易用性 22 项(高 4 / 中高 3 / 中 8 / 低 7)。


0.1 修复实施记录(2026-09-30,用户指定三项)

本报告中以下三项已实施并测试通过(Top10 表中 #1、#5、#10 的稳定性部分):

项 实施 改动
O-1 PII 日志 已修复 AbstractDataCleaner:提取结果日志改为脱敏输出(新增 maskForLog:保留前4后2、短值留首字符),"预处理后文本"整段内容不再打印(只留长度,降 debug);GraphService 的 System.out.println(sql)(含手机号/卡号节点值)移除;删除整文件注释的死代码 GraphServicecopy.java(613 行,内含同类打印)
S-2 ES 写入主链路 已修复 SearchDocIndexService:add()/flush() 全部容错化(ES 故障记日志+丢弃计数,绝不上抛清洗链路);新增熔断——连续 3 批失败后熔断 60s,期间 add() 直接跳过(不再每批承受 60s 超时),成功即复位。缺失数据按案件重建索引补偿。新增 SearchDocIndexServiceTest(4 用例:不上抛/熔断开/成功复位/flush 降级)
S-4 附件 RAG 已修复 ① ChatAttachmentService 路径 C 检索失败降级为"无 RAG 片段的普通回答"(fail-open,不阻断整条消息);② AttachmentDocIndexer.index() 增加单附件总预算 zsjz.chat-attachment.index-budget-seconds(默认 600s),超时后剩余 chunk 跳过并置 truncated,杜绝上传请求被占住几十分钟。新增 AttachmentDocIndexerTest(3 用例)

新增测试 7 个全部通过;全量 370 个测试仅剩 HEAD 既有的 8 个过时断言(工具注册数,与本轮无关),零新增回归。


0. 结论摘要

一句话:系统的"骨架"是健康的(异常处理框架、流式解析、AI 清洗看门狗、LLM 失败语义都是样板级),但存在三类系统性风险——

  1. 稳定性:外部依赖 failure mode 不对等。直连 LLM 有超时重试、ES 查询侧 fail-open,但意图识别 .block() 无超时(配置项存在却没接线,挂死会永久卡住会话锁)、ES 在清洗写入主链路上无任何容错(ES 挂 = 文件级清洗失败 + 半截数据)、对话主流无应用层超时。这三处都是"好的模式已经存在,只是没铺到位"。
  2. 稳定性:静默失败仍然存在。治理"创建时序图"吞掉 SQLException 还向用户报完成;预处理整批失败返回空列表(精心设计的报错被自己的 catch 吞掉);file_ai_profile 的 PENDING 态无看门狗,重启后永久卡死;dm 清洗与治理没有 ai_clean 那样的重启自愈,且全项目无优雅停机配置。
  3. 合规级问题:测绘核心 PII(姓名/卡号/证件号)逐条打进 info 日志,且 logback root=debug 仅控制台输出、无文件无滚动——量大、无档、敏感。这是全报告优先级最高的一条。

易用性侧的系统性问题只有一个:新模块(agent/insight/aiclean)与旧模块像两代人所写——前者 REST + VO + @Valid + 测试,后者 RPC 动词 + 实体直出 + 零校验;同一语义"删除"有 5 种接口形状、分页 4 种形状、"每页条数"3 个名字。逐个端点修补无意义,需要一次契约层统一。

Top 10 行动项(按性价比排序):

# 问题 严重度 类型 工作量
1 PII 逐条进 info 日志 + root=debug 无文件日志 高 合规/稳定 0.5 天
2 意图识别 .block() 无超时(配置项未接线)→ 卡死会话锁 高 稳定 0.5 天
3 治理 createTimeSeries 吞 SQLException 还报完成 高 稳定 0.5 天
4 预处理整批失败吞 ServerException 返回空列表 高 稳定 0.5 天
5 ES 移出清洗写入主链路(旁路化 + 失败降级) 高 稳定 1-2 天
6 SheetProbe POI 全量加载改 SAX(OOM 风险) 中高 稳定 1 天
7 dm 清洗/govern 重启自愈(对齐 ai_clean 看门狗)+ 优雅停机 中高 稳定 2 天
8 file_ai_profile PENDING 态补看门狗 中 稳定 0.5 天
9 SSE 断线丢进度:事件落缓冲支持回放(或 /dm、/govern 补轮询通道) 中高 可观测 1-2 天
10 API 契约统一(错误码枚举、统一分页 VO、写操作改 POST) 中高 易用 渐进

1. 稳定性

1.1 外部调用容错

S-1(高)意图识别 LLM 调用无超时——超时配置项存在但从未接线

  • 位置:module/agent/intent/IntentService.java:84
  • 现状:agent.call(userMsg, IntentResult.class).block() 无任何 timeout。而 IntentProperties.java:17 定义了 timeoutSeconds = 5(注释写明"超时即降级为原始问题直发"),但 IntentService/IntentMiddleware 全程未引用该配置——配置是死的。对照:FollowupService 有 blockLast(Duration.ofSeconds(8)) + 全 catch(FollowupService.java:85-91),Intent 漏了同款。
  • 触发:模型端点半开连接(TCP 建立但不吐数据不 RST)——反代黑洞、厂商网关 hang。
  • 后果:IntentMiddleware.onAgent 在主对话 Flux 管线内同步调用;fail-open 只覆盖"抛异常",不覆盖"永不返回"——整条 SSE 无任何输出,且 chat 会话锁在 doFinally 释放,流不终结 → 该会话此后永远收到"正在回复中"。
  • 修复:.block(Duration.ofSeconds(props.getTimeoutSeconds())) + TimeoutException 并入 fail-open 分支;补一条单测覆盖"配置超时生效"。

S-2(高)Elasticsearch 在批量清洗写入主链路上,无容错——ES 挂 = 文件级清洗失败 + 半截数据

  • 位置:module/dm/clean/AbstractCallRecordDataLoader.java:142(AbstractTransRecordDataLoader.java:107 及各 External*Loader 同型);写入服务 module/search/service/SearchDocIndexService.java:79-95
  • 现状:每个 loader 的 doWrite 逐行调 searchDocIndexService.add(...),无 try/catch;add 攒满 1000 条时同步 repository.saveAll(batch) 直连 ES。异常沿链路抛到 CleanTask.java:108-119 → 文件标 FILE_READ_FAIL。
  • 后果:清洗过程中 ES 宕机/滚动重启 → 该文件清洗中断,DuckDB 业务表留下前 N 行半截数据,多用户大批量导入时 ES 单点拖垮数据管理主链路。讽刺的是查询侧反而是 fail-open 的(SearchQueryService.java:75-78 返回空列表)——写入侧比查询侧脆。ES 假死时 socket-timeout 60s(application-dev.yaml:27-28),每批先挂 60s。失败后 SearchDocIndexService.buffers 里该案件尾批(≤999 条)滞留内存。
  • 修复:全文检索定位为旁路能力——add() 内部 catch(ES 失败只 log + 计数,绝不上抛清洗链路),另加"ES 连续失败 N 批后熔断 60s"防止 60s 超时反复拖慢清洗;搜索缺失数据靠文件级重建索引补偿(/search 已有按案件删除重建的底座)。

S-3(中高)对话主流无整体超时、无应用层心跳

  • 位置:module/agent/service/impl/AgentChatServiceImpl.java(doStreamLocked 的 agent.streamEvents 无 timeout 操作符);AgentModelFactory.create(未设置连接/读超时);chat SSE 无心跳(SseService 的 5s 心跳只覆盖清洗/治理通道)。
  • 后果:模型流建连后长时间不发首事件 → SSE 挂住无 error 帧(onErrorResume 只接"抛异常"),会话锁到客户端断连才释放;经代理部署时空闲连接被掐,前端表现"永久转圈后断开"。
  • 修复:events.timeout(Duration.ofMinutes(2), fallbackError)(首事件超时)+ 给 chat SSE 加与清洗通道同款心跳帧。

S-4(中)附件大文档 RAG:检索失败阻断整条消息;上传同步索引无总预算

  • 位置:module/agent/attachment/ChatAttachmentService.java:290(DOCUMENT 分支 docIndexer.search(...) 无 try/catch);AttachmentDocIndexer.java:96-153(500 chunk × 每块 60s 超时,无总预算)。
  • 后果:嵌入模型 API 故障时,用户对带大文档附件的会话提问整条消息失败,而不是降级为"不带 RAG 片段的普通回答";上传侧最坏 500/8×60s ≈ 62 分钟占住 Tomcat 请求线程。
  • 修复:① buildBlocks 的 DOCUMENT 分支整体 try/catch,失败 log.warn 后返回空 RAG 块继续对话(对齐 ES 查询侧的 fail-open);② 上传索引改为后台虚拟线程任务 + indexStatus 轮询(上传请求立即返回),或至少加总预算(如 10 分钟)超时。

S-5(中·已知决策)Redis 是全站认证硬依赖,无降级

  • 位置:application-dev.yaml:19-22;application.yaml:53-54 注释自认"Redis 不可用则所有 StpUtil.* 抛异常,无降级"。
  • 后果:Redis 抖动 → 全站请求 500(且不是 NotLoginException,落不到 401 处理,用户看到"系统异常")。这是注释里的显式架构选择,如实记录;建议至少做一件事:GlobalExceptionHandler 对 org.springframework.data.redis.RedisConnectionFailureException 给出专属文案"认证服务暂不可用,请稍后重试",把 500 变成可理解的提示。

1.2 资源与内存

S-6(中高·OOM 风险)SheetProbe 用 POI usermodel 全量加载 workbook——全链路唯一的全量内存点

  • 位置:module/aiclean/probe/SheetProbe.java:62(WorkbookFactory.create(file),文件上限 80MB)
  • 现状:80MB xlsx 解压后 XML 数百 MB,POI DOM 模式内存放大 20-50 倍;AI 清洗判定并发闸门默认 3 → 最坏 3 份 usermodel 同时在堆上。主链路预览/清洗都是 Fesod 流式 + DuckDBAppender,唯独这条探测路径不是。
  • 修复:探测只需要前 200 行 → 改 POI SAX(XSSFSheetXMLHandler)或复用 Fesod 流式读,堆占用从数百 MB 降到 KB 级。

S-7(中)上传源文件目录无 TTL/孤儿清扫,磁盘增长无上界

  • 位置:DmService.java(saveUploadedFile 落 workspace/{caseId}/upload/{batchId}/;sweepStaleTmpBatches 只扫 PathConst.TMP_PATH;clearUploadBatch 仅用户手动触发)。
  • 场景:上传后不点清洗就关浏览器 → 200MB×N 源文件永久留存。tmp 批次有 24h TTL、chat 附件有 7 天孤儿清扫,唯独 DM 上传目录没有。
  • 修复:与"重新清洗需要源文件"的语义协调——清扫"无关联 file_info 活动批次且超过 7 天"的 upload 目录(对齐附件孤儿清扫的条件删除范式)。

S-8(低)PG 主库 Hikari 零调优

  • 位置:application-dev.yaml(无 hikari 节,默认 maximum-pool-size=10),被请求线程 + boundedElastic 异步落库 + GlobalCache.initCache 全表读共享。
  • 修复:显式配置(如 maximum-pool-size=20、connection-timeout=10s)并压测;顺带给 MyBatis 加慢 SQL 日志(见 O-7)。

S-9(低)graph_his 图谱历史 JSON 只增不清(GraphService.java:632);SearchQueryService.java:62 leading-wildcard 查询仅性能提示。

1.3 停机、自愈与状态机

S-10(中高)无优雅停机 + dm 清洗/govern 无重启自愈

  • 位置:application.yaml 无 server.shutdown: graceful / spring.lifecycle.timeout-per-shutdown-phase(Boot 3 默认 IMMEDIATE);AppStopEndEventListener 停机只 flush RocksDB。
  • 后果:SIGTERM/kill -9 时 dm 清洗与治理(单阶段 10-40 分钟)被硬杀:DuckDB 留部分行、govern 的 call_record 可能 truncate 后未重算完。ai_clean 有看门狗启动恢复(好的),dm 清洗与治理没有——FileSheetStateEnum 没有"清洗中/治理中"中间态,重启后前端看不出"上次没跑完",全靠用户记得重跑。AI 回复落库在停机窗口静默丢。
  • 修复:① yaml 加 graceful shutdown + 30s timeout;② FileSheetStateEnum 增加 CLEANING/GOVERNING 中间态,启动时把中间态标为 FAIL(原因:服务重启中断)——对齐 AiCleanWatchdog 的启动 sweep 范式,用户可一键重跑。

S-11(中)file_ai_profile 的 PENDING/RUNNING 态无看门狗,重启后永久卡死

  • 位置:FileRecognitionService(内存队列无启动重扫);FileAiProfileService.java:142(建档即 PENDING)。
  • 后果:上传 docx/pdf 落 PENDING → 服务重启 → 档案永远"待识别",前端轮询等不到,唯一恢复方式是删文件重传。这是状态机里唯一没被看门狗覆盖的态(ai_clean 有、清洗失败有 FAIL 态)。
  • 修复:启动时(或并入现有看门狗)把 PENDING/RUNNING 且 update_time 超过 30 分钟的档案标 FAIL(原因"服务重启中断,请删除后重新上传")。

S-12(中)清洗完成回调链路非原子:initCache 失败会跳过人员库落库

  • 位置:DmService.java createDefaultCallback(GlobalCache.initCache() 在 drain/flush 之前,且无 try/catch)。
  • 后果:全部 sheet 洗完的瞬间 PG 抖动 → initCache 抛异常 → 本批人员缓冲不落库不清空、SSE 停摆。
  • 修复:调整顺序为"先 drain+flush(业务数据落库优先)→ 再 initCache",且 initCache 失败只告警不阻断回调其余步骤。

S-13(高)治理"创建时序图"吞 SQLException,失败伪装成成功

  • 位置:GovernService.java createTimeSeries:catch (SQLException e) 只 log.error("数据库执行错误: {}", e.getMessage())(无堆栈),随后照样 sendProgress("创建时序图完成!")——流水线继续、最终报"全部执行完成"。
  • 后果:时序图数据静默缺失,用户拿到"治理成功"的错误结论。这是治理链路 fail-fast 改造后漏网的最后一处(与 awaitStage 的修复形成对照)。
  • 修复:抛出让流水线失败中断(对齐其他阶段),SSE 推"创建时序图失败:…";日志带堆栈。

2. 可观测性(异常处理 / 日志 / 健康 / 指标 / 进度)

2.1 总体判断

GlobalExceptionHandler(common/exception)是样板级的好:15 类异常全覆盖、业务异常 cause 链拆包(救回被 MyBatisSystemException 包住的"请先打开案件")、兜底 500 不泄堆栈且服务端留全栈、401 按前端约定返回 HTTP 200+code 401 且写明理由。问题都在"框架没铺到的地方"。

2.2 问题清单

O-1(高·合规)测绘核心 PII 逐条打进 info 日志

  • 位置:module/dm/clean/AbstractDataCleaner.java:211(log.info("预处理后文本:{}", cleanText)——整段原文含开户人姓名/卡号)、:244(log.info("数据提取完成,结果:姓名={}, 卡号={}, 证件号={}", ...));module/graph/service/GraphService.java:112(System.out.println(sql)——图谱查询 SQL 含手机号/卡号节点值,绕过 logback 直打 stdout);放大器:MyBatis log-impl: Slf4jImpl + root=debug 会把 WHERE 子句里的手机号/卡号字面量全量输出。
  • 后果:清洗是每文件必经链路,等于把案件核心敏感数据全量复制到日志流;无文件滚动则日志留存策略无从谈起。这是本报告合规优先级最高的一条。
  • 修复:两处改 debug + 掩码(卡号/证件号中间段打星);GraphService 改 log.debug 并删除 GraphServicecopy.java(613 行死代码文件);见 O-2 调 root。

O-2(高·运维)logback:root=debug、仅 CONSOLE、无文件无滚动

  • 位置:src/main/resources/logback-spring.xml(全文 8 行)。
  • 后果:日志全靠 stdout 重定向;debug 级让 DuckDB/Reactor/Hikari/MyBatis 全量输出,量级放大;重启即丢,无从排障。
  • 修复:root=info + RollingFileAppender(按天+大小滚动、保留 30 天)+ 按包分级(com.zsjz=info, org.springframework=warn);用 <springProfile> 区分 dev(可 debug)/ prod。

O-3(高)SSE 流内异常把 ex.getMessage() 原样透传前端,未过兜底滤网

  • 位置:AgentChatServiceImpl.java:305-317、InsightServiceImpl.java:354-357(onErrorResume 直接 sse("error", Map.of("error", ex.getMessage())))。
  • 后果:SSE 流不经过 GlobalExceptionHandler——DataAccessException 等底层异常的 message(含 SQL 片段/表名/驱动细节)直接上屏;NPE 变成"未知错误"用户无从判断。
  • 修复:仿 GlobalExceptionHandler——unwrap 出 ServerException 用其 message,其余统一"对话处理失败,请稍后重试"并 log.error 带堆栈。注意与既有"错误文案可行动"的好例子共存(并发锁"该会话正在回复中"这类是 ServerException,会走 unwrap 保留)。

O-4(中高)预处理整批失败吞 ServerException 返回空列表——精心设计的用户报错被自己的 catch 吃掉

  • 位置:DmService.java:142-146(catch (Exception e) { log.error(...) } return List.of());同型 :459-462(resolvePreviewFiles,try 块内 :454 刻意抛出的"所有文件不存在或格式错误!"被吞)。
  • 后果:预处理批量链路整体失败时前端只显示 0 个文件,无任何提示。
  • 修复:catch 分支只兜底非业务异常——先 unwrap ServerException 上抛,其余异常返回空列表 + log。

O-5(中高)SSE 断线即丢进度,无回放;/dm 与 /govern 只有 SSE 一条进度通道

  • 位置:SseService.java:97-111(emitter 不存在直接跳过推送;事件 id 是随机串,无 Last-Event-ID 回放)。
  • 后果:治理 10-40 分钟长任务里,用户浏览器断网/休眠/代理踢线期间的所有进度帧永久丢失,重连后干等下一条心跳。
  • 修复(按成本递增):a) 最小改动——给 /dm 与 /govern 各补一个查询当前批次的轮询接口(对齐 /aiClean/smartClean/status 已有范式);b) 完整方案——SseService 按用户保留最近 N 条事件的环形缓冲,see() 带 Last-Event-ID 回放。

O-6(中)智能清洗轮询接口不暴露失败原因

  • 位置:AiSmartCleanService.SmartCleanStatusVO(返回 stage/计数/needReview,不含 lastError)。
  • 后果:看门狗与 run() 失败时精心写入 lastError("批次心跳超时…请重新发起智能清洗"),前端轮询不到,用户只见 FAILED 不知道为什么、该干什么。job 粒度 failReason 经 /aiClean/jobs 可查(好),批次级出口缺这一列。
  • 修复:SmartCleanStatusVO 补 lastError 字段(脱敏截断)。

O-7(中)无 traceId/MDC;GlobalExceptionHandler 兜底日志无请求上下文

  • 全仓 0 处 MDC;清洗/治理链路有"【数据治理】+caseId"前缀(好),但一次对话(sessionKey→agent→工具→落库)、一次智能清洗(batchId→jobId)跨多行无关联 ID;兜底"系统异常"日志只能靠线程名反推。
  • 修复:Servlet Filter 里 MDC.put("traceId", ...)(有 caseId/userId 也一并放入)+ logback pattern 输出;成本一天以内。

O-8(中)健康检查是假活:/sys/health 不探测任何依赖;无 Actuator

  • 位置:SystemController.java:47-50(return Result.succeed());pom.xml 无 actuator。
  • 后果:PG/Redis/ES/LLM 网关任何一个挂掉,/sys/health 依然 200,网关/前端就绪探测被误导。
  • 修复:升级 /sys/health 为依赖汇总自检(PG SELECT 1、Redis ping、ES exists、LLM 配置存在性,任一失败返回 code 非 0 + 各依赖状态明细);不引入 actuator 也够用,引入则加 micrometer 导出更好。

O-9(中)无指标、无慢 SQL 观测

  • 全仓无 Micrometer/自定义指标;LLM token 用量散落(ai_clean_job 表有 tokenIn/tokenOut——好;UsageStore 仅内存轮次);唯一查询耗时日志在被吞异常的 createTimeSeries 里;DuckDB 大 SQL(治理 30 分钟级语句)无耗时统计。
  • 修复:① 一个 MyBatis Interceptor 打印 >3s 的慢 SQL(带 caseId 上下文);② LLM 调用计数器(次数/token/耗时/失败)落一张 metrics 表或日志聚合,替代纯内存 UsageStore。

O-10(中)日志级别滥用污染信噪比

  • GlobalExceptionHandler 里 404/400 类客户端错误全用 log.error(:118-192 共 8 处)——污染 error 告警;GovernService.calcTreePersonData 循环级 info("根据卡号统计数据!")大案件成倍刷屏;IntentService.java:89 预期内的 fail-open 降级用 error(同文件 :141 用 warn,不一致)。
  • 修复:客户端错误降 warn;循环级 info 降 debug;降级分支统一 warn。

O-11(中)"进度条"是假进度 + 失败也推 100%

  • SlowDownProgress.java:按"越接近 100 越慢"的随机步进模拟,与真实完成量无关;叠加 GovernService 失败分支推 SseDTO(102, 100, "治理失败:…")——前端按"进度=100 即成功"渲染会误判。
  • 修复:失败事件的 progress 用当前值而非 100;前端协议增加显式 status: FAILED 字段(与文案解耦)。

O-12(低)错误码体系形同虚设

  • ErrorEnum 只有 9 个条目,全仓 new ServerException(400, ...) 裸 int 遍地(AgentChatServiceImpl 5 处、ChatAttachmentService、AiSmartCleanService 等),前端只能匹配 message 文本。
  • 修复:见 E-1(并入易用性契约治理)。

3. 易用性

3.1 API 设计一致性(53 Controller / 216 端点)

E-1(高)GET 做删除/写操作大面积存在(约 25 处)

  • 最危险:AgentChatController.java:126-129——GET /chat/messages/{messageId} 实际是删除消息,URL 无 delete 字样、返回 void,浏览器预取/curl 即删数据。其余:GET /chat/sessions/{id}/delete、/title(改标题)、/pin、/messages/{id}/star;GET /insight/sessions/{id}/delete;GET /capability/skills/{name}/delete、/toggle;旧模块 GET /sdc/delete、/pbi/...、/case/...、/tpl/...、/dm/cancelClean、/govern/calcTask(启动任务)等。
  • 后果:浏览器预取/链接分享/日志重放会误触发删除与任务;无幂等语义可定义。
  • 修复:写操作改 POST/DELETE。前端 electron 客户端同步改造;对无法马上改的前端,先给这批 GET 加"仅 POST 允许写"不现实——现实路径:至少把"URL 不含 delete 字样的 GET 删除"(/chat/messages/{id})改名,其余在契约版本化时一并处理。

E-2(高)同一语义"删除"有 5 种接口形状、分页 4 种返回体、"每页条数"3 个名字

  • 删除:GET /sessions/{id}/delete vs GET /xxx/delete?id= vs POST /user/delete(body)vs POST /dm/deletedFiles(ids 数组)vs GET /chat/messages/{id}(无字样)。
  • 分页返回:MP Page<T> / Map{list,total}(GovernTreeController 7 个端点)/ IPage(InsightController)/ 纯 List。
  • 分页入参:page/limit(Query 基类,46 类继承)vs page/size(AiCleanController)vs pageSize(agent 工具层)。
  • 修复:定义统一契约——Result<PageVO<T>>{records,total,page,size} + 删除一律 POST /xxx/delete(body 传 id(s))+ 入参统一 Query 基类;新代码立即执行,旧代码随版本化迁移。

E-3(高)响应包络不统一:agent 系 5 个 Controller 完全裸 VO

  • AgentChatController.java:63 返回裸 ChatSessionVO、多处返回 void;InsightController.java:94 直接返回 MP IPage;其余 185/216 端点返回 Result<T>。前端 defHttp 维护两套 transform(GlobalExceptionHandler 注释自己承认 /chat/** 走 isTransformResponse:false 特殊路径容易漏处理 401)。
  • 修复:/chat 系列补 Result 包络(SSE 流除外),前端删掉特殊分支。

E-4(中高)实体直接出参,VO 层缺失

  • CallRecordController Result<Page<CallRecord>>、TransRecordController、GovernTreeController Result<List<GovernTree>>、DmController Result<List<GovernConf>>、AiCleanController 返回 AiCleanJob 实体。表加字段即破坏前端契约,内部字段无遮蔽。
  • 修复:出参一律 VO(MapStruct 已在项目内用于 agent 模块,范式现成)。

E-5(中高)参数校验覆盖率 8/53 个 Controller

  • @Valid 与约束注解全部集中在 agent/embedding 新模块约 12 个 DTO;52 个旧 Controller 的 DTO/Query 零约束注解;null 直接穿透进 service(UserController.java:77 dto == null ? null : dto.getId() 手写兜底);SpecialDateService.java:37/70 注释自我承认"来自请求体(无 @Valid 校验),缺参时不能直接 == 1L(自动拆箱 NPE)"——用防御性补偿代替校验。
  • 修复:GlobalExceptionHandler 的校验异常处理链已就绪(MethodArgumentNotValid/Bind/ConstraintViolation 全有 handler),只差 DTO 上加注解。优先给写操作 DTO 补 @NotNull/@NotBlank。

E-6(中)错误码与文案

  • SSE error 帧无错误码字段(只有 message);LlmService.java:180 把上游 401/429 原始报文透传终端用户(timeout 已修成 504,说明只修了一半);好的对照:401 文案按 sa-token 类型细分、"请先打开案件后再执行数据治理"可行动。
  • 修复:SSE error 帧补 code 字段并与 Result.code 同枚举(见 O-12);LlmService 按上游状态码映射为面向用户的文案("模型服务认证失败,请检查模型配置")。

E-7(中)URL 命名三分天下 + 缩写谜语

  • RESTful 派(/agents、/chat/sessions)、RPC 动词派(/call/night/statFirstCallNight,15 个 stat* 控制器同一模板)、缩写谜语派(/sdc、/pbi、/pg、/gt、/tpl、/qry、/cr);大小写混用;部分映射缺前导斜杠、注解内多余空格。
  • 修复:不做大规模重命名(前端改动面太大);定一条规范写进 AGENTS/CODEX 规则:新接口必须 /模块/资源/动作 kebab 或 camel 二选一,禁新缩写;15 个 stat 控制器是同一模板,可抽公共基类一次收敛。

3.2 配置易用性

E-8(高)无 prod profile + 明文凭证入库

  • application.yaml:10 写死 active: dev;application-dev.yaml 内 postgres/postgres、neo4j/neo4j123、注释掉的 ES 密码全部在 git 里。
  • 修复:active: ${SPRING_PROFILES_ACTIVE:dev} + 新增 application-prod.yaml(凭证走环境变量);已泄露的密码轮换。

E-9(中)配置注释与实际不一致 + 三处联动硬编码

  • application-dev.yaml:54 注释写 Redis 是 .109,实际配的 .103;QUICK_START.md 同错且引用不存在的 ai-frontend/ 目录;server.port/context-path/storage.local.base-url 三处必须手工同步改(注释自己警告了)。
  • 修复:修注释;base-url 用配置引用或启动时校验三者一致性。

3.3 开发者体验

E-10(中高)死代码与复制粘贴代际

  • GraphServicecopy.java:613 行整文件块注释的死代码(文件名就叫 copy);WebConfig.java:28-38 注释掉的 db1 bean(含个人本机路径);App.java 的 init() 死代码。
  • 清洗器带序号复制品:TransInOutDataCleaner2~6、CallDataCleaner1、TransStdDataCleaner1、TransCoreHisBalanceCleaner2 等——序号即复制代际;15 个 stat* Controller 同模板复制 15 份;三胞胎 KeyController 逐行同构;GovernTreeController 7 个 getTreeXxxPage 仅表名不同。
  • 修复:死代码直接删(git 有历史);stat 控制器抽基类;清洗器复制品登记到治理清单(行为各异不敢贸然合并,但至少命名与注释标注来源差异)。

E-11(中)命名拼写错误与超大类

  • 拼写:包名 govern/serivce(已知)、SseService.see()(sse→see,连锁传播到调用点与注释)、TrackMeetSummeryDTO、/saveGovernCon、getMaplocal、SUCCEED_CODE。
  • 超大类(>800 行):DmService(967 行且无接口的单体 @Service)、TransAnalysisTool(942)、AgentChatServiceImpl(874);800 边缘还有 5 个。
  • 修复:拼写在下一轮触碰这些文件时顺手修(包名迁移除外,动静大);超大类只拆"下一步必改"的 DmService(预览/清洗/删除/上传四块职责天然可分)。

E-12(中高)数据库 schema 无版本化迁移

  • sql/ 下 20+ 个无版本脚本(aasd.sql、case_table_1.sql、testSql/交易共同.sql…),无 Flyway/Liquibase,QUICK_START.md:102 承认手工执行且混有 Solon 时代旧表。多人环境无法可重复建库。
  • 修复:引入 Flyway(baseline 现状)+ 新增变更一律走迁移脚本;测试 SQL 移出 sql/ 目录。

E-13(中)文档错位

  • readme.md 是个人便签(许可证工具 + pmtiles 命令);ai-server/CODE_WIKI.md(849 行)是 Solon 时代遗留且未删(QUICK_START 里加了 ⚠ 但没删文件);QUICK_START 本身质量不错。
  • 修复:readme 重写为"项目是什么 + 怎么起 + 指向 QUICK_START";CODE_WIKI 归档到 docs/legacy 或删除。

3.4 前端契约稳定性

E-14(高·业务正确性风险)BigDecimal 全局序列化为"千分位字符串"且 null→"0.00"

  • 位置:common/config/BigDecimalSerializer.java:24-26(null 序列化为 "0.00")、:31(经 DataUtil.convertAmount 输出 "1,234.00" 带千分位)。
  • 后果:① 空值与真实零金额在 UI 上不可区分——研判业务里这是"假数据"(用户会认为该账户余额为 0 而非无数据);② 前端做数值运算必须先去逗号,每处都要记得。
  • 修复:null 输出 null(前端空态展示);千分位格式化移到前端展示层(后端输出纯数字字符串或数值)。这是接口行为变更,需与前端同步排期。

E-15(低)日期双轨但一致;Redis 缓存的 ObjectMapper 未配全局日期格式(缓存内时间戳格式与 HTTP 出参不同)。


4. 修复路线图

第一批:止血(1-2 天,全部是局部小改)

  1. O-1 PII 日志脱敏 + O-2 logback info/滚动/分文件(合规,最高优先)
  2. S-1 意图识别 block 超时接线(IntentProperties.timeoutSeconds 已有)
  3. S-13 createTimeSeries 抛异常中断流水线
  4. O-4 预处理 unwrap ServerException
  5. O-3 SSE 兜底错误不泄内部 message
  6. S-12 回调顺序调整(先 flush 人员库再 initCache)

第二批:稳定性加固(3-5 天)

  1. S-2 ES 移出清洗主链路(add 内部 catch + 熔断)
  2. S-6 SheetProbe 改 SAX
  3. S-10 graceful shutdown + FileSheetStateEnum 中间态 + 启动 sweep
  4. S-11 file_ai_profile 看门狗;S-3 对话流超时 + 心跳;S-4 RAG 检索降级
  5. O-5 /dm、/govern 补轮询通道;O-6 SmartCleanStatusVO 补 lastError

第三批:可观测性与运维(2-3 天)

  1. O-7 MDC traceId;O-8 /sys/health 依赖自检;O-9 慢 SQL Interceptor
  2. O-10 级别治理;O-11 失败不推 100%;E-8 prod profile + 凭证出库

第四批:契约治理(渐进,随版本)

  1. E-14 BigDecimal 序列化变更(与前端联排)
  2. E-1/E-2/E-3 写操作 POST 化、统一分页 VO、/chat 补 Result 包络
  3. E-5 写 DTO 补校验;E-12 Flyway 落地;E-10 死代码清理

5. 已做得好的部分(防止误伤,后续改动请保持)

  1. GlobalExceptionHandler 样板级:15 类异常、cause 拆包、500 不泄堆栈、401 按前端约定 200+401 且写明理由。
  2. AI 清洗(aiclean)模块的观测设计是全仓标杆:job 状态机落库、costMs/tokenIn/tokenOut/failReason、批次原子计数、needReview 明细落库、看门狗双 sweep(启动孤儿+心跳超时)、失败原因写 lastError。
  3. LLM 失败语义干净:LlmService 绝不返回 null、400/404/500/504 分明、timeout 用 Reactor 操作符、LlmRetry 只对 429/5xx 指数退避且仅批处理链路使用。
  4. 大文件解析全流式:预览/清洗 Fesod 流式 + DuckDBAppender(200MB csv 不进堆);附件表格扫描同款;行级脏数据有 CleanErrorLog 台账(带行号)。
  5. DuckDB 资源预算:memory_limit 按 max-open-cases 分摊物理内存 60%、超 8 案拒绝开案、threads=2、快照指纹缓存。
  6. PythonExecutor 防护完备:子进程超时强杀、输出截断、沙箱清理、py-out LRU 跳过在途、快照导出重试后显式报错。
  7. ES 查询侧 fail-open + 启动不阻断(但写入侧需按 S-2 补齐——两侧要对称)。
  8. 上下文传播系统化:CaseContextHolder(ScopedValue)+ Reactor 钩子,每处异步提交显式带 caseId/userId——SSE 能推到正确的人,这是最容易系统性坏掉的地方。
  9. application.yaml 的"为什么"注释是示范级(调度线程池与心跳、MCP base-url 404 陷阱、DuckDB 预算分摊)。
  10. 对话错误持久化:错误/中断/部分回复落库为 message,刷新可见;研判流 doFinally 无条件复位状态位防会话卡死。