Selaa lähdekoodia

feat: wire nutrition API into controllers + config

Add RecommendationController (POST /api/recommend/search), extend AIChatController with POST /api/ai/nutrition/send (incl. [RECOMMEND] tag parsing), add sendNutritionMessage() to AIService with dedicated API Key, and add nutrition-api-key to application.yml.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Sisyphus 3 kuukautta sitten
vanhempi
sitoutus
7b7e6be444

+ 162 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java

@@ -1,19 +1,28 @@
 package com.etotem.cfc.controller.ai;
 package com.etotem.cfc.controller.ai;
 
 
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.HealthAnalysisResult;
+import com.etotem.cfc.dto.RecommendationQuery;
+import com.etotem.cfc.dto.RecommendationResult;
 import com.etotem.cfc.service.AIService;
 import com.etotem.cfc.service.AIService;
 import com.etotem.cfc.service.FamilyContextService;
 import com.etotem.cfc.service.FamilyContextService;
+import com.etotem.cfc.service.HealthAnalysisService;
+import com.etotem.cfc.service.RecommendationService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.bind.annotation.*;
 
 
 import javax.annotation.Resource;
 import javax.annotation.Resource;
 import java.util.*;
 import java.util.*;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 
 /**
 /**
  * AI 聊天接口
  * AI 聊天接口
  * 对接 Dify Chatbot API,自动注入家庭上下文
  * 对接 Dify Chatbot API,自动注入家庭上下文
  */
  */
+@Slf4j
 @Tag(name = "AI聊天", description = "AI家庭助手聊天接口")
 @Tag(name = "AI聊天", description = "AI家庭助手聊天接口")
 @RestController
 @RestController
 @RequestMapping("/api/ai")
 @RequestMapping("/api/ai")
@@ -25,6 +34,12 @@ public class AIChatController {
     @Resource
     @Resource
     private FamilyContextService familyContextService;
     private FamilyContextService familyContextService;
 
 
+    @Resource
+    private HealthAnalysisService healthAnalysisService;
+
+    @Resource
+    private RecommendationService recommendationService;
+
     @Operation(summary = "发送聊天消息(支持传入reportId以解读报告)")
     @Operation(summary = "发送聊天消息(支持传入reportId以解读报告)")
     @PostMapping("/chat/send")
     @PostMapping("/chat/send")
     public Result<Map<String, Object>> sendMessage(
     public Result<Map<String, Object>> sendMessage(
@@ -97,4 +112,151 @@ public class AIChatController {
         aiService.deleteConversation(conversationId, String.valueOf(userId));
         aiService.deleteConversation(conversationId, String.valueOf(userId));
         return Result.success(null);
         return Result.success(null);
     }
     }
+
+    // ── 精准营养助手 ──
+
+    private static final Pattern RECOMMEND_PATTERN =
+            Pattern.compile("\\[RECOMMEND:\\s*(\\{.*?\\})\\]", Pattern.DOTALL);
+
+    @Operation(summary = "发送营养分析消息(基于菌群报告)")
+    @PostMapping("/nutrition/send")
+    public Result<Map<String, Object>> sendNutritionMessage(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, String> params) {
+        String query = params.get("query");
+        String conversationId = params.get("conversationId");
+        String reportIdStr = params.get("reportId");
+
+        if (query == null || query.trim().isEmpty()) {
+            return Result.error("消息不能为空");
+        }
+        if (reportIdStr == null || reportIdStr.trim().isEmpty()) {
+            return Result.error("需要reportId进行营养分析");
+        }
+
+        Long reportId = Long.valueOf(reportIdStr);
+
+        // 1. 菌群分析
+        HealthAnalysisResult analysis = healthAnalysisService.analyze(reportId);
+        if (analysis == null) {
+            return Result.error("报告不存在或分析失败: reportId=" + reportId);
+        }
+
+        // 2. 构建 Dify inputs(菌群分析 + 家庭上下文)
+        Map<String, Object> inputs = familyContextService.buildContext(userId);
+        inputs.put("health_analysis", analysis);
+
+        // 3. 调用精准营养助手
+        Map<String, Object> difyResp = aiService.sendNutritionMessage(
+                query, String.valueOf(userId), conversationId, inputs);
+
+        String answer = (String) difyResp.getOrDefault("answer", "");
+
+        // 4. 解析 [RECOMMEND] 标记
+        List<RecommendationResult> recommendations = null;
+        String cleanAnswer = answer;
+        Matcher matcher = RECOMMEND_PATTERN.matcher(answer);
+        if (matcher.find()) {
+            try {
+                String json = matcher.group(1);
+                // 简单 JSON 解析(不用 Jackson 避免异常链)
+                Map<String, Object> rec = parseSimpleJson(json);
+                if (rec != null) {
+                    @SuppressWarnings("unchecked")
+                    List<String> tags = (List<String>) rec.get("tags");
+                    @SuppressWarnings("unchecked")
+                    List<String> types = (List<String>) rec.get("types");
+                    Object limitObj = rec.get("limit");
+                    int limit = limitObj instanceof Number ? ((Number) limitObj).intValue() : 3;
+
+                    if (tags != null && !tags.isEmpty()) {
+                        RecommendationQuery rq = new RecommendationQuery();
+                        rq.setNutritionTags(tags);
+                        rq.setTypes(types != null && !types.isEmpty() ? types : null);
+                        rq.setLimit(limit);
+                        recommendations = recommendationService.search(rq);
+                    }
+                }
+            } catch (Exception e) {
+                log.warn("解析 [RECOMMEND] 标记失败, 忽略: {}", e.getMessage());
+            }
+            // 从回答中移除标记
+            cleanAnswer = matcher.replaceAll("").trim();
+        }
+
+        // 5. 组装结果
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("answer", cleanAnswer);
+        result.put("conversationId", difyResp.getOrDefault("conversationId", ""));
+        result.put("recommendations", recommendations != null ? recommendations : Collections.emptyList());
+        return Result.success(result);
+    }
+
+    /**
+     * 极简 JSON 解析(仅用于 [RECOMMEND] 标记中的简单对象)
+     * 避免引入 Jackson 依赖或抛异常
+     */
+    private Map<String, Object> parseSimpleJson(String json) {
+        json = json.trim();
+        if (!json.startsWith("{") || !json.endsWith("}")) return null;
+        json = json.substring(1, json.length() - 1).trim();
+
+        Map<String, Object> map = new LinkedHashMap<>();
+        // 按逗号分割顶层键值对(忽略引号内的逗号)
+        List<String> pairs = splitOutsideQuotes(json);
+        for (String pair : pairs) {
+            int colonIdx = pair.indexOf(':');
+            if (colonIdx < 0) continue;
+            String key = pair.substring(0, colonIdx).trim();
+            String value = pair.substring(colonIdx + 1).trim();
+            key = stripQuotes(key);
+            if (key.isEmpty()) continue;
+
+            if (value.startsWith("[") && value.endsWith("]")) {
+                // 解析数组
+                String inner = value.substring(1, value.length() - 1).trim();
+                List<String> items = new ArrayList<>();
+                for (String item : splitOutsideQuotes(inner)) {
+                    items.add(stripQuotes(item.trim()));
+                }
+                map.put(key, items);
+            } else {
+                map.put(key, stripQuotes(value));
+            }
+        }
+        return map;
+    }
+
+    private List<String> splitOutsideQuotes(String s) {
+        List<String> parts = new ArrayList<>();
+        int depth = 0;
+        boolean inStr = false;
+        int start = 0;
+        for (int i = 0; i < s.length(); i++) {
+            char c = s.charAt(i);
+            if (c == '"' && (i == 0 || s.charAt(i - 1) != '\\')) {
+                inStr = !inStr;
+            } else if (!inStr) {
+                if (c == '{' || c == '[') depth++;
+                else if (c == '}' || c == ']') depth--;
+                else if (c == ',' && depth == 0) {
+                    parts.add(s.substring(start, i));
+                    start = i + 1;
+                }
+            }
+        }
+        if (start < s.length()) {
+            parts.add(s.substring(start));
+        }
+        return parts;
+    }
+
+    private String stripQuotes(String s) {
+        if (s == null) return "";
+        s = s.trim();
+        if (s.startsWith("\"") && s.endsWith("\"") && s.length() >= 2) {
+            return s.substring(1, s.length() - 1);
+        }
+        return s;
+    }
 }
 }

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

@@ -0,0 +1,32 @@
+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.RecommendationService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.List;
+
+@Tag(name = "营养推荐", description = "精准营养推荐搜索")
+@RestController
+@RequestMapping("/api/recommend")
+public class RecommendationController {
+
+    @Resource
+    private RecommendationService recommendationService;
+
+    @Operation(summary = "按营养标签搜索推荐内容")
+    @PostMapping("/search")
+    public Result<List<RecommendationResult>> search(
+            @RequestBody RecommendationQuery query) {
+        List<RecommendationResult> results = recommendationService.search(query);
+        return Result.success(results);
+    }
+}

+ 34 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java

@@ -24,6 +24,9 @@ public class AIService {
     @Value("${dify.api-key}")
     @Value("${dify.api-key}")
     private String apiKey;
     private String apiKey;
 
 
+    @Value("${dify.nutrition-api-key}")
+    private String nutritionApiKey;
+
     private HttpHeaders authHeaders() {
     private HttpHeaders authHeaders() {
         HttpHeaders headers = new HttpHeaders();
         HttpHeaders headers = new HttpHeaders();
         headers.setContentType(MediaType.APPLICATION_JSON);
         headers.setContentType(MediaType.APPLICATION_JSON);
@@ -64,6 +67,37 @@ public class AIService {
         return result;
         return result;
     }
     }
 
 
+    /**
+     * 发送聊天消息到精准营养助手(使用专用 API Key)
+     */
+    public Map<String, Object> sendNutritionMessage(String query, String userId,
+                                                     String conversationId,
+                                                     Map<String, Object> inputs) {
+        String url = difyBaseUrl + "/chat-messages";
+
+        Map<String, Object> body = new LinkedHashMap<>();
+        body.put("query", query);
+        body.put("user", userId);
+        body.put("response_mode", "blocking");
+        body.put("conversation_id", conversationId != null ? conversationId : "");
+        body.put("inputs", inputs != null ? inputs : Collections.emptyMap());
+        body.put("auto_generate_name", true);
+
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_JSON);
+        headers.setBearerAuth(nutritionApiKey);
+
+        HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
+        ResponseEntity<Map> resp = restTemplate.postForEntity(url, entity, Map.class);
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        if (resp.getBody() != null) {
+            result.put("answer", resp.getBody().get("answer"));
+            result.put("conversationId", resp.getBody().get("conversation_id"));
+        }
+        return result;
+    }
+
     /**
     /**
      * 获取用户的所有会话列表
      * 获取用户的所有会话列表
      */
      */

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

@@ -72,6 +72,7 @@ wechat:
 dify:
 dify:
   base-url: http://dify.bianwoyou.cn/v1
   base-url: http://dify.bianwoyou.cn/v1
   api-key: app-rtNSAHG2NFzRsXCGkeZlVNR9
   api-key: app-rtNSAHG2NFzRsXCGkeZlVNR9
+  nutrition-api-key: app-OnHzTiI6EULUfLyceCDqJkQu  # 精准营养助手
 
 
 logging:
 logging:
   level:
   level: