Sfoglia il codice sorgente

feat(backend): AiGateway + 5% grayscale routing to LangGraph

- AiGateway.java: HTTP → Python 服务网关,含熔断器 (3次失败→停30s)
- application.yml: 新增 python.* 配置项
- RecommendationController: 5% 流量走 Python,异常自动 fallback
iwt 3 mesi fa
parent
commit
785594dd33

+ 22 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/ai/RecommendationController.java

@@ -3,9 +3,12 @@ package com.etotem.cfc.controller.ai;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.RecommendationQuery;
 import com.etotem.cfc.dto.RecommendationResult;
+import com.etotem.cfc.service.AiGateway;
 import com.etotem.cfc.service.RecommendationService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.web.bind.annotation.PathVariable;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
@@ -21,13 +24,32 @@ import java.util.List;
 @RequestMapping("/api/recommend")
 public class RecommendationController {
 
+    private static final Logger log = LoggerFactory.getLogger(RecommendationController.class);
+
     @Resource
     private RecommendationService recommendationService;
 
+    @Resource
+    private AiGateway aiGateway;
+
     @Operation(summary = "按营养标签搜索推荐内容")
     @PostMapping("/search")
     public Result<List<RecommendationResult>> search(
             @RequestBody RecommendationQuery query) {
+
+        // 灰度: 5% 流量走 Python LangGraph
+        Long userId = query.getUserId() != null ? query.getUserId() : 0L;
+        if (aiGateway.isEnabled() && Math.random() < 0.05) {
+            List<RecommendationResult> pythonResults = aiGateway.recommend(
+                userId, query.getNutritionTags(), query.getTypes(), query.getLimit());
+            if (pythonResults != null && !pythonResults.isEmpty()) {
+                log.info("[灰度] Recommend via LangGraph, userId={}", userId);
+                return Result.success(pythonResults);
+            }
+            log.info("[灰度] LangGraph 无结果/异常, fallback 本地搜索");
+        }
+
+        // 现有业务逻辑
         List<RecommendationResult> results = recommendationService.search(query);
         return Result.success(results);
     }

+ 145 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java

@@ -0,0 +1,145 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.dto.RecommendationResult;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+
+import javax.annotation.PostConstruct;
+import java.util.*;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * LangGraph Python 服务网关
+ * 统一管理 Python 服务调用、熔断、Fallback
+ */
+@Service
+public class AiGateway {
+
+    private static final Logger log = LoggerFactory.getLogger(AiGateway.class);
+
+    @Value("${python.enabled:false}")
+    private boolean enabled;
+
+    @Value("${python.base-url:http://localhost:9000}")
+    private String baseUrl;
+
+    @Value("${python.timeout-ms:15000}")
+    private int timeoutMs;
+
+    @Value("${python.circuit-breaker.failure-threshold:3}")
+    private int failureThreshold;
+
+    @Value("${python.circuit-breaker.reset-timeout-ms:30000}")
+    private int resetTimeoutMs;
+
+    private final RestTemplate restTemplate = new RestTemplate();
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    // 熔断器状态
+    private final AtomicInteger consecutiveFailures = new AtomicInteger(0);
+    private final AtomicLong lastFailureTime = new AtomicLong(0);
+    private volatile boolean circuitOpen = false;
+
+    @PostConstruct
+    public void init() {
+        if (enabled) {
+            log.info("AiGateway 已启用: baseUrl={}, timeout={}ms", baseUrl, timeoutMs);
+        } else {
+            log.info("AiGateway 已禁用, 所有请求走 Dify");
+        }
+    }
+
+    public boolean isEnabled() {
+        return enabled;
+    }
+
+    private boolean isCircuitOpen() {
+        if (!circuitOpen) return false;
+        // 检查是否到重置时间
+        if (System.currentTimeMillis() - lastFailureTime.get() > resetTimeoutMs) {
+            circuitOpen = false;
+            consecutiveFailures.set(0);
+            log.info("AiGateway 熔断器已半开");
+            return false;
+        }
+        return true;
+    }
+
+    private void recordFailure() {
+        int failures = consecutiveFailures.incrementAndGet();
+        lastFailureTime.set(System.currentTimeMillis());
+        if (failures >= failureThreshold) {
+            circuitOpen = true;
+            log.warn("AiGateway 熔断器已打开 (连续{}次失败)", failures);
+        }
+    }
+
+    /**
+     * 调用 Python 推荐服务, 失败时返回 null (由调用方决定 fallback)
+     */
+    public List<RecommendationResult> recommend(Long userId, List<String> tags, List<String> types, Integer limit) {
+        if (!enabled || isCircuitOpen()) return null;
+
+        try {
+            // 使用 ObjectMapper 构造 JSON 替代手动拼接
+            ObjectNode body = objectMapper.createObjectNode();
+            body.put("user_id", userId);
+            if (tags != null && !tags.isEmpty()) {
+                ArrayNode tagsArray = body.putArray("tags");
+                tags.forEach(tagsArray::add);
+            }
+            if (types != null && !types.isEmpty()) {
+                ArrayNode typesArray = body.putArray("types");
+                types.forEach(typesArray::add);
+            }
+            body.put("limit", limit != null ? limit : 5);
+
+            HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
+            String url = baseUrl + "/api/v1/recommend";
+
+            ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
+
+            if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
+                JsonNode root = objectMapper.readTree(response.getBody());
+                JsonNode items = root.get("items");
+                if (items != null && items.isArray()) {
+                    List<RecommendationResult> results = new ArrayList<>();
+                    for (JsonNode item : items) {
+                        RecommendationResult r = new RecommendationResult();
+                        r.setType(item.get("type").asText());
+                        r.setId(item.get("id").asLong());
+                        r.setName(item.get("name").asText());
+                        r.setDescription(item.has("description") ? item.get("description").asText() : "");
+                        r.setReason(item.has("reason") ? item.get("reason").asText() : "");
+                        r.setSource("langgraph");
+                        results.add(r);
+                    }
+                    consecutiveFailures.set(0);
+                    log.debug("AiGateway recommend 成功: {} 条", results.size());
+                    return results;
+                }
+            }
+            return null;
+        } catch (Exception e) {
+            log.warn("AiGateway recommend 调用失败: {}", e.getMessage());
+            recordFailure();
+            return null;
+        }
+    }
+
+    private org.springframework.http.HttpHeaders createJsonHeaders() {
+        org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders();
+        headers.setContentType(org.springframework.http.MediaType.APPLICATION_JSON);
+        return headers;
+    }
+}

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

@@ -89,6 +89,14 @@ dify:
   tongue-api-key: ""  # 舌诊分析,空字符串=mock模式
   callback-secret: dify-callback-secret  # Dify Workflow HTTP回调认证密钥
 
+python:
+  enabled: true
+  base-url: http://localhost:9000
+  timeout-ms: 15000
+  circuit-breaker:
+    failure-threshold: 3
+    reset-timeout-ms: 30000
+
 math:
   verify-mode: dify  # dify | eval; dify=走Dify工作流, eval=服务端计算(兜底)
   workflow-id: ""    # Dify 数学判题工作流 ID(留空则使用 eval 兜底)