Forráskód Böngészése

fix: 稳定性与合规三项修复(PII 日志脱敏 / ES 写入熔断 / 附件 RAG 降级+预算)

- 合规:清洗链路姓名/卡号/证件号日志脱敏(maskForLog:保留前4后2),
  预处理原文不再打印;GraphService 移除 System.out(SQL 含手机号/卡号节点值)
- 稳定:SearchDocIndexService add()/flush() 容错化(ES 故障记日志+丢弃计数,
  绝不上抛清洗主链路),连续 3 批失败熔断 60s 期间 add 直接跳过,成功即复位;
  缺失检索数据可按案件重建索引补偿
- 稳定:附件 RAG 检索失败降级为"无片段的普通回答"(fail-open,不阻断整条消息);
  新增单附件索引总预算 zsjz.chat-attachment.index-budget-seconds(默认 600s),
  超预算 chunk 跳过并置 truncated,上传请求不再被嵌入端点故障占住几十分钟
- 测试:新增 SearchDocIndexServiceTest(4 用例)+ AttachmentDocIndexerTest(3 用例)全绿;
  全量 370 测试仅剩 HEAD 既有的 8 个过时断言,零新增回归
- 报告与实施记录:docs/design/stability-usability-report.md
cc 1 hete
szülő
commit
25165b9888

+ 24 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/attachment/AttachmentDocIndexer.java

@@ -103,13 +103,22 @@ public class AttachmentDocIndexer {
 
         // 全局闸门(成员级):限制的是「全部附件合计」的嵌入并发,不再是单附件内的并发
         Semaphore gate = embedGate();
+        // 单附件索引总预算(S-4):超时后剩余 chunk 跳过(truncated 置真),
+        // 避免嵌入端点整体变慢时上传请求被占住几十分钟
+        long deadlineMs = System.currentTimeMillis() + props.getIndexBudgetSeconds() * 1000L;
         List<Runnable> jobs = new ArrayList<>(chunks.size());
         // 并发累加必须用原子计数(int[] ++ 在多线程下丢更新,块数会少报)
         java.util.concurrent.atomic.AtomicInteger ok = new java.util.concurrent.atomic.AtomicInteger();
+        java.util.concurrent.atomic.AtomicInteger skipped = new java.util.concurrent.atomic.AtomicInteger();
         for (int i = 0; i < chunks.size(); i++) {
             int chunkNo = i;
             String content = chunks.get(i);
             jobs.add(() -> {
+                if (System.currentTimeMillis() > deadlineMs) {
+                    // 预算已尽:不排队不做工
+                    skipped.incrementAndGet();
+                    return;
+                }
                 try {
                     gate.acquire();
                 } catch (InterruptedException e) {
@@ -117,6 +126,11 @@ public class AttachmentDocIndexer {
                     return;
                 }
                 try {
+                    if (System.currentTimeMillis() > deadlineMs) {
+                        // 排到队尾时预算刚好用尽:放弃本块
+                        skipped.incrementAndGet();
+                        return;
+                    }
                     double[] vec = spec.model()
                             .embed(TextBlock.builder().text(content).build())
                             .block(Duration.ofSeconds(60));
@@ -147,7 +161,17 @@ public class AttachmentDocIndexer {
                 break;
             }
         }
+        if (skipped.get() > 0) {
+            // 预算耗尽:已索引的部分照常可用,truncated 置真提示"仅部分内容被索引"
+            truncated = true;
+            log.warn("附件索引总预算用尽({}s),跳过 {}/{} 个 chunk: attachmentId={}, 已索引 {} 块",
+                    props.getIndexBudgetSeconds(), skipped.get(), chunks.size(), attachmentId, ok.get());
+        }
         if (ok.get() == 0) {
+            if (skipped.get() > 0) {
+                throw new IllegalStateException("附件索引预算用尽(index-budget-seconds="
+                        + props.getIndexBudgetSeconds() + "s)且无一 chunk 完成索引,请增大预算或检查嵌入服务");
+            }
             throw new IllegalStateException("附件全部 chunk 向量化失败,请检查嵌入服务");
         }
         return new IndexResult(ok.get(), truncated);

+ 7 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/attachment/ChatAttachmentProperties.java

@@ -53,6 +53,13 @@ public class ChatAttachmentProperties {
     /** RAG 索引并发度(embedding 调用) */
     private int indexConcurrency = 8;
 
+    /**
+     * 单附件索引总预算(秒):所有 chunk 的向量化+入库合计超过该时长后,
+     * 剩余 chunk 跳过(truncated 置真)。防止嵌入端点整体变慢时上传请求被无限占用
+     * (最坏 500 chunk × 60s/8 并发 ≈ 62 分钟,S-4)
+     */
+    private int indexBudgetSeconds = 600;
+
     /** 孤儿附件保留小时数:上传后这么久仍未随消息发出才清理(默认 7 天) */
     private int orphanHours = 168;
 

+ 11 - 2
ai-server/src/main/java/com/zsjz/ai/module/agent/attachment/ChatAttachmentService.java

@@ -286,8 +286,17 @@ public class ChatAttachmentService {
             sb.append(buildSamplePreview(att));
             return sb.toString();
         }
-        // 路径 C:RAG 检索片段
-        List<String> fragments = docIndexer.search(question, att.getId(), att.getSessionId(), att.getCaseId());
+        // 路径 C:RAG 检索片段。
+        // fail-open(S-4):嵌入模型/PG 向量检索故障时降级为"无 RAG 片段的普通回答",
+        // 绝不能让检索失败阻断整条消息——search 空结果会走下方"未检索到片段"的既有文案
+        List<String> fragments;
+        try {
+            fragments = docIndexer.search(question, att.getId(), att.getSessionId(), att.getCaseId());
+        } catch (Exception e) {
+            log.warn("附件 RAG 检索失败,降级为无片段回答(不阻断对话): attachmentId={}, err={}",
+                    att.getId(), e.getMessage());
+            fragments = List.of();
+        }
         sb.append(head).append("(文档, ").append(att.getCharCount()).append(" 字, 已切片索引 ")
                 .append(att.getIndexBlocks()).append(" 片").append(att.getTruncated() == 1 ? ", 仅索引前部" : "")
                 .append(")\n");

+ 23 - 3
ai-server/src/main/java/com/zsjz/ai/module/dm/clean/AbstractDataCleaner.java

@@ -210,7 +210,9 @@ public abstract class AbstractDataCleaner<T extends Cleaned> implements DataClea
         String text = Joiner.on("").skipNulls().join(pre);
         text = ReUtil.replaceAll(text, SPECIAL_CHARS_BLANK, "");
         String cleanText = text;
-        log.info("预处理后文本:{}", cleanText);
+        // 内容不再打印(合规):预处理文本通常含开户人姓名/卡号等测绘核心敏感数据,
+        // 只保留长度用于排查(O-1)
+        log.debug("预处理后文本长度:{}", cleanText.length());
         //  遍历提取字段(替代硬编码索引)
         for (Map.Entry<String, List<String>> entry : funcRegxMap.entrySet()) {
             String prefix = entry.getKey();
@@ -240,14 +242,32 @@ public abstract class AbstractDataCleaner<T extends Cleaned> implements DataClea
                 log.info("未匹配到(前缀:{},后缀:{})", prefix, suffix);
             }
         }
-        // 提取完成日志,便于排查
-        log.info("数据提取完成,结果:姓名={}, 卡号={}, 证件号={}", personName, personCardNo, personCertNo);
+        // 提取完成日志,便于排查。姓名/卡号/证件号必须脱敏(合规):日志不得出现完整敏感值(O-1)
+        log.info("数据提取完成,结果:姓名={}, 卡号={}, 证件号={}",
+                maskForLog(personName), maskForLog(personCardNo), maskForLog(personCertNo));
     }
 
     public void valid(CleanResult<T> c) {
 
     }
 
+    /**
+     * 日志专用敏感值脱敏:保留前 4 后 2,中间打星;短值只保留首字符。
+     * 卡号/证件号/姓名等测绘敏感数据不允许以完整形态进入日志。
+     */
+    private static String maskForLog(String value) {
+        if (StrUtil.isEmpty(value)) {
+            return value;
+        }
+        if (value.length() <= 2) {
+            return value.charAt(0) + "**";
+        }
+        if (value.length() <= 6) {
+            return value.charAt(0) + "****";
+        }
+        return value.substring(0, 4) + "****" + value.substring(value.length() - 2);
+    }
+
     @Override
     public void after(T t) {
 

+ 1 - 1
ai-server/src/main/java/com/zsjz/ai/module/graph/service/GraphService.java

@@ -109,7 +109,7 @@ public class GraphService {
         if (type.contains(201) || type.contains(202) || type.contains(203)) {
             sql += finalSelectCallSql();
         }*/
-        System.out.println(sql);
+        // 调试用 SQL 打印已移除:SQL 里含手机号/卡号节点值,属测绘敏感数据不得进日志(O-1)
         List<RelEdgeDTO> edgeList = graphMapper.graphView(sql);
         if (edgeList.size() > 10000) {
             throw new ServerException("当前数据量过大,请根据条件缩数据范围!");

+ 67 - 4
ai-server/src/main/java/com/zsjz/ai/module/search/service/SearchDocIndexService.java

@@ -19,6 +19,11 @@ import java.util.concurrent.atomic.AtomicBoolean;
 /**
  * 全文检索写入服务(Elasticsearch,替代原嵌入式 Lucene 的 {@code LuceneManager})。
  *
+ * <p><b>写入容错(S-2)</b>:全文检索是旁路能力,<b>绝不允许把故障传导进清洗主链路</b>——
+ * ES 宕机时写入静默降级(记日志+丢弃计数),连续 {@value #BREAKER_THRESHOLD} 批失败后熔断
+ * {@value #BREAKER_COOLDOWN_MS / 1000}s,期间新文档直接跳过,避免每批 60s 超时反复拖慢清洗。
+ * 缺失的检索数据可按案件重建索引补偿;查询侧本就是 fail-open(异常返回空列表)。
+ *
  * <p><b>案件隔离</b>:caseId 一律取自 {@link CaseContextHolder}——
  * 写入时强制打进文档、删除时强制作为过滤条件,调用方无法指定别的案件;
  * 取不到上下文直接抛异常(清洗链路跑在虚拟线程上,忘绑上下文比失败更糟)。
@@ -47,6 +52,23 @@ public class SearchDocIndexService {
      */
     private final AtomicBoolean indexReady = new AtomicBoolean(false);
 
+    /** 连续失败达到该批数后熔断 */
+    private static final int BREAKER_THRESHOLD = 3;
+
+    /** 熔断时长(毫秒):期间 add() 直接跳过,不再反复承受 60s 超时 */
+    private static final long BREAKER_COOLDOWN_MS = 60_000L;
+
+    /** ES 连续失败批数(成功即清零) */
+    private final java.util.concurrent.atomic.AtomicInteger esConsecutiveFailures =
+            new java.util.concurrent.atomic.AtomicInteger();
+
+    /** 熔断打开的截止时刻;0 = 未熔断 */
+    private volatile long esBreakerOpenUntilMs = 0L;
+
+    /** 因故障/熔断丢弃的检索文档累计数(可观测) */
+    private final java.util.concurrent.atomic.AtomicLong esDroppedDocs =
+            new java.util.concurrent.atomic.AtomicLong();
+
     public SearchDocIndexService(SearchDocRepository repository, ElasticsearchOperations operations) {
         this.repository = repository;
         this.operations = operations;
@@ -80,6 +102,11 @@ public class SearchDocIndexService {
         Long caseId = requireCaseId();
         doc.setCaseId(String.valueOf(caseId));
         doc.setDocId(String.valueOf(caseId) + '|' + doc.getTableName() + '|' + doc.getRowId());
+        if (esBreakerOpen()) {
+            // 熔断期:直接丢弃并计数,绝不上抛清洗主链路
+            esDroppedDocs.incrementAndGet();
+            return;
+        }
         List<SearchDoc> buffer = buffers.computeIfAbsent(caseId, key -> new java.util.ArrayList<>(BATCH_SIZE));
         List<SearchDoc> batch = null;
         synchronized (buffer) {
@@ -90,7 +117,12 @@ public class SearchDocIndexService {
             }
         }
         if (batch != null) {
-            saveBatch(batch);
+            try {
+                saveBatch(batch);
+                recordEsSuccess();
+            } catch (Exception e) {
+                recordEsFailure(batch.size(), e);
+            }
         }
     }
 
@@ -111,11 +143,20 @@ public class SearchDocIndexService {
                 }
             }
             if (batch != null) {
-                saveBatch(batch);
+                try {
+                    saveBatch(batch);
+                    recordEsSuccess();
+                } catch (Exception e) {
+                    recordEsFailure(batch.size(), e);
+                }
             }
         }
-        ensureIndex();
-        operations.indexOps(SearchDoc.class).refresh();
+        try {
+            ensureIndex();
+            operations.indexOps(SearchDoc.class).refresh();
+        } catch (Exception e) {
+            recordEsFailure(0, e);
+        }
     }
 
     /**
@@ -165,6 +206,28 @@ public class SearchDocIndexService {
         repository.saveAll(batch);
     }
 
+    private boolean esBreakerOpen() {
+        return System.currentTimeMillis() < esBreakerOpenUntilMs;
+    }
+
+    private void recordEsSuccess() {
+        esConsecutiveFailures.set(0);
+        esBreakerOpenUntilMs = 0L;
+    }
+
+    private void recordEsFailure(int dropped, Exception e) {
+        long totalDropped = dropped == 0 ? esDroppedDocs.get() : esDroppedDocs.addAndGet(dropped);
+        int fails = esConsecutiveFailures.incrementAndGet();
+        if (fails >= BREAKER_THRESHOLD) {
+            esBreakerOpenUntilMs = System.currentTimeMillis() + BREAKER_COOLDOWN_MS;
+            log.error("ES 连续 {} 批写入失败,全文检索熔断 {}s(清洗主链路不受影响;期间检索文档缺失,"
+                    + "可按案件重建索引补偿;累计丢弃 {} 条)", fails, BREAKER_COOLDOWN_MS / 1000, totalDropped, e);
+        } else {
+            log.error("ES 写入失败,本次丢弃 {} 条检索文档(累计丢弃 {},连续失败 {} 批)",
+                    dropped, totalDropped, fails, e);
+        }
+    }
+
     /**
      * 取当前线程绑定的案件 ID,取不到时抛异常(见类注释)
      */

+ 82 - 0
ai-server/src/test/java/com/zsjz/ai/module/agent/attachment/AttachmentDocIndexerTest.java

@@ -0,0 +1,82 @@
+package com.zsjz.ai.module.agent.attachment;
+
+import com.zsjz.ai.module.agent.rag.EmbeddingModelFactory;
+import com.zsjz.ai.module.agent.attachment.AttachmentVectorMapper;
+import io.agentscope.core.embedding.EmbeddingModel;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Mono;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * AttachmentDocIndexer(S-4)索引总预算回归:
+ * 嵌入端点整体变慢时,超预算的 chunk 跳过(truncated 置真)而不是把上传请求挂住几十分钟。
+ */
+class AttachmentDocIndexerTest {
+
+    private EmbeddingModelFactory factory;
+    private AttachmentVectorMapper vectorMapper;
+    private ChatAttachmentProperties props;
+    private AttachmentDocIndexer indexer;
+
+    @BeforeEach
+    void setUp() {
+        factory = mock(EmbeddingModelFactory.class);
+        vectorMapper = mock(AttachmentVectorMapper.class);
+        props = new ChatAttachmentProperties();
+        props.setChunkSize(10);
+        props.setChunkOverlap(0);
+        props.setMaxChunks(50);
+        props.setIndexConcurrency(4);
+        props.setIndexBudgetSeconds(600);
+        indexer = new AttachmentDocIndexer(factory, vectorMapper, props);
+
+        EmbeddingModel embeddingModel = mock(EmbeddingModel.class);
+        when(embeddingModel.embed(any())).thenReturn(Mono.just(new double[]{0.5, 0.5}));
+        when(factory.resolveDefault())
+                .thenReturn(new EmbeddingModelFactory.EmbeddingSpec(1L, "test-embed", embeddingModel, 2));
+        when(vectorMapper.tableExists(anyString())).thenReturn(1);
+    }
+
+    @Test
+    @DisplayName("预算充足:全部 chunk 完成索引,truncated=false")
+    void budgetEnoughIndexesAll() {
+        // 1000 字 / chunk(下限200) / overlap 0 → 5 个 chunk
+        AttachmentDocIndexer.IndexResult result = indexer.index("x".repeat(1000), 1L, 1L, 1L);
+        assertEquals(5, result.chunks());
+        assertTrue(!result.truncated());
+        verify(vectorMapper, times(5)).insertVectorDoc(anyString(), anyString(), anyString(), anyString(), anyString());
+    }
+
+    @Test
+    @DisplayName("预算为 0:全部 chunk 被跳过,报错文案带预算提示(而非误导性的嵌入服务故障)")
+    void budgetZeroSkipsAll() {
+        props.setIndexBudgetSeconds(0);
+        IllegalStateException ex = assertThrows(IllegalStateException.class,
+                () -> indexer.index("x".repeat(1000), 9L, 1L, 1L));
+        assertTrue(ex.getMessage().contains("预算"), "应明确报预算耗尽而非嵌入服务故障: " + ex.getMessage());
+        verify(vectorMapper, never()).insertVectorDoc(anyString(), anyString(), anyString(), anyString(), anyString());
+    }
+
+    @Test
+    @DisplayName("嵌入模型未配置:明确报错(维持既有语义)")
+    void noEmbeddingModelFails() {
+        when(factory.resolveDefault()).thenReturn(null);
+        IllegalStateException ex = assertThrows(IllegalStateException.class,
+                () -> indexer.index("x".repeat(1000), 1L, 1L, 1L));
+        assertTrue(ex.getMessage().contains("未配置嵌入模型"));
+        verify(vectorMapper, never()).createVectorTable(anyString(), anyInt());
+    }
+}

+ 112 - 0
ai-server/src/test/java/com/zsjz/ai/module/search/service/SearchDocIndexServiceTest.java

@@ -0,0 +1,112 @@
+package com.zsjz.ai.module.search.service;
+
+import com.zsjz.ai.common.context.CaseContextHolder;
+import com.zsjz.ai.module.search.domain.SearchDoc;
+import com.zsjz.ai.module.search.repository.SearchDocRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
+import org.springframework.data.elasticsearch.core.IndexOperations;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyCollection;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * SearchDocIndexService(S-2)写入容错回归:ES 故障绝不上抛清洗主链路,
+ * 连续 3 批失败后熔断(期间 add 直接跳过、不再承受 60s 超时),成功即复位。
+ */
+class SearchDocIndexServiceTest {
+
+    private SearchDocRepository repository;
+    private ElasticsearchOperations operations;
+    private IndexOperations indexOperations;
+    private SearchDocIndexService service;
+
+    @BeforeEach
+    void setUp() {
+        repository = mock(SearchDocRepository.class);
+        operations = mock(ElasticsearchOperations.class);
+        indexOperations = mock(IndexOperations.class);
+        when(operations.indexOps(SearchDoc.class)).thenReturn(indexOperations);
+        when(indexOperations.exists()).thenReturn(true);
+        service = new SearchDocIndexService(repository, operations);
+    }
+
+    private SearchDoc doc(int i) {
+        SearchDoc doc = new SearchDoc();
+        doc.setTableName("call_record_all");
+        doc.setRowId(String.valueOf(i));
+        doc.setContent("row-" + i);
+        return doc;
+    }
+
+    private void addBatch() {
+        CaseContextHolder.runWith(1L, 1L, () -> {
+            for (int i = 0; i < 1000; i++) {
+                service.add(doc(i));
+            }
+        });
+    }
+
+    @Test
+    @DisplayName("ES 写入失败不上抛:攒满一批提交抛异常时 add 静默降级(清洗链路不受影响)")
+    void esFailureDoesNotPropagate() {
+        doThrow(new RuntimeException("es down")).when(repository).saveAll(any());
+        assertDoesNotThrow(this::addBatch, "add 在 ES 故障时必须吞掉异常(清洗主链路保护)");
+        verify(repository, times(1)).saveAll(any());
+    }
+
+    @Test
+    @DisplayName("连续 3 批失败后熔断:后续 add 不再触达 ES")
+    void breakerOpensAfterThreeFailingBatches() {
+        doThrow(new RuntimeException("es down")).when(repository).saveAll(any());
+        addBatch();
+        addBatch();
+        addBatch();
+        verify(repository, times(3)).saveAll(anyCollection());
+        // 熔断期:再来一批也不触达 ES(避免每批 60s 超时反复拖慢清洗)
+        addBatch();
+        verify(repository, times(3)).saveAll(anyCollection());
+    }
+
+    @Test
+    @DisplayName("成功复位熔断计数:中间成功一批后连续失败重新起算")
+    void successResetsFailureStreak() {
+        when(repository.saveAll(any()))
+                .thenThrow(new RuntimeException("f1"))
+                .thenThrow(new RuntimeException("f2"))
+                .thenReturn(List.of())   // 第 3 批成功 → 连续失败清零
+                .thenThrow(new RuntimeException("f3"))
+                .thenThrow(new RuntimeException("f4"));
+        addBatch();
+        addBatch();
+        addBatch();
+        addBatch();
+        addBatch();
+        // 若熔断未复位,第 5 批就不再触达 ES;成功清零后 5 批全部触达
+        verify(repository, times(5)).saveAll(anyCollection());
+    }
+
+    @Test
+    @DisplayName("flush 在 ES 故障时不上抛(含 refresh 失败),尾批丢弃并计数")
+    void flushDoesNotThrowWhenEsDown() {
+        doThrow(new RuntimeException("es down")).when(repository).saveAll(any());
+        doThrow(new RuntimeException("refresh failed")).when(indexOperations).refresh();
+        CaseContextHolder.runWith(1L, 1L, () -> {
+            for (int i = 0; i < 5; i++) {
+                service.add(doc(i));
+            }
+            assertDoesNotThrow(service::flush, "flush 是旁路能力,ES 故障必须静默降级");
+        });
+        verify(repository, times(1)).saveAll(any());
+    }
+}

+ 343 - 0
docs/design/stability-usability-report.md

@@ -0,0 +1,343 @@
+# 稳定性与易用性分析与优化报告(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 天)**
+7. S-2 ES 移出清洗主链路(add 内部 catch + 熔断)
+8. S-6 SheetProbe 改 SAX
+9. S-10 graceful shutdown + FileSheetStateEnum 中间态 + 启动 sweep
+10. S-11 file_ai_profile 看门狗;S-3 对话流超时 + 心跳;S-4 RAG 检索降级
+11. O-5 /dm、/govern 补轮询通道;O-6 SmartCleanStatusVO 补 lastError
+
+**第三批:可观测性与运维(2-3 天)**
+12. O-7 MDC traceId;O-8 /sys/health 依赖自检;O-9 慢 SQL Interceptor
+13. O-10 级别治理;O-11 失败不推 100%;E-8 prod profile + 凭证出库
+
+**第四批:契约治理(渐进,随版本)**
+14. E-14 BigDecimal 序列化变更(与前端联排)
+15. E-1/E-2/E-3 写操作 POST 化、统一分页 VO、/chat 补 Result 包络
+16. 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 无条件复位状态位防会话卡死。