Просмотр исходного кода

fix(ai): AiGateway 超时分离 - connect 快速失败 + read 覆盖 LLM 推理耗时

- AiGateway: SimpleClientHttpRequestFactory 设置 connect(timeout-ms)/read(read-timeout-ms) 超时,默认 read 120s
- application.yml: 新增 python.read-timeout-ms: 120000
- 前端 api.js: request 支持 timeout 选项(不混入 query),问卷接口加 130s 超时
Xiaogang Liao 1 месяц назад
Родитель
Сommit
51fc0544cd

+ 15 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java

@@ -11,6 +11,7 @@ import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.http.HttpEntity;
 import org.springframework.http.ResponseEntity;
+import org.springframework.http.client.SimpleClientHttpRequestFactory;
 import org.springframework.stereotype.Service;
 import org.springframework.web.client.RestTemplate;
 import java.util.*;
@@ -37,6 +38,11 @@ public class AiGateway {
     @Value("${python.timeout-ms:15000}")
     private int timeoutMs;
 
+    // LLM 推理可达 30-60s(画像/问卷更长),read 超时需覆盖推理耗时,
+    // 否则 AI 长任务永远被超时截断退化到 mock 兜底
+    @Value("${python.read-timeout-ms:120000}")
+    private int readTimeoutMs;
+
     @Value("${python.circuit-breaker.failure-threshold:3}")
     private int failureThreshold;
 
@@ -56,9 +62,16 @@ public class AiGateway {
 
     @PostConstruct
     public void init() {
+        // 应用超时到 HTTP 客户端:
+        // connect 用 python.timeout-ms(快速失败),read 用 python.read-timeout-ms(覆盖 LLM 推理耗时)。
+        // 之前用 `new RestTemplate()` 默认无限阻塞,LangGraph 服务挂起时后端线程被拖死,前端永远等不到响应
+        SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
+        factory.setConnectTimeout(timeoutMs);
+        factory.setReadTimeout(readTimeoutMs);
+        restTemplate.setRequestFactory(factory);
         if (enabled) {
-            log.info("AiGateway 已启用: baseUrl={}, timeout={}ms",
-                    baseUrl, timeoutMs);
+            log.info("AiGateway 已启用: baseUrl={}, connectTimeout={}ms, readTimeout={}ms",
+                    baseUrl, timeoutMs, readTimeoutMs);
         } else {
             log.info("AiGateway 已禁用, 所有请求走 LangGraph");
         }

+ 2 - 0
cfc-backend/src/main/resources/application.yml

@@ -104,6 +104,8 @@ python:
   enabled: true
   base-url: https://ai.etotem.com.cn
   timeout-ms: 15000
+  # LLM 推理可达 30-60s(画像/问卷更长),read 超时必须覆盖推理耗时,connect 超时才用 timeout-ms
+  read-timeout-ms: 120000
   circuit-breaker:
     failure-threshold: 3
     reset-timeout-ms: 30000

+ 95 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/AiGatewayTimeoutTest.java

@@ -0,0 +1,95 @@
+package com.etotem.cfc.service;
+
+import com.sun.net.httpserver.HttpServer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.net.InetSocketAddress;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.when;
+
+/**
+ * 回归测试:LangGraph 服务挂起(accept 连接但永不响应)时,
+ * AiGateway 必须在 python.timeout-ms 内返回 null(走 mock 兜底),
+ * 而不是无限阻塞拖死后端线程。
+ *
+ * 背景:AiGateway 曾使用 `new RestTemplate()`(默认无限 connect/read 超时),
+ * 配置的 python.timeout-ms 从未应用,LangGraph 挂起时问卷生成永远转圈。
+ */
+@ExtendWith(MockitoExtension.class)
+class AiGatewayTimeoutTest {
+
+    private HttpServer server;
+    private AiGateway gateway;
+
+    @Mock
+    private PiisService piisService;
+
+    private void startHungServer() throws IOException {
+        server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+        server.setExecutor(runnable -> {
+            Thread t = new Thread(runnable);
+            t.setDaemon(true);
+            t.start();
+        });
+        server.createContext("/", exchange -> {
+            // 不写响应,模拟挂起/卡死的上游服务
+            try {
+                Thread.sleep(TimeUnit.HOURS.toMillis(1));
+            } catch (InterruptedException ignored) {
+            }
+        });
+        server.start();
+    }
+
+    private void buildGateway(int timeoutMs) throws Exception {
+        gateway = new AiGateway();
+        ReflectionTestUtils.setField(gateway, "enabled", true);
+        ReflectionTestUtils.setField(gateway, "baseUrl",
+                "http://127.0.0.1:" + server.getAddress().getPort());
+        ReflectionTestUtils.setField(gateway, "timeoutMs", timeoutMs);
+        ReflectionTestUtils.setField(gateway, "readTimeoutMs", timeoutMs);
+        ReflectionTestUtils.setField(gateway, "failureThreshold", 3);
+        ReflectionTestUtils.setField(gateway, "resetTimeoutMs", 30000);
+        when(piisService.sanitizeContext(any(), isNull())).thenAnswer(inv -> inv.getArgument(0));
+        ReflectionTestUtils.setField(gateway, "piisService", piisService);
+        gateway.init();
+    }
+
+    @AfterEach
+    void tearDown() {
+        if (server != null) server.stop(0);
+        if (gateway != null) gateway = null;
+    }
+
+    @Test
+    void generateQuestionnaire_upstreamHangs_returnsNullWithinTimeout() throws Exception {
+        startHungServer();
+        int timeoutMs = 500;
+        buildGateway(timeoutMs);
+
+        Map<String, Object> inputs = new HashMap<>();
+        inputs.put("member_name", "测试");
+        inputs.put("relationship_type", "child");
+
+        long start = System.currentTimeMillis();
+        Map<String, Object> result = gateway.generateQuestionnaire(inputs);
+        long elapsed = System.currentTimeMillis() - start;
+
+        assertNull(result, "上游挂起时应返回 null 触发 mock 兜底");
+        assertTrue(elapsed < TimeUnit.SECONDS.toMillis(5),
+                "应在 ~" + timeoutMs + "ms 超时返回,实际耗时 " + elapsed + "ms");
+    }
+}

+ 10 - 7
cfc-frontend/utils/api.js

@@ -137,10 +137,11 @@ function _handle401(url, method, data, options, resolve, reject, resData) {
 
 const request = (url, method = 'POST', data = {}, options = {}) => {
   // Append query params from options to URL if provided (e.g., { guideId } for GET requests)
+  // `timeout`/`_skipDedup` 是请求配置项,不是 query 参数,需排除
   if (options && Object.keys(options).length > 0) {
     const separator = url.includes('?') ? '&' : '?'
     const query = Object.entries(options)
-      .filter(([_, v]) => v !== undefined && v !== null && v !== '')
+      .filter(([k, v]) => k !== 'timeout' && k !== '_skipDedup' && v !== undefined && v !== null && v !== '')
       .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
       .join('&')
     if (query) url = url + separator + query
@@ -176,7 +177,7 @@ const request = (url, method = 'POST', data = {}, options = {}) => {
   }
 
   var _promise = new Promise((resolve, reject) => {
-    uni.request({
+    var _reqOpts = {
       url: fullUrl,
       method: method,
       data: body,
@@ -222,7 +223,9 @@ const request = (url, method = 'POST', data = {}, options = {}) => {
         })
         reject(err)
       }
-    })
+    }
+    if (options && options.timeout) _reqOpts.timeout = options.timeout
+    uni.request(_reqOpts)
   })
 
   // 注册去重缓存,3 秒窗口内复用
@@ -2390,19 +2393,19 @@ export const getInteractionTypes = () => {
 
 // ===== 鍏崇郴闂嵎 =====
 export const generateQuestionnaire = (memberId) => {
-  return request('/api/family/questionnaire/generate', 'POST', { memberId })
+  return request('/api/family/questionnaire/generate', 'POST', { memberId }, { timeout: 130000 })
 }
 
 export const submitQuestionnaire = (data) => {
-  return request('/api/family/questionnaire/submit', 'POST', data)
+  return request('/api/family/questionnaire/submit', 'POST', data, { timeout: 130000 })
 }
 
 export const getQuestionnaireSnapshot = (memberId) => {
-  return request('/api/family/questionnaire/snapshot/' + memberId, 'POST', {})
+  return request('/api/family/questionnaire/snapshot/' + memberId, 'POST', {}, { timeout: 130000 })
 }
 
 export const getQuestionnaireHistory = (memberId) => {
-  return request('/api/family/questionnaire/history/' + memberId, 'POST', {})
+  return request('/api/family/questionnaire/history/' + memberId, 'POST', {}, { timeout: 130000 })
 }
 
 // ===== 五维家庭自检(P0-1)=====