Procházet zdrojové kódy

feat: LLM推送数据PII脱敏(智能脱敏)

- 新增PiisService: 递归脱敏Map/List/文本, 姓名→固定化名, 电话/身份证/邮箱→[已脱敏]
- FamilyContextService: 用户/孩子姓名脱敏
- AiGateway.chat/generateQuestionnaire/generateMenu/generateHealthPlan: 入口脱敏
- AIService: sendNutritionMessage/sendTongueDiagnosis/runWorkflow/enrichInputsWithMemory/mirrorConversation/getContext/requestSummaryAsync: 入口脱敏
- 化名映射: userId+salt→SHA-256→固定化名, 保证AI可识别同一用户
Xiaogang Liao před 2 měsíci
rodič
revize
a6190ef43e

+ 48 - 19
cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java

@@ -36,6 +36,9 @@ public class AIService {
     @Resource
     private AiGateway aiGateway;
 
+    @Resource
+    private PiisService piisService;
+
     @Value("${dify.base-url}")
     private String difyBaseUrl;
 
@@ -100,16 +103,21 @@ public class AIService {
     }
 
     private void mirrorConversation(Long userId, String difyConversationId,
-                                     String assistantType, String userQuery,
-                                     String assistantAnswer, Map<String, Object> inputs) {
+                                      String assistantType, String userQuery,
+                                      String assistantAnswer, Map<String, Object> inputs) {
         try {
+            // PII脱敏
+            userQuery = piisService.sanitizeText(userQuery);
+            assistantAnswer = piisService.sanitizeText(assistantAnswer);
+            Map<String, Object> sanitizedInputs = piisService.sanitizeContext(inputs, userId);
+
             Long convId = chatMirrorService.upsertConversation(userId, difyConversationId, assistantType, null);
             List<Map<String, Object>> messages = new ArrayList<>();
             Map<String, Object> userMsg = new LinkedHashMap<>();
             userMsg.put("role", "user");
             userMsg.put("content", userQuery);
-            if (inputs != null && !inputs.isEmpty()) {
-                userMsg.put("inputs", inputs);
+            if (sanitizedInputs != null && !sanitizedInputs.isEmpty()) {
+                userMsg.put("inputs", sanitizedInputs);
             }
             messages.add(userMsg);
             Map<String, Object> assistantMsg = new LinkedHashMap<>();
@@ -126,8 +134,12 @@ public class AIService {
      * 发送聊天消息到精准营养助手(使用专用 API Key)
      */
     public Map<String, Object> sendNutritionMessage(String query, String userId,
-                                                     String conversationId,
-                                                     Map<String, Object> inputs) {
+                                                      String conversationId,
+                                                      Map<String, Object> inputs) {
+        // PII脱敏
+        query = piisService.sanitizeText(query);
+        Map<String, Object> sanitizedInputs = piisService.sanitizeContext(inputs, Long.valueOf(userId));
+
         String url = difyBaseUrl + "/chat-messages";
 
         Map<String, Object> body = new LinkedHashMap<>();
@@ -135,7 +147,7 @@ public class AIService {
         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("inputs", sanitizedInputs != null ? sanitizedInputs : Collections.emptyMap());
         body.put("auto_generate_name", true);
 
         HttpHeaders headers = new HttpHeaders();
@@ -206,9 +218,14 @@ public class AIService {
     }
 
     public Map<String, Object> runWorkflow(String workflowId, Map<String, Object> inputs, String userId) {
+        // PII脱敏
+        Long uid = null;
+        try { uid = Long.valueOf(userId); } catch (Exception ignored) {}
+        Map<String, Object> sanitizedInputs = piisService.sanitizeContext(inputs, uid);
+
         String url = difyBaseUrl + "/workflows/" + workflowId + "/run";
         Map<String, Object> body = new LinkedHashMap<>();
-        body.put("inputs", inputs != null ? inputs : Collections.emptyMap());
+        body.put("inputs", sanitizedInputs != null ? sanitizedInputs : Collections.emptyMap());
         body.put("response_mode", "blocking");
         body.put("user", userId);
         HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, authHeaders());
@@ -235,17 +252,20 @@ public class AIService {
      */
     @SuppressWarnings("unchecked")
     public Map<String, Object> sendTongueDiagnosis(String imageUrl, String userId, Map<String, Object> inputs) {
-        // 如果未配置 tongue-api-key,返回模拟数据以便开发
         if (tongueApiKey == null || tongueApiKey.isEmpty()) {
             return mockTongueResult();
         }
 
-        // 调用 Dify Workflow API 分析舌象
+        // PII脱敏
+        Long uid = null;
+        try { uid = Long.valueOf(userId); } catch (Exception ignored) {}
+        Map<String, Object> sanitizedInputs = piisService.sanitizeContext(inputs, uid);
+
         String url = difyBaseUrl + "/workflows/run";
         HttpHeaders headers = tongueAuthHeaders();
 
         Map<String, Object> body = new LinkedHashMap<>();
-        body.put("inputs", inputs != null ? inputs : Collections.emptyMap());
+        body.put("inputs", sanitizedInputs != null ? sanitizedInputs : Collections.emptyMap());
         body.put("user", userId);
         body.put("response_mode", "blocking");
 
@@ -253,7 +273,7 @@ public class AIService {
         Map<String, Object> fileInput = new HashMap<>();
         fileInput.put("type", "image");
         fileInput.put("url", imageUrl);
-        inputs.put("tongue_image", fileInput);
+        sanitizedInputs.put("tongue_image", fileInput);
 
         HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
         try {
@@ -361,7 +381,7 @@ public class AIService {
         SortUtil.applySort(summaryQ);
         AiConversationSummary currentSummary = aiConversationSummaryMapper.selectOne(summaryQ);
         if (currentSummary != null) {
-            ctx.put("conversation_summary", currentSummary.getSummaryText());
+            ctx.put("conversation_summary", piisService.sanitizeText(currentSummary.getSummaryText()));
         }
 
         // 用户近期摘要(最近5条)
@@ -374,6 +394,7 @@ public class AIService {
         List<String> summaries = recentList.stream()
                 .map(AiConversationSummary::getSummaryText)
                 .filter(Objects::nonNull)
+                .map(s -> piisService.sanitizeText(s))
                 .collect(Collectors.toList());
         if (!summaries.isEmpty()) {
             ctx.put("recent_summaries", summaries);
@@ -387,7 +408,7 @@ public class AIService {
         if (!facts.isEmpty()) {
             Map<String, String> factMap = new LinkedHashMap<>();
             for (AiUserFact f : facts) {
-                factMap.put(f.getFactKey(), f.getFactValue());
+                factMap.put(piisService.sanitizeText(f.getFactKey()), piisService.sanitizeText(f.getFactValue()));
             }
             ctx.put("user_facts", factMap);
         }
@@ -401,9 +422,12 @@ public class AIService {
     @Async
     public void requestSummaryAsync(Long userId, String conversationId, String recentMessages) {
         try {
+            // PII脱敏
+            String sanitizedMessages = piisService.sanitizeText(recentMessages);
+
             Map<String, Object> inputs = new LinkedHashMap<>();
             inputs.put("conversation_id", conversationId);
-            inputs.put("messages_text", recentMessages);
+            inputs.put("messages_text", sanitizedMessages);
 
             String summaryUrl = difyBaseUrl + "/workflows/run";
             Map<String, Object> body = new LinkedHashMap<>();
@@ -441,13 +465,13 @@ public class AIService {
      * 在 sendMessage 之前,用记忆数据丰富 inputs
      */
     public Map<String, Object> enrichInputsWithMemory(Long userId, String conversationId,
-                                                       Map<String, Object> originalInputs) {
+                                                        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);
@@ -457,12 +481,13 @@ public class AIService {
         List<String> summaries = recentList.stream()
                 .map(AiConversationSummary::getSummaryText)
                 .filter(Objects::nonNull)
+                .map(s -> piisService.sanitizeText(s))
                 .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);
         SortUtil.applySort(factQ);
@@ -470,7 +495,11 @@ public class AIService {
         if (!facts.isEmpty()) {
             StringBuilder factSb = new StringBuilder();
             for (AiUserFact f : facts) {
-                factSb.append(f.getFactKey()).append(": ").append(f.getFactValue()).append("\n");
+                String key = piisService.sanitizeText(f.getFactKey());
+                String value = piisService.sanitizeText(f.getFactValue());
+                if (key != null && !key.isEmpty()) {
+                    factSb.append(key).append(": ").append(value != null ? value : "").append("\n");
+                }
             }
             enriched.put("user_facts", factSb.toString());
         }

+ 28 - 11
cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java

@@ -17,6 +17,7 @@ import java.util.*;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.concurrent.atomic.AtomicLong;
 import javax.annotation.PostConstruct;
+import javax.annotation.Resource;
 
 /**
  * LangGraph Python 服务网关
@@ -50,6 +51,9 @@ public class AiGateway {
     private final AtomicLong lastFailureTime = new AtomicLong(0);
     private volatile boolean circuitOpen = false;
 
+    @Resource
+    private PiisService piisService;
+
     @PostConstruct
     public void init() {
         if (enabled) {
@@ -142,18 +146,22 @@ public class AiGateway {
      * 调用 Python ChatAgent, 失败时返回 null
      */
     public Map<String, Object> chat(String query, Long userId, String conversationId,
-                                    Map<String, Object> inputs) {
+                                     Map<String, Object> inputs) {
         if (!enabled || isCircuitOpen()) return null;
 
         try {
+            // PII脱敏
+            query = piisService.sanitizeText(query);
+            Map<String, Object> sanitizedInputs = piisService.sanitizeContext(inputs, userId);
+
             ObjectNode body = objectMapper.createObjectNode();
             body.put("query", query);
             body.put("user_id", userId);
             body.put("conversation_id", conversationId != null ? conversationId : "");
 
-            if (inputs != null && !inputs.isEmpty()) {
+            if (sanitizedInputs != null && !sanitizedInputs.isEmpty()) {
                 ObjectNode ctx = body.putObject("context");
-                inputs.forEach((key, value) -> {
+                sanitizedInputs.forEach((key, value) -> {
                     if (value instanceof String) ctx.put(key, (String) value);
                     else if (value instanceof Number) ctx.put(key, ((Number) value).doubleValue());
                     else if (value instanceof Boolean) ctx.put(key, (Boolean) value);
@@ -186,11 +194,14 @@ public class AiGateway {
         if (!enabled || isCircuitOpen()) return null;
 
         try {
+            // PII脱敏
+            Map<String, Object> sanitizedInputs = piisService.sanitizeContext(inputs, null);
+
             ObjectNode body = objectMapper.createObjectNode();
-            body.put("member_name", inputs.getOrDefault("member_name", "").toString());
-            body.put("relationship_type", inputs.getOrDefault("relationship_type", "").toString());
-            if (inputs.containsKey("family_context")) {
-                body.set("family_context", objectMapper.valueToTree(inputs.get("family_context")));
+            body.put("member_name", sanitizedInputs.getOrDefault("member_name", "").toString());
+            body.put("relationship_type", sanitizedInputs.getOrDefault("relationship_type", "").toString());
+            if (sanitizedInputs.containsKey("family_context")) {
+                body.set("family_context", objectMapper.valueToTree(sanitizedInputs.get("family_context")));
             }
 
             HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
@@ -284,11 +295,15 @@ public class AiGateway {
      */
     public String generateMenu(String selectedFoodsJson, String participantsJson, String date) {
         if (!enabled || isCircuitOpen()) return null;
-        
+
         try {
+            // PII脱敏
+            String sanitizedFoods = piisService.sanitizeText(selectedFoodsJson);
+            String sanitizedParticipants = piisService.sanitizeText(participantsJson);
+
             ObjectNode body = objectMapper.createObjectNode();
-            body.put("selected_foods", selectedFoodsJson);
-            body.put("participants", participantsJson);
+            body.put("selected_foods", sanitizedFoods);
+            body.put("participants", sanitizedParticipants);
             body.put("date", date);
             
             HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
@@ -315,7 +330,9 @@ public class AiGateway {
     public String generateHealthPlan(Map<String, Object> inputs) {
         if (!enabled || isCircuitOpen()) return null;
         try {
-            ObjectNode body = objectMapper.valueToTree(inputs);
+            // PII脱敏
+            Map<String, Object> sanitizedInputs = piisService.sanitizeContext(inputs, null);
+            ObjectNode body = objectMapper.valueToTree(sanitizedInputs);
             HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
             String url = baseUrl + "/api/v1/analysis/run";
             ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);

+ 5 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyContextService.java

@@ -54,6 +54,9 @@ public class FamilyContextService {
     @Resource
     private GrowthTaskMapper growthTaskMapper;
 
+    @Resource
+    private PiisService piisService;
+
     /**
      * 构建当前用户的家庭上下文
      *
@@ -67,7 +70,7 @@ public class FamilyContextService {
         User currentUser = userMapper.selectById(userId);
         if (currentUser != null) {
             Map<String, Object> userInfo = new LinkedHashMap<>();
-            userInfo.put("nickname", currentUser.getNickname() != null ? currentUser.getNickname() : "");
+            userInfo.put("name", piisService.getAlias(currentUser.getNickname(), userId));
             userInfo.put("role", currentUser.getRole() != null ? currentUser.getRole() : "parent");
             userInfo.put("totalPoints", currentUser.getTotalPoints() != null ? currentUser.getTotalPoints() : 0);
             ctx.put("user", userInfo);
@@ -95,7 +98,7 @@ public class FamilyContextService {
         if (children != null) {
             for (ChildInfoDTO child : children) {
                 Map<String, Object> c = new LinkedHashMap<>();
-                c.put("name", child.getNickname() != null ? child.getNickname() : "");
+                c.put("name", piisService.getAlias(child.getNickname(), userId));
                 c.put("age", child.getAge() != null ? child.getAge() : 0);
                 c.put("totalPoints", child.getTotalPoints() != null ? child.getTotalPoints() : 0);
                 c.put("streakDays", child.getStreakDays() != null ? child.getStreakDays() : 0);

+ 191 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/PiisService.java

@@ -0,0 +1,191 @@
+package com.etotem.cfc.service;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.PostConstruct;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.*;
+import java.util.regex.Pattern;
+
+/**
+ * PII 脱敏服务
+ * 推送给 LLM/Dify/LangGraph 的数据必须经过本服务脱敏
+ * 采用智能脱敏:姓名→固定化名,手机号/身份证/邮箱/详细地址→[已脱敏]
+ */
+@Service
+public class PiisService {
+
+    private static final Logger log = LoggerFactory.getLogger(PiisService.class);
+
+    private static final String SANITIZED = "[已脱敏]";
+
+    private static final String[] NAME_FIELDS = {
+        "name", "nickname", "personName", "memberName", "author", "userName",
+        "childName", "parentName", "realName", "trueName", "person_name", "member_name",
+        "child_name", "parent_name", "user_name", "title", "fullName", "realname",
+        "displayName", "screenName", "contactName"
+    };
+
+    private static final String[] REMOVE_FIELDS = {
+        "phone", "mobile", "telephone", "idCard", "idNumber", "email", "address",
+        "schoolAddress", "livingAddress", "homeAddress", "workAddress", "detailedAddress",
+        "id_card", "id_number", "phone_number"
+    };
+
+    private static final String[] TEXT_FIELDS = {
+        "summary", "content", "description", "remark", "note", "notes",
+        "user_facts", "conversation_summary", "recent_summaries",
+        "reportSurvey", "findings", "suggestions"
+    };
+
+    private static final Pattern PHONE_PATTERN = Pattern.compile("(?<!\\d)1[3-9]\\d{9}(?!\\d)");
+    private static final Pattern ID_CARD_PATTERN = Pattern.compile("(?<!\\d)\\d{17}[0-9xX](?!\\d)");
+    private static final Pattern EMAIL_PATTERN = Pattern.compile("[\\w.%+-]+@[\\w.-]+\\.[a-zA-Z]{2,}");
+    private static final Pattern ADDRESS_PATTERN = Pattern.compile("[\\u4e00-\\u9fa5]{2,4}(?:省|市|区|县|镇|乡|村|街道|路|街|巷|弄|号)");
+
+    private String aliasSalt;
+
+    @PostConstruct
+    public void init() {
+        aliasSalt = "cfc-pii-" + UUID.randomUUID().toString().replace("-", "");
+    }
+
+    /**
+     * 按 userId 生成固定化名(同一用户始终对应同一化名)
+     */
+    public String getAlias(Long userId) {
+        if (userId == null) return "用户X";
+        try {
+            MessageDigest md = MessageDigest.getInstance("SHA-256");
+            md.update((userId + "-" + aliasSalt).getBytes(StandardCharsets.UTF_8));
+            byte[] hash = md.digest();
+            int prefix = (hash[0] & 0xFF);
+            char ch = (char) ('A' + (prefix % 26));
+            int num = ((hash[1] & 0xFF) % 9) + 1;
+            return "用户" + ch + num;
+        } catch (Exception e) {
+            return "用户" + (userId % 26 + 65);
+        }
+    }
+
+    /**
+     * 按字段名 + userId 生成固定化名(用于家庭成员等场景,不同成员有不同化名)
+     */
+    public String getAlias(String name, Long userId) {
+        if (userId == null || name == null || name.isEmpty()) {
+            return getAlias(null);
+        }
+        try {
+            MessageDigest md = MessageDigest.getInstance("SHA-256");
+            md.update((name + "-" + userId + "-" + aliasSalt).getBytes(StandardCharsets.UTF_8));
+            byte[] hash = md.digest();
+            int prefix = (hash[0] & 0xFF);
+            char ch = (char) ('A' + (prefix % 26));
+            int num = ((hash[1] & 0xFF) % 9) + 1;
+            return "用户" + ch + num;
+        } catch (Exception e) {
+            return getAlias(userId);
+        }
+    }
+
+    /**
+     * 脱敏文本:替换手机号、身份证、邮箱
+     */
+    public String sanitizeText(String text) {
+        if (text == null) return null;
+        String result = PHONE_PATTERN.matcher(text).replaceAll(SANITIZED);
+        result = ID_CARD_PATTERN.matcher(result).replaceAll(SANITIZED);
+        result = EMAIL_PATTERN.matcher(result).replaceAll(SANITIZED);
+        return result;
+    }
+
+    /**
+     * 脱敏整个 context Map(递归遍历)
+     */
+    public Map<String, Object> sanitizeContext(Map<String, Object> context, Long userId) {
+        if (context == null || context.isEmpty()) return context;
+        return sanitizeMap(new LinkedHashMap<>(context), userId, 0);
+    }
+
+    private Map<String, Object> sanitizeMap(Map<String, Object> map, Long userId, int depth) {
+        if (depth > 10 || map == null) return map;
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        for (Map.Entry<String, Object> entry : map.entrySet()) {
+            String key = entry.getKey();
+            Object value = entry.getValue();
+            if (value == null) continue;
+
+            if (isNameField(key)) {
+                if (value instanceof String) {
+                    String alias = getAlias((String) value, userId);
+                    result.put(key, alias);
+                    log.debug("PII脱敏: {} 字段脱敏为 {} (userId={})", key, alias, userId);
+                }
+            } else if (isRemoveField(key)) {
+                log.debug("PII脱敏: {} 字段已移除 (userId={})", key, userId);
+                // 直接跳过,不放入结果
+            } else if (isTextField(key) || value instanceof String) {
+                if (value instanceof String) {
+                    result.put(key, sanitizeText((String) value));
+                } else {
+                    result.put(key, value);
+                }
+            } else if (value instanceof Map) {
+                @SuppressWarnings("unchecked")
+                Map<String, Object> nested = (Map<String, Object>) value;
+                result.put(key, sanitizeMap(nested, userId, depth + 1));
+            } else if (value instanceof List) {
+                result.put(key, sanitizeList((List<?>) value, userId, depth + 1));
+            } else {
+                result.put(key, value);
+            }
+        }
+        return result;
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<Object> sanitizeList(List<?> list, Long userId, int depth) {
+        if (depth > 10 || list == null) return new ArrayList<>(list);
+        List<Object> result = new ArrayList<>();
+        for (Object item : list) {
+            if (item instanceof String) {
+                result.add(sanitizeText((String) item));
+            } else if (item instanceof Map) {
+                result.add(sanitizeMap((Map<String, Object>) item, userId, depth + 1));
+            } else if (item instanceof List) {
+                result.add(sanitizeList((List<?>) item, userId, depth + 1));
+            } else {
+                result.add(item);
+            }
+        }
+        return result;
+    }
+
+    private boolean isNameField(String key) {
+        if (key == null) return false;
+        for (String field : NAME_FIELDS) {
+            if (field.equalsIgnoreCase(key)) return true;
+        }
+        return false;
+    }
+
+    private boolean isRemoveField(String key) {
+        if (key == null) return false;
+        for (String field : REMOVE_FIELDS) {
+            if (field.equalsIgnoreCase(key)) return true;
+        }
+        return false;
+    }
+
+    private boolean isTextField(String key) {
+        if (key == null) return false;
+        for (String field : TEXT_FIELDS) {
+            if (field.equalsIgnoreCase(key)) return true;
+        }
+        return false;
+    }
+}