Ver código fonte

feat: AI对话记忆层 - 会话摘要+关键事实+意图感知上下文注入

- 新增实体: AiConversationSummary, AiUserFact
- 新增映射器: AiConversationSummaryMapper, AiUserFactMapper
- 新增AiContextService: 7意图路由
- AIService: enrichInputsWithMemory, getContext, requestSummaryAsync
- AIChatController: getContext()端点 + sendMessage注入记忆
- DatabaseInitializer: runMigration81内建表
- schema.sql: 同步两张新表定义
- WebConfig: /api/ai/context加入JWT白名单
- chat.vue: 简化传参(移除reportId/surveyId)
Sisyphus Agent 2 meses atrás
pai
commit
2a14f72759

+ 38 - 1
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -5247,7 +5247,7 @@ try {
     }
 
     private void runMigration81() {
-        // 迁移 81: 创建 product_dimension_mapping 表(商品维度关联)
+
         try {
             jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_dimension_mapping (" +
                     "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
@@ -5267,6 +5267,43 @@ try {
             log.warn("创建 product_dimension_mapping 表失败:{}", e.getMessage());
         }
 
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS ai_conversation_summary (" +
+                "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                "user_id BIGINT NOT NULL COMMENT '用户ID', " +
+                "conversation_id VARCHAR(100) NOT NULL COMMENT 'Dify会话ID', " +
+                "summary_text TEXT COMMENT '对话摘要文本', " +
+                "summary_tokens INT DEFAULT 0 COMMENT '摘要token数', " +
+                "message_count INT DEFAULT 0 COMMENT '本次对话消息数', " +
+                "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
+                "INDEX idx_conversation (conversation_id), " +
+                "INDEX idx_user (user_id)" +
+            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI对话摘要'");
+            log.info("已创建ai_conversation_summary表");
+        } catch (Exception e) {
+            log.warn("创建ai_conversation_summary表失败: {}", e.getMessage());
+        }
+
+
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS ai_user_facts (" +
+                "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                "user_id BIGINT NOT NULL COMMENT '用户ID', " +
+                "fact_key VARCHAR(100) NOT NULL COMMENT '事实键', " +
+                "fact_value VARCHAR(500) COMMENT '事实值', " +
+                "source_conversation VARCHAR(100) COMMENT '来源会话ID', " +
+                "confidence DECIMAL(3,2) DEFAULT 1.00 COMMENT '置信度0-1', " +
+                "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
+                "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', " +
+                "INDEX idx_user_key (user_id, fact_key), " +
+                "INDEX idx_conversation (source_conversation)" +
+            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI用户关键事实'");
+            log.info("已创建ai_user_facts表");
+        } catch (Exception e) {
+            log.warn("创建ai_user_facts表失败: {}", e.getMessage());
+        }
+        }
+
         // 迁移 81: products 表添加 recommendation_tags 字段
         ensureColumn("products", "recommendation_tags", "VARCHAR(500) COMMENT '推荐标签 JSON' AFTER domain");
         // 迁移 81: products 表添加 purchase_count_threshold 字段

+ 2 - 1
cfc-backend/src/main/java/com/etotem/cfc/config/WebConfig.java

@@ -76,7 +76,8 @@ public class WebConfig implements WebMvcConfigurer {
                 "/api/articles/featured",
                 "/api/articles/record-read",
                 "/api/activity/list",
-                "/api/activity/detail"
+                "/api/activity/detail",
+                "/api/ai/context"
         );
 
         registry.addInterceptor(operationLogInterceptor)

+ 20 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java

@@ -100,7 +100,9 @@ public class AIChatController {
             inputs.put("mascot_persona", "");
         }
 
-        // 调用 Dify
+        // AI记忆层注入(会话摘要+关键事实)
+        inputs = aiService.enrichInputsWithMemory(userId, conversationId, inputs);
+
         Map<String, Object> difyResp = aiService.sendMessage(
                 query, String.valueOf(userId),
                 conversationId, inputs);
@@ -351,4 +353,21 @@ public class AIChatController {
         }
         return s;
     }
+
+    /**
+     * Dify Workflow HTTP 节点回调:获取用户上下文
+     * 用于意图分类后的上下文注入
+     */
+    @Operation(summary = "获取AI上下文(供Dify Workflow回调)", hidden = true)
+    @PostMapping("/context")
+    public Result<Map<String, Object>> getContext(@RequestBody Map<String, String> params) {
+        String userIdStr = params.get("user_id");
+        String conversationId = params.get("conversation_id");
+        if (userIdStr == null || conversationId == null) {
+            return Result.error("缺少参数: user_id, conversation_id");
+        }
+        Long userId = Long.valueOf(userIdStr);
+        Map<String, Object> ctx = aiService.getContext(userId, conversationId);
+        return Result.success(ctx);
+    }
 }

+ 36 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/AiConversationSummary.java

@@ -0,0 +1,36 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("ai_conversation_summary")
+public class AiConversationSummary implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 用户ID */
+    private Long userId;
+
+    /** Dify会话ID */
+    private String conversationId;
+
+    /** 对话摘要文本 */
+    private String summaryText;
+
+    /** 摘要token数 */
+    private Integer summaryTokens;
+
+    /** 本次对话消息数 */
+    private Integer messageCount;
+
+    /** 创建时间 */
+    private Date createdAt;
+}

+ 39 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/AiUserFact.java

@@ -0,0 +1,39 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("ai_user_facts")
+public class AiUserFact implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 用户ID */
+    private Long userId;
+
+    /** 事实键 */
+    private String factKey;
+
+    /** 事实值 */
+    private String factValue;
+
+    /** 来源会话ID */
+    private String sourceConversation;
+
+    /** 置信度0-1 */
+    private BigDecimal confidence;
+
+    /** 创建时间 */
+    private Date createdAt;
+
+    /** 更新时间 */
+    private Date updatedAt;
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/AiConversationSummaryMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.AiConversationSummary;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface AiConversationSummaryMapper extends BaseMapper<AiConversationSummary> {
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/AiUserFactMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.AiUserFact;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface AiUserFactMapper extends BaseMapper<AiUserFact> {
+}

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

@@ -7,9 +7,16 @@ import org.springframework.web.client.RestTemplate;
 
 import com.etotem.cfc.entity.EmotionCheckin;
 import com.etotem.cfc.mapper.EmotionCheckinMapper;
+import com.etotem.cfc.entity.AiConversationSummary;
+import com.etotem.cfc.entity.AiUserFact;
+import com.etotem.cfc.mapper.AiConversationSummaryMapper;
+import com.etotem.cfc.mapper.AiUserFactMapper;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import javax.annotation.Resource;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import org.springframework.scheduling.annotation.Async;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import java.util.*;
 import java.util.stream.Collectors;
 
@@ -43,6 +50,17 @@ public class AIService {
     @Resource
     private ChatMirrorService chatMirrorService;
 
+    @Resource
+    private AiConversationSummaryMapper aiConversationSummaryMapper;
+
+    @Resource
+    private AiUserFactMapper aiUserFactMapper;
+
+    @Value("${dify.callback-secret}")
+    private String callbackSecret;
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
     private HttpHeaders authHeaders() {
         HttpHeaders headers = new HttpHeaders();
         headers.setContentType(MediaType.APPLICATION_JSON);
@@ -332,4 +350,174 @@ public class AIService {
 
         return sb.toString();
     }
+
+    // ========== AI Memory Layer ==========
+
+    /**
+     * 获取用户上下文(供 Dify Workflow HTTP 节点回调)
+     * 返回: 会话摘要 + 近期摘要 + 关键事实
+     */
+    public Map<String, Object> getContext(Long userId, String conversationId) {
+        Map<String, Object> ctx = new LinkedHashMap<>();
+
+        // 当前会话摘要
+        LambdaQueryWrapper<AiConversationSummary> summaryQ = new LambdaQueryWrapper<>();
+        summaryQ.eq(AiConversationSummary::getConversationId, conversationId);
+        summaryQ.orderByDesc(AiConversationSummary::getCreatedAt);
+        summaryQ.last("LIMIT 1");
+        AiConversationSummary currentSummary = aiConversationSummaryMapper.selectOne(summaryQ);
+        if (currentSummary != null) {
+            ctx.put("conversation_summary", currentSummary.getSummaryText());
+        }
+
+        // 用户近期摘要(最近5条)
+        LambdaQueryWrapper<AiConversationSummary> recentQ = new LambdaQueryWrapper<>();
+        recentQ.eq(AiConversationSummary::getUserId, userId);
+        recentQ.orderByDesc(AiConversationSummary::getCreatedAt);
+        recentQ.last("LIMIT 5");
+        List<AiConversationSummary> recentList = aiConversationSummaryMapper.selectList(recentQ);
+        List<String> summaries = recentList.stream()
+                .map(AiConversationSummary::getSummaryText)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toList());
+        if (!summaries.isEmpty()) {
+            ctx.put("recent_summaries", summaries);
+        }
+
+        // 用户关键事实
+        LambdaQueryWrapper<AiUserFact> factQ = new LambdaQueryWrapper<>();
+        factQ.eq(AiUserFact::getUserId, userId);
+        List<AiUserFact> facts = aiUserFactMapper.selectList(factQ);
+        if (!facts.isEmpty()) {
+            Map<String, String> factMap = new LinkedHashMap<>();
+            for (AiUserFact f : facts) {
+                factMap.put(f.getFactKey(), f.getFactValue());
+            }
+            ctx.put("user_facts", factMap);
+        }
+
+        return ctx;
+    }
+
+    /**
+     * 调用 Dify Workflow 生成对话摘要并保存
+     */
+    @Async
+    public void requestSummaryAsync(Long userId, String conversationId, String recentMessages) {
+        try {
+            Map<String, Object> inputs = new LinkedHashMap<>();
+            inputs.put("conversation_id", conversationId);
+            inputs.put("messages_text", recentMessages);
+
+            String summaryUrl = difyBaseUrl + "/workflows/run";
+            Map<String, Object> body = new LinkedHashMap<>();
+            body.put("inputs", inputs);
+            body.put("response_mode", "blocking");
+            body.put("user", userId.toString());
+
+            HttpHeaders headers = authHeaders();
+            headers.set("X-Dify-Secret", callbackSecret);
+            HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
+            ResponseEntity<Map> resp = restTemplate.postForEntity(summaryUrl, entity, Map.class);
+
+            if (resp.getBody() != null) {
+                Map<String, Object> data = (Map<String, Object>) resp.getBody().get("data");
+                if (data != null) {
+                    Map<String, Object> outputs = (Map<String, Object>) data.get("outputs");
+                    if (outputs != null) {
+                        String summary = (String) outputs.get("summary");
+                        String factsJson = (String) outputs.get("facts");
+                        if (summary != null && !summary.isEmpty()) {
+                            saveSummary(userId, conversationId, summary);
+                        }
+                        if (factsJson != null && !factsJson.isEmpty()) {
+                            saveFacts(userId, factsJson, conversationId);
+                        }
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.warn("异步生成对话摘要失败: {}", e.getMessage());
+        }
+    }
+
+    /**
+     * 在 sendMessage 之前,用记忆数据丰富 inputs
+     */
+    public Map<String, Object> enrichInputsWithMemory(Long userId, String conversationId,
+                                                       Map<String, Object> originalInputs) {
+        Map<String, Object> enriched = new LinkedHashMap<>();
+        if (originalInputs != null) {
+            enriched.putAll(originalInputs);
+        }
+
+        // 最近摘要
+        LambdaQueryWrapper<AiConversationSummary> recentQ = new LambdaQueryWrapper<>();
+        recentQ.eq(AiConversationSummary::getUserId, userId);
+        recentQ.orderByDesc(AiConversationSummary::getCreatedAt);
+        recentQ.last("LIMIT 3");
+        List<AiConversationSummary> recentList = aiConversationSummaryMapper.selectList(recentQ);
+        List<String> summaries = recentList.stream()
+                .map(AiConversationSummary::getSummaryText)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toList());
+        if (!summaries.isEmpty()) {
+            enriched.put("conversation_summary", String.join("\n---\n", summaries));
+        }
+
+        // 用户事实
+        LambdaQueryWrapper<AiUserFact> factQ = new LambdaQueryWrapper<>();
+        factQ.eq(AiUserFact::getUserId, userId);
+        List<AiUserFact> facts = aiUserFactMapper.selectList(factQ);
+        if (!facts.isEmpty()) {
+            StringBuilder factSb = new StringBuilder();
+            for (AiUserFact f : facts) {
+                factSb.append(f.getFactKey()).append(": ").append(f.getFactValue()).append("\n");
+            }
+            enriched.put("user_facts", factSb.toString());
+        }
+
+        return enriched;
+    }
+
+    private void saveSummary(Long userId, String conversationId, String summaryText) {
+        try {
+            AiConversationSummary record = new AiConversationSummary();
+            record.setUserId(userId);
+            record.setConversationId(conversationId);
+            record.setSummaryText(summaryText);
+            aiConversationSummaryMapper.insert(record);
+            log.info("已保存对话摘要: conversationId={}", conversationId);
+        } catch (Exception e) {
+            log.warn("保存对话摘要失败: {}", e.getMessage());
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    private void saveFacts(Long userId, String factsJson, String conversationId) {
+        try {
+            List<Map<String, Object>> factList;
+            if (factsJson.trim().startsWith("[")) {
+                factList = objectMapper.readValue(factsJson, List.class);
+            } else {
+                Map<String, Object> single = objectMapper.readValue(factsJson, Map.class);
+                factList = Collections.singletonList(single);
+            }
+            for (Map<String, Object> fact : factList) {
+                String key = (String) fact.getOrDefault("key", fact.getOrDefault("fact_key", ""));
+                String value = (String) fact.getOrDefault("value", fact.getOrDefault("fact_value", ""));
+                if (key.isEmpty() || value.isEmpty()) continue;
+
+                AiUserFact record = new AiUserFact();
+                record.setUserId(userId);
+                record.setFactKey(key);
+                record.setFactValue(value);
+                record.setSourceConversation(conversationId);
+                aiUserFactMapper.insert(record);
+            }
+            log.info("已提取并保存{}条关键事实", factList.size());
+        } catch (Exception e) {
+            log.warn("保存关键事实失败: {}", e.getMessage());
+        }
+    }
 }

+ 347 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/AiContextService.java

@@ -0,0 +1,347 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.dto.ChildInfoDTO;
+import com.etotem.cfc.entity.AiConversationSummary;
+import com.etotem.cfc.entity.AiUserFact;
+import com.etotem.cfc.entity.EmotionCheckin;
+import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.AiConversationSummaryMapper;
+import com.etotem.cfc.mapper.AiUserFactMapper;
+import com.etotem.cfc.mapper.EmotionCheckinMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * AI 对话上下文服务
+ * 为 Dify Workflow 提供按意图获取的家庭数据
+ */
+@Service
+public class AiContextService {
+
+    @Resource
+    private UserService userService;
+
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private TaskService taskService;
+
+    @Resource
+    private HealthReportService healthReportService;
+
+    @Resource
+    private AiUserFactMapper aiUserFactMapper;
+
+    @Resource
+    private AiConversationSummaryMapper aiConversationSummaryMapper;
+
+    @Resource
+    private EmotionCheckinMapper emotionCheckinMapper;
+
+    /**
+     * 根据意图类型获取对应上下文数据
+     *
+     * @param intentType 意图类型:health_report / task_progress / child_info / emotion_status / user_identity / user_facts
+     * @param userId     当前用户ID
+     * @param params     可选参数(如 childId、reportId 等)
+     * @return Dify 格式的上下文数据
+     */
+    public Map<String, Object> getContext(String intentType, Long userId, Map<String, Object> params) {
+        if (intentType == null) {
+            intentType = "user_identity";
+        }
+        switch (intentType) {
+            case "health_report":
+                return getHealthReportContext(userId, params);
+            case "task_progress":
+                return getTaskProgressContext(userId, params);
+            case "child_info":
+                return getChildInfoContext(userId, params);
+            case "emotion_status":
+                return getEmotionStatusContext(userId, params);
+            case "user_identity":
+            default:
+                return getUserIdentityContext(userId);
+            case "user_facts":
+                return getUserFactsContext(userId);
+            case "recent_summaries":
+                return getRecentSummariesContext(userId, params);
+        }
+    }
+
+    /** 用户身份基础信息(始终返回) */
+    private Map<String, Object> getUserIdentityContext(Long userId) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        User user = userMapper.selectById(userId);
+        if (user == null) {
+            result.put("has_data", false);
+            return result;
+        }
+        result.put("has_data", true);
+        result.put("用户姓名", user.getNickname() != null ? user.getNickname() : "");
+        result.put("用户角色", user.getRole() != null ? user.getRole() : "parent");
+        result.put("总能量", user.getTotalPoints() != null ? user.getTotalPoints() : 0);
+
+        // 家庭成员简要列表
+        List<ChildInfoDTO> children = userService.getChildren(userId);
+        if (children != null && !children.isEmpty()) {
+            List<Map<String, Object>> childList = new ArrayList<>();
+            for (ChildInfoDTO child : children) {
+                Map<String, Object> c = new LinkedHashMap<>();
+                c.put("姓名", child.getNickname() != null ? child.getNickname() : "");
+                c.put("年龄", child.getAge() != null ? child.getAge() : 0);
+                c.put("能量", child.getTotalPoints() != null ? child.getTotalPoints() : 0);
+                c.put("连续打卡", child.getStreakDays() != null ? child.getStreakDays() : 0);
+                childList.add(c);
+            }
+            result.put("家庭成员", childList);
+        } else {
+            result.put("家庭成员", Collections.emptyList());
+        }
+        return result;
+    }
+
+    /** 健康报告上下文 */
+    private Map<String, Object> getHealthReportContext(Long userId, Map<String, Object> params) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("数据类型", "健康报告");
+
+        Long targetUserId = userId;
+        if (params != null && params.get("userId") != null) {
+            targetUserId = Long.valueOf(params.get("userId").toString());
+        }
+
+        HealthReport report = healthReportService.getLatestReport(targetUserId);
+        if (report == null) {
+            result.put("has_data", false);
+            result.put("提示", "暂无健康报告数据");
+            return result;
+        }
+        result.put("has_data", true);
+        result.put("报告日期", report.getReportDate() != null ? report.getReportDate().toString() : "");
+        result.put("整体评分", report.getOverallScore() != null ? report.getOverallScore() : "未知");
+        result.put("肠道健康评分", report.getGutHealthScore() != null ? report.getGutHealthScore() : "未知");
+        result.put("慢病风险评分", report.getChronicDiseaseScore() != null ? report.getChronicDiseaseScore() : "未知");
+        result.put("营养评分", report.getNutritionScore() != null ? report.getNutritionScore() : "未知");
+        result.put("肠道类型", report.getGutType() != null ? report.getGutType() : "未知");
+
+        // 可选:获取详细报告
+        if (params != null && Boolean.TRUE.equals(params.get("includeDetail"))) {
+            Map<String, Object> detail = healthReportService.getReportDetail(report.getId());
+            if (detail != null) {
+                result.put("报告详情", detail);
+            }
+        }
+        return result;
+    }
+
+    /** 任务进度上下文 */
+    private Map<String, Object> getTaskProgressContext(Long userId, Map<String, Object> params) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("数据类型", "任务进度");
+
+        Long targetUserId = userId;
+        if (params != null && params.get("childId") != null) {
+            targetUserId = Long.valueOf(params.get("childId").toString());
+        }
+
+        List<ChildInfoDTO> children = userService.getChildren(userId);
+
+        List<Map<String, Object>> childTaskList = new ArrayList<>();
+        if (children != null) {
+            for (ChildInfoDTO child : children) {
+                Long childId = child.getId();
+                if (targetUserId != null && !targetUserId.equals(userId)) {
+                    // 如果指定了 childId,只返回该孩子
+                    if (!childId.equals(targetUserId)) {
+                        continue;
+                    }
+                }
+                Map<String, Object> childTasks = new LinkedHashMap<>();
+                childTasks.put("姓名", child.getNickname() != null ? child.getNickname() : "");
+                childTasks.put("年龄", child.getAge() != null ? child.getAge() : 0);
+
+                Map<String, Object> stats = taskService.getChildTaskStats(childId);
+                childTasks.put("任务完成率", stats.getOrDefault("completionRate", 0) + "%");
+                childTasks.put("完成任务数", stats.getOrDefault("completedCount", 0));
+                childTasks.put("总任务数", stats.getOrDefault("totalCount", 0));
+                childTasks.put("连续打卡天数", child.getStreakDays() != null ? child.getStreakDays() : 0);
+                childTaskList.add(childTasks);
+            }
+        }
+
+        result.put("children_tasks", childTaskList);
+        result.put("has_data", !childTaskList.isEmpty());
+        return result;
+    }
+
+    /** 孩子详细信息上下文 */
+    private Map<String, Object> getChildInfoContext(Long userId, Map<String, Object> params) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("数据类型", "孩子信息");
+
+        List<ChildInfoDTO> children = userService.getChildren(userId);
+        if (children == null || children.isEmpty()) {
+            result.put("has_data", false);
+            result.put("提示", "暂无孩子信息");
+            return result;
+        }
+
+        // 如果指定了孩子姓名,筛选
+        String childName = params != null ? (String) params.get("childName") : null;
+        List<Map<String, Object>> childList = new ArrayList<>();
+
+        for (ChildInfoDTO child : children) {
+            if (childName != null && !childName.isEmpty()) {
+                String nickname = child.getNickname() != null ? child.getNickname() : "";
+                if (!nickname.contains(childName) && !childName.contains(nickname)) {
+                    continue;
+                }
+            }
+
+            Map<String, Object> c = new LinkedHashMap<>();
+            c.put("姓名", child.getNickname() != null ? child.getNickname() : "");
+            c.put("年龄", child.getAge() != null ? child.getAge() : 0);
+            c.put("性别", child.getGender() != null ? child.getGender() : "");
+            c.put("能量", child.getTotalPoints() != null ? child.getTotalPoints() : 0);
+            c.put("连续打卡", child.getStreakDays() != null ? child.getStreakDays() : 0);
+            c.put("用户ID", child.getId());
+
+            // 健康报告
+            HealthReport report = healthReportService.getLatestReport(child.getId());
+            if (report != null) {
+                Map<String, Object> reportInfo = new LinkedHashMap<>();
+                reportInfo.put("报告日期", report.getReportDate() != null ? report.getReportDate().toString() : "");
+                reportInfo.put("整体评分", report.getOverallScore());
+                reportInfo.put("肠道类型", report.getGutType() != null ? report.getGutType() : "");
+                c.put("健康报告", reportInfo);
+            }
+
+            // 任务统计
+            Map<String, Object> taskStats = taskService.getChildTaskStats(child.getId());
+            c.put("任务完成率", taskStats.getOrDefault("completionRate", 0) + "%");
+
+            childList.add(c);
+        }
+
+        result.put("children", childList);
+        result.put("has_data", !childList.isEmpty());
+        return result;
+    }
+
+    /** 情绪状态上下文 */
+    private Map<String, Object> getEmotionStatusContext(Long userId, Map<String, Object> params) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("数据类型", "情绪状态");
+
+        int days = 7; // 默认查最近7天
+        if (params != null && params.get("days") != null) {
+            days = Integer.valueOf(params.get("days").toString());
+        }
+
+        List<EmotionCheckin> recentCheckins = emotionCheckinMapper.selectList(
+            new LambdaQueryWrapper<EmotionCheckin>()
+                .eq(EmotionCheckin::getChildId, userId)
+                .orderByDesc(EmotionCheckin::getCreatedAt)
+                .last("LIMIT " + days)
+        );
+
+        if (recentCheckins == null || recentCheckins.isEmpty()) {
+            result.put("has_data", false);
+            result.put("提示", "最近暂无情绪打卡记录");
+            return result;
+        }
+
+        List<Map<String, Object>> checkinList = new ArrayList<>();
+        for (EmotionCheckin checkin : recentCheckins) {
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("日期", checkin.getCreatedAt() != null ? checkin.getCreatedAt().toString() : "");
+            item.put("心情评分", checkin.getMoodScore() != null ? checkin.getMoodScore().toString() : "");
+            item.put("情绪标签", checkin.getEmotionTags() != null ? checkin.getEmotionTags() : "");
+            item.put("备注", checkin.getNote() != null ? checkin.getNote() : "");
+            checkinList.add(item);
+        }
+
+        result.put("has_data", true);
+        result.put("打卡记录", checkinList);
+        result.put("最近天数", days);
+        return result;
+    }
+
+    /** 用户关键事实(Layer 2) */
+    private Map<String, Object> getUserFactsContext(Long userId) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("数据类型", "用户关键事实");
+
+        List<AiUserFact> facts = aiUserFactMapper.selectList(
+            new LambdaQueryWrapper<AiUserFact>()
+                .eq(AiUserFact::getUserId, userId)
+                .orderByDesc(AiUserFact::getUpdatedAt)
+                .last("LIMIT 20")
+        );
+
+        if (facts == null || facts.isEmpty()) {
+            result.put("has_data", false);
+            result.put("提示", "暂无记录的关键事实");
+            return result;
+        }
+
+        List<Map<String, Object>> factList = new ArrayList<>();
+        for (AiUserFact fact : facts) {
+            Map<String, Object> f = new LinkedHashMap<>();
+            f.put("事实", fact.getFactKey() + ":" + fact.getFactValue());
+            f.put("置信度", fact.getConfidence() != null ? fact.getConfidence() : BigDecimal.ONE);
+            factList.add(f);
+        }
+
+        result.put("has_data", true);
+        result.put("事实列表", factList);
+        return result;
+    }
+
+    /** 最近会话摘要(Layer 1) */
+    private Map<String, Object> getRecentSummariesContext(Long userId, Map<String, Object> params) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("数据类型", "最近对话摘要");
+
+        int limit = 5;
+        if (params != null && params.get("limit") != null) {
+            limit = Integer.valueOf(params.get("limit").toString());
+        }
+
+        List<AiConversationSummary> summaries = aiConversationSummaryMapper.selectList(
+            new LambdaQueryWrapper<AiConversationSummary>()
+                .eq(AiConversationSummary::getUserId, userId)
+                .orderByDesc(AiConversationSummary::getCreatedAt)
+                .last("LIMIT " + limit)
+        );
+
+        if (summaries == null || summaries.isEmpty()) {
+            result.put("has_data", false);
+            result.put("提示", "暂无历史对话摘要");
+            return result;
+        }
+
+        List<Map<String, Object>> summaryList = new ArrayList<>();
+        for (AiConversationSummary summary : summaries) {
+            Map<String, Object> s = new LinkedHashMap<>();
+            s.put("摘要", summary.getSummaryText());
+            s.put("对话消息数", summary.getMessageCount());
+            s.put("时间", summary.getCreatedAt() != null ? summary.getCreatedAt().toString() : "");
+            summaryList.add(s);
+        }
+
+        result.put("has_data", true);
+        result.put("历史摘要", summaryList);
+        return result;
+    }
+}

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

@@ -85,6 +85,7 @@ dify:
   api-key: app-rtNSAHG2NFzRsXCGkeZlVNR9
   nutrition-api-key: app-OnHzTiI6EULUfLyceCDqJkQu  # 精准营养助手
   tongue-api-key: ""  # 舌诊分析,空字符串=mock模式
+  callback-secret: dify-callback-secret  # Dify Workflow HTTP回调认证密钥
 
 math:
   verify-mode: dify  # dify | eval; dify=走Dify工作流, eval=服务端计算(兜底)

+ 27 - 0
cfc-backend/src/main/resources/schema.sql

@@ -2762,3 +2762,30 @@ CREATE TABLE IF NOT EXISTS social_circle_member (
   INDEX idx_member (member_id, member_type),
   INDEX idx_circle_id (circle_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='圈子成员';
+
+-- AI对话摘要
+CREATE TABLE IF NOT EXISTS ai_conversation_summary (
+  id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  user_id BIGINT NOT NULL COMMENT '用户ID',
+  conversation_id VARCHAR(100) NOT NULL COMMENT 'Dify会话ID',
+  summary_text TEXT COMMENT '对话摘要文本',
+  summary_tokens INT DEFAULT 0 COMMENT '摘要token数',
+  message_count INT DEFAULT 0 COMMENT '本次对话消息数',
+  created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  INDEX idx_conversation (conversation_id),
+  INDEX idx_user (user_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI对话摘要';
+
+-- AI用户关键事实
+CREATE TABLE IF NOT EXISTS ai_user_facts (
+  id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  user_id BIGINT NOT NULL COMMENT '用户ID',
+  fact_key VARCHAR(100) NOT NULL COMMENT '事实键',
+  fact_value VARCHAR(500) COMMENT '事实值',
+  source_conversation VARCHAR(100) COMMENT '来源会话ID',
+  confidence DECIMAL(3,2) DEFAULT 1.00 COMMENT '置信度0-1',
+  created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  INDEX idx_user_key (user_id, fact_key),
+  INDEX idx_conversation (source_conversation)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI用户关键事实';

+ 1 - 11
cfc-frontend/pages/ai/chat.vue

@@ -135,8 +135,6 @@ export default {
       showConversations: false,
       scrollToId: '',
       pageLoaded: false,
-      reportId: null,
-      surveyId: null,
       pendingFirstMessage: '',
       fromPage: '',
       mode: '',
@@ -174,8 +172,6 @@ export default {
     }
   },
   onLoad: function(options) {
-    this.reportId = options.reportId ? parseInt(options.reportId) : null
-    this.surveyId = options.surveyId ? parseInt(options.surveyId) : null
     this.mode = options.mode || ''
     this.fromPage = options.fromPage ? decodeURIComponent(options.fromPage) : ''
     if (options.firstMessage) {
@@ -272,15 +268,9 @@ export default {
           query: text,
           conversationId: this.currentConversationId
         }
-        if (this.reportId) {
-          params.reportId = this.reportId
-        }
-        if (this.surveyId) {
-          params.surveyId = this.surveyId
-        }
 
         var res, data
-        if (this.isNutrition && this.reportId) {
+        if (this.mode === 'nutrition') {
           // 营养模式:调精准营养助手
           res = await aiSendNutritionMessage(params)
           data = res.data || {}