소스 검색

feat(diet): S-cfc-125 implement diet.food.healthAdjust capability

- Food: add @TableField(exist=false) transient fields adjustment/adjustmentReason
- HealthFilterService: add checkFoodBlocks/checkFoodWarnings/checkDietPreferenceAdjust/assessFoodAdjustment/loadDietPreferences
- HealthFilterService.loadProfile: merge lifestyle.allergySymptoms into allergies (D1)
- FoodRecommendService.getCurrentFoods: compute adjustment (blocked>warning>boost>normal) per food, emit adjustment/adjustmentReason keys
- FoodRecommendService.getCurrentFoodsAsFoodList: propagate transient fields
- BeijingNutritionService.generateIngredientList: add checkDrugInteractions call + religious diet filtering via food.dietType vs prefs.religiousDiet

All contract tests (DietFoodHealthAdjustContractTest 10/10) + regressions (FoodRecommendServiceTest 21/21, FoodRecommendMemberViewResolutionTest 6/6, ReportDietRecommendContractTest 8/8, FamilyFoodOverviewServiceTest 9/9) pass.
E2E Test Bot 3 일 전
부모
커밋
ba9fd2837

+ 10 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/Food.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.entity;
 
 import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
 import com.fasterxml.jackson.annotation.JsonAlias;
@@ -43,6 +44,15 @@ public class Food implements Serializable {
     private Integer sortOrder;
     /** 饮食类型: none/清真/halal/素食/vegan */
     private String dietType;
+
+    /** 健康问卷调整结果: normal/blocked/warning/boost(D1-D8 判定,瞬态不入库,供 getCurrentFoodsAsFoodList 透传) */
+    @TableField(exist = false)
+    private String adjustment;
+
+    /** 调整命中原因文本(多条用「;」连接,瞬态不入库) */
+    @TableField(exist = false)
+    private String adjustmentReason;
+
     private Date createdAt;
     private Date updatedAt;
 }

+ 52 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/BeijingNutritionService.java

@@ -639,6 +639,8 @@ public class BeijingNutritionService {
         Map<Long, List<String>> allergiesMap = new HashMap<>();
         Map<Long, List<String>> avoidMap = new HashMap<>();
         Map<Long, Map<String, String>> bacteriaStatusMap = new HashMap<>();
+        // A5:参与者宗教饮食合并集(任一参与者不匹配即剔除,none/空不拦)
+        Set<String> allReligiousDiets = new HashSet<>();
 
         for (Long memberId : participantIds) {
             LambdaQueryWrapper<com.etotem.cfc.entity.DietPreferences> prefWrapper = new LambdaQueryWrapper<>();
@@ -663,6 +665,10 @@ public class BeijingNutritionService {
                         log.warn("解析 absoluteAvoid 失败: {}", e.getMessage());
                     }
                 }
+                if (prefs.getReligiousDiet() != null && !prefs.getReligiousDiet().trim().isEmpty()
+                        && !"none".equalsIgnoreCase(prefs.getReligiousDiet().trim())) {
+                    allReligiousDiets.add(prefs.getReligiousDiet().trim());
+                }
             }
 
             LambdaQueryWrapper<HealthReport> reportWrapper = new LambdaQueryWrapper<>();
@@ -713,6 +719,20 @@ public class BeijingNutritionService {
             if (allAllergies.contains(foodName) || allAvoid.contains(foodName)) continue;
             if (healthFilterService.isFoodHardBlocked(foodName, mergedHealthProfile)) continue;
 
+            // A5 用药维度(D2):食物名/food.allergens 命中药物交互 → risk1 剔除、risk2/3 提示
+            List<String> drugReasons = healthFilterService.checkDrugInteractions(mergedHealthProfile, food);
+            boolean drugHardBlock = false;
+            for (String reason : drugReasons) {
+                if (reason != null && reason.contains("硬拦截")) {
+                    drugHardBlock = true;
+                    break;
+                }
+            }
+            if (drugHardBlock) continue;
+
+            // A5 宗教饮食过滤(D5):food.dietType vs 任一参与者 religiousDiet 不匹配即剔除;none/空不受影响
+            if (isReligiousDietMismatch(food, allReligiousDiets)) continue;
+
             BacteriaFoodMapping.FoodAdjustment adj = adjustments.get(foodName);
             double bacteriaScore = scoreBacteria(adj != null ? adj.getDirection() : null, food.getScore());
             double nutritionScore = scoreNutrition(food.getCategory(), food);
@@ -818,6 +838,38 @@ public class BeijingNutritionService {
         }
         return recommendations;
     }
+
+    /**
+     * A5 宗教饮食过滤(D5):food.dietType 与任一参与者 religiousDiet 不匹配即视为不兼容。
+     * <p>语义:none/空 dietType 表示未标注宗教属性的通用食材,不拦;素食类宗教饮食仅接受
+     * 素食/vegan/vegetarian;清真类接受 清真/halal 以及素食子集;未知类型按字面匹配。</p>
+     */
+    private boolean isReligiousDietMismatch(Food food, Set<String> religiousDiets) {
+        if (religiousDiets == null || religiousDiets.isEmpty() || food == null) return false;
+        String dietType = food.getDietType() == null ? "" : food.getDietType().trim();
+        if (dietType.isEmpty() || "none".equalsIgnoreCase(dietType)) return false;
+        for (String religious : religiousDiets) {
+            if (religious == null || religious.trim().isEmpty()) continue;
+            if (!isReligiousCompatibleDietType(religious.trim(), dietType)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** 判断食材 dietType 与某个宗教饮食偏好在语义上是否兼容(兼容返回 true) */
+    private boolean isReligiousCompatibleDietType(String religious, String dietType) {
+        if (religious.contains("素")) {
+            return dietType.contains("素") || "vegan".equalsIgnoreCase(dietType)
+                    || "vegetarian".equalsIgnoreCase(dietType) || "veg".equalsIgnoreCase(dietType);
+        }
+        if (religious.contains("清") || "halal".equalsIgnoreCase(religious)) {
+            return dietType.contains("清") || "halal".equalsIgnoreCase(dietType)
+                    || dietType.contains("素") || "vegan".equalsIgnoreCase(dietType)
+                    || "vegetarian".equalsIgnoreCase(dietType);
+        }
+        return religious.equalsIgnoreCase(dietType);
+    }
     
     // ============================================================
     // 均衡化评分引擎

+ 56 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FoodRecommendService.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.DietPreferences;
 import com.etotem.cfc.entity.Food;
 import com.etotem.cfc.entity.FoodRecommendIndex;
 import com.etotem.cfc.entity.FoodRecommendIdxLog;
@@ -51,6 +52,9 @@ public class FoodRecommendService {
     @Resource
     private FamilyFoodOverviewService familyFoodOverviewService;
 
+    @Resource
+    private HealthFilterService healthFilterService;
+
     /**
      * 把外部传入的 subjectId 归一为 food_recommend_idx 的统一写入/读取键(T28 方向1:写侧统一为 users.id)。
      * <p>
@@ -335,6 +339,21 @@ public class FoodRecommendService {
             }
         }
 
+        // A2:健康问卷四维 → 食材个性化调整(D1-D8)。画像/偏好按目标成员一次性加载,避免每食材重复查询。
+        HealthFilterService.HealthProfile healthProfile = null;
+        DietPreferences dietPrefs = null;
+        if (healthFilterService != null) {
+            try {
+                Long targetMemberId = resolveUserIdToMemberId(userId);
+                // 成员视角优先(diet_preferences 按 family_member_id 存);无成员卡退回 userId 级画像
+                healthProfile = healthFilterService.loadProfile(targetMemberId,
+                        targetMemberId == null ? userId : null, null);
+                dietPrefs = healthFilterService.loadDietPreferences(targetMemberId);
+            } catch (Exception e) {
+                log.warn("食材健康问卷画像加载失败 userId={}: {}", userId, e.getMessage());
+            }
+        }
+
         List<Map<String, Object>> result = new ArrayList<>();
         for (FoodRecommendIndex idx : indices) {
             Food food = foodMap.get(idx.getFoodId());
@@ -350,11 +369,45 @@ public class FoodRecommendService {
                 m.put("fiber", food.getFiber());
                 m.put("energyKj", food.getEnergyKj());
             }
+
+            // A2:按 D1-D8 判定 adjustment(blocked > warning > boost > normal),仅打标不删食材。
+            String adjustment = "normal";
+            String adjustmentReason = "";
+            if (food != null && healthFilterService != null && healthProfile != null) {
+                try {
+                    HealthFilterService.FoodAdjustmentResult adj =
+                            healthFilterService.assessFoodAdjustment(healthProfile, dietPrefs, food);
+                    adjustment = adj.adjustment != null ? adj.adjustment : "normal";
+                    adjustmentReason = adj.reason != null ? adj.reason : "";
+                } catch (Exception e) {
+                    log.warn("食材{}健康问卷调整失败: {}", food.getName(), e.getMessage());
+                }
+            }
+            m.put("adjustment", adjustment);
+            m.put("adjustmentReason", adjustmentReason);
+
             result.add(m);
         }
         return result;
     }
 
+    /** 由写侧键 userId(users.id)反查 family_members 首条记录 id(memberId),无则返回 null */
+    private Long resolveUserIdToMemberId(Long userId) {
+        if (userId == null) return null;
+        try {
+            List<FamilyMember> members = familyMemberMapper.selectList(
+                    new LambdaQueryWrapper<FamilyMember>()
+                            .eq(FamilyMember::getUserId, userId)
+                            .last("LIMIT 1"));
+            if (!members.isEmpty() && members.get(0).getId() != null) {
+                return members.get(0).getId();
+            }
+        } catch (Exception e) {
+            log.warn("userId→memberId 反查失败 userId={}: {}", userId, e.getMessage());
+        }
+        return null;
+    }
+
     /** 将当前食材数据转换为 Food 实体列表,供 /api/health/foods 无 reportId 时使用 */
     public List<Food> getCurrentFoodsAsFoodList(Long userId) {
         List<Map<String, Object>> current = getCurrentFoods(userId);
@@ -375,6 +428,9 @@ public class FoodRecommendService {
             if (fiber != null) food.setFiber(fiber);
             BigDecimal energyKj = (BigDecimal) m.get("energyKj");
             if (energyKj != null) food.setEnergyKj(energyKj);
+            // A3:透传健康问卷调整结果(瞬态字段)
+            food.setAdjustment((String) m.get("adjustment"));
+            food.setAdjustmentReason((String) m.get("adjustmentReason"));
             result.add(food);
         }
         return result;

+ 249 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthFilterService.java

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.entity.DietPreferences;
 import com.etotem.cfc.entity.DiseaseIntakeLimit;
 import com.etotem.cfc.entity.DrugInteraction;
+import com.etotem.cfc.entity.Food;
 import com.etotem.cfc.entity.HealthStatus;
 import com.etotem.cfc.entity.Product;
 import com.etotem.cfc.mapper.DietPreferencesMapper;
@@ -113,6 +114,11 @@ public class HealthFilterService {
         } else {
             p.diseases.addAll(parseNameList(status.getDiseaseHistory()));
             p.medications.addAll(parseNameList(status.getMedications()));
+            // D1 体症维度:health_status.lifestyle(allergySymptoms) 并入画像 allergies(lifestyle 为 JSON 对象,解析失败静默)
+            List<String> lifestyleAllergies = parseLifestyleAllergySymptoms(status.getLifestyle());
+            if (!lifestyleAllergies.isEmpty()) {
+                p.allergies.addAll(lifestyleAllergies);
+            }
             for (String d : p.diseases) {
                 if (d.contains("妊娠") || d.contains("怀孕") || d.contains("哺乳")) {
                     p.sensitivePregnant = true;
@@ -237,6 +243,249 @@ public class HealthFilterService {
         return warnings;
     }
 
+    /**
+     * 药物-食物相互作用检查(Food 重载):以 food.name / food.allergens 为命中面。
+     * 命中risk_level=1硬拦截,2/3返回软警告/间隔提示。逻辑对齐 Product 版本。
+     */
+    public List<String> checkDrugInteractions(HealthProfile profile, Food food) {
+        List<String> warnings = new ArrayList<>();
+        if (profile.medications.isEmpty() || food == null) return warnings;
+
+        String foodName = food.getName() == null ? "" : food.getName();
+        String allergens = food.getAllergens() == null ? "" : food.getAllergens();
+
+        List<DrugInteraction> interactions = drugInteractionMapper.selectList(null);
+        for (DrugInteraction it : interactions) {
+            String subst = it.getInteractingSubst();
+            if (subst == null || subst.isEmpty()) continue;
+            boolean hitFood = containsToken(foodName, subst) || containsToken(allergens, subst);
+            if (!hitFood) continue;
+
+            boolean medHit = profile.medications.stream()
+                    .anyMatch(m -> m != null && (containsToken(m, it.getDrugSpecifics()) || containsToken(m, it.getDrugClass())));
+            if (!medHit) continue;
+
+            if (it.getRiskLevel() != null && it.getRiskLevel() == 1) {
+                warnings.add("药物-食物禁忌" + subst + "命中(" + it.getDrugSpecifics() + "),硬拦截");
+            } else if (it.getRiskLevel() != null && it.getRiskLevel() == 3) {
+                int minutes = it.getIntervalMinutes() != null ? it.getIntervalMinutes() : 60;
+                warnings.add("药物" + it.getDrugSpecifics() + "与" + subst + "需间隔" + minutes + "分钟服用");
+            } else {
+                warnings.add("药物" + it.getDrugSpecifics() + "与" + subst + "存在相互作用,建议咨询医生");
+            }
+        }
+        return warnings;
+    }
+
+    /**
+     * 按食材做硬拦截检查(D1过敏/D2-risk1药物/D3疾病/D8年龄),返回拦截原因列表,空=通过。
+     * 逻辑对齐 checkProductBlocks,以 food.name / food.allergens 为命中面。
+     */
+    public List<String> checkFoodBlocks(HealthProfile profile, Food food) {
+        List<String> reasons = new ArrayList<>();
+        if (food == null) return reasons;
+
+        // D3 疾病硬拦截(表#7 block_foods)
+        if (isFoodHardBlocked(food.getName(), profile)) {
+            reasons.add("禁忌疾病命中,硬拦截");
+        }
+
+        // D1 过敏原命中(food.allergens)
+        String allergens = food.getAllergens();
+        if (allergens != null && !allergens.isEmpty()) {
+            for (String allergy : profile.allergies) {
+                if (allergy == null || allergy.isEmpty()) continue;
+                if (containsToken(allergens, allergy)) {
+                    reasons.add("过敏原" + allergy + "命中,建议不食");
+                }
+            }
+        }
+
+        // D8 年龄禁忌(AGE_BLOCK_RULES)
+        if (profile.age != null) {
+            for (Map.Entry<String, String> rule : AGE_BLOCK_RULES.entrySet()) {
+                int minAge;
+                try {
+                    minAge = Integer.parseInt(rule.getKey());
+                } catch (NumberFormatException e) {
+                    continue;
+                }
+                if (profile.age < minAge && containsToken(food.getName(), rule.getValue())) {
+                    reasons.add(minAge + "岁内禁食" + rule.getValue() + ",硬拦截");
+                }
+            }
+        }
+
+        // D2 用药:仅把 risk1 硬拦截计入 block
+        for (String w : checkDrugInteractions(profile, food)) {
+            if (w != null && w.contains("硬拦截")) {
+                reasons.add(w);
+            }
+        }
+
+        return reasons;
+    }
+
+    /**
+     * 按食材做软警告检查(D2-risk2/3 用药间隔/咨询),返回提示原因列表。
+     */
+    public List<String> checkFoodWarnings(HealthProfile profile, Food food) {
+        List<String> warnings = new ArrayList<>();
+        if (food == null) return warnings;
+        for (String w : checkDrugInteractions(profile, food)) {
+            if (w != null && !w.contains("硬拦截")) {
+                warnings.add(w);
+            }
+        }
+        return warnings;
+    }
+
+    /**
+     * 饮食偏好调整判定(D4绝对避免/D5宗教饮食/D6 mustEat boost),返回结构化结果。
+     */
+    public DietPreferenceAdjustResult checkDietPreferenceAdjust(HealthProfile profile, DietPreferences prefs, Food food) {
+        DietPreferenceAdjustResult result = new DietPreferenceAdjustResult();
+        if (food == null || prefs == null) return result;
+
+        // D4 绝对避免
+        List<String> avoidList = parseStringList(prefs.getAbsoluteAvoid());
+        String foodName = food.getName() == null ? "" : food.getName();
+        for (String avoid : avoidList) {
+            if (avoid == null || avoid.trim().isEmpty()) continue;
+            if (avoid.trim().equals(foodName.trim()) || containsToken(food.getAllergens(), avoid)) {
+                result.blockReasons.add("该成员明确不吃" + avoid);
+            }
+        }
+
+        // D5 宗教饮食:food.dietType vs prefs.religiousDiet(none/空不拦)
+        String religious = prefs.getReligiousDiet();
+        if (religious != null && !religious.trim().isEmpty() && !"none".equalsIgnoreCase(religious.trim())) {
+            String dietType = food.getDietType() == null ? "" : food.getDietType().trim();
+            boolean dietTypeNone = dietType.isEmpty() || "none".equalsIgnoreCase(dietType);
+            // none/空 表示未标注特定宗教属性食材,不拦(与 D5「none/空⇢不拦」一致)
+            if (!dietTypeNone && !isReligiousCompatible(religious.trim(), dietType)) {
+                result.blockReasons.add("饮食禁忌(宗教/素食)");
+            }
+        }
+
+        // D6 mustEat boost
+        List<String> mustEatList = parseStringList(prefs.getMustEat());
+        for (String must : mustEatList) {
+            if (must == null || must.trim().isEmpty()) continue;
+            if (must.trim().equals(foodName.trim()) || containsToken(food.getAllergens(), must)) {
+                result.boost = true;
+            }
+        }
+
+        return result;
+    }
+
+    /**
+     * 按食材综合判定调整等级(D1-D8 按序:blocked > warning > boost > normal),供
+     * FoodRecommendService.getCurrentFoods / getCurrentFoodsAsFoodList 透传使用。
+     */
+    public FoodAdjustmentResult assessFoodAdjustment(HealthProfile profile, DietPreferences prefs, Food food) {
+        FoodAdjustmentResult r = new FoodAdjustmentResult();
+        if (food == null) {
+            r.adjustment = "normal";
+            r.reason = "";
+            return r;
+        }
+        List<String> blockReasons = new ArrayList<>(checkFoodBlocks(profile, food));
+        DietPreferenceAdjustResult pref = checkDietPreferenceAdjust(profile, prefs, food);
+        blockReasons.addAll(pref.blockReasons);
+
+        if (!blockReasons.isEmpty()) {
+            r.adjustment = "blocked";
+            r.reason = String.join(";", blockReasons);
+            return r;
+        }
+
+        List<String> warnings = checkFoodWarnings(profile, food);
+        if (!warnings.isEmpty()) {
+            r.adjustment = "warning";
+            r.reason = String.join(";", warnings);
+            return r;
+        }
+
+        if (pref.boost) {
+            r.adjustment = "boost";
+            r.reason = "符合饮食习惯偏好";
+            return r;
+        }
+
+        r.adjustment = "normal";
+        r.reason = "";
+        return r;
+    }
+
+    /** 按 memberId 加载饮食偏好(无记录返回 null),供 FoodRecommendService 复用 */
+    public DietPreferences loadDietPreferences(Long memberId) {
+        if (memberId == null) return null;
+        try {
+            return dietPreferencesMapper.selectOne(
+                    new LambdaQueryWrapper<DietPreferences>()
+                            .eq(DietPreferences::getFamilyMemberId, memberId)
+                            .last("LIMIT 1"));
+        } catch (Exception e) {
+            log.warn("加载饮食偏好失败 memberId={}: {}", memberId, e.getMessage());
+            return null;
+        }
+    }
+
+    /**
+     * 判断食材 dietType 与宗教饮食偏好在语义上是否兼容(兼容返回 true 表示不拦)。
+     * 素食类宗教饮食仅接受 素食/vegan/vegetarian;清真类接受 清真/halal 以及素食子集。
+     */
+    private boolean isReligiousCompatible(String religiousDiet, String dietType) {
+        if (religiousDiet.contains("素")) {
+            return dietType.contains("素") || "vegan".equalsIgnoreCase(dietType)
+                    || "vegetarian".equalsIgnoreCase(dietType) || "veg".equalsIgnoreCase(dietType);
+        }
+        if (religiousDiet.contains("清") || "halal".equalsIgnoreCase(religiousDiet)) {
+            return dietType.contains("清") || "halal".equalsIgnoreCase(dietType)
+                    || dietType.contains("素") || "vegan".equalsIgnoreCase(dietType)
+                    || "vegetarian".equalsIgnoreCase(dietType);
+        }
+        return religiousDiet.equalsIgnoreCase(dietType);
+    }
+
+    /** 解析 health_status.lifestyle(JSON 对象)中的 allergySymptoms 数组,解析失败静默返回空列表 */
+    private List<String> parseLifestyleAllergySymptoms(String lifestyleJson) {
+        List<String> list = new ArrayList<>();
+        if (lifestyleJson == null || lifestyleJson.isEmpty()) return list;
+        try {
+            JsonNode node = objectMapper.readTree(lifestyleJson);
+            if (node.isObject()) {
+                JsonNode symptoms = node.get("allergySymptoms");
+                if (symptoms != null && symptoms.isArray()) {
+                    for (JsonNode item : symptoms) {
+                        if (item.isTextual() && !item.asText().isEmpty()) list.add(item.asText());
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.warn("解析 lifestyle.allergySymptoms 失败: {}", e.getMessage());
+        }
+        return list;
+    }
+
+    /**
+     * 饮食偏好调整结果(结构化):block 原因列表 + boost 布尔
+     */
+    public static class DietPreferenceAdjustResult {
+        public List<String> blockReasons = new ArrayList<>();
+        public boolean boost;
+    }
+
+    /**
+     * 食材综合调整结果:adjustment(normal/blocked/warning/boost) + reason 命中原因
+     */
+    public static class FoodAdjustmentResult {
+        public String adjustment = "normal";
+        public String reason = "";
+    }
+
     /**
      * 食材/商品是否被疾病硬拦截(表#7 block_foods)
      */