Преглед изворни кода

feat: 实现三级健康约束规则体系(硬拦截/软警告/正常推荐),支持疾病禁忌、过敏命中、药物相互作用、剂量超标、档案缺失提示,并提供健康方案生成能力

Sisyphus пре 1 недеља
родитељ
комит
04319b4c8d

+ 134 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -2991,6 +2991,21 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         createFatGoalTable();
         addProductVendorVisibleColumn();
 
+        // 迁移360: products 表添加健康约束字段(基础疾病禁忌/过敏原/药物相互作用标记)
+        addProductHealthConstraintColumns();
+
+        // 迁移361: 创建 drug_interactions 表(药物-食物禁忌对照表)
+        createDrugInteractionsTable();
+
+        // 迁移362: 创建 disease_intake_limits 表(疾病-饮食摄入阈值)
+        createDiseaseIntakeLimitsTable();
+
+        // 迁移363: 初始化 drug_interactions 种子数据
+        seedDrugInteractionsData();
+
+        // 迁移364: 初始化 disease_intake_limits 种子数据
+        seedDiseaseIntakeLimitsData();
+
         // 指标统一层迁移(356~362)
         migrateIndicatorDefinitionsColumns();
         migrateIndicatorValuesColumns();
@@ -12308,4 +12323,123 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
     private void addProductVendorVisibleColumn() {
         ensureColumn("products", "vendor_visible", "TINYINT(1) DEFAULT NULL COMMENT '是否仅供应商体系内用户可见(列表接口生效,直接分享可bypass)'");
     }
+
+    // 迁移360: products 表添加健康约束字段(基础疾病禁忌/过敏原/药物相互作用标记)
+    private void addProductHealthConstraintColumns() {
+        ensureColumn("products", "contraindicated_diseases", "VARCHAR(255) DEFAULT NULL COMMENT '禁忌基础疾病,逗号分隔(如\"慢性肾病,肝病\")'");
+        ensureColumn("products", "allergen_tags", "VARCHAR(255) DEFAULT NULL COMMENT '过敏原标签,逗号分隔(如\"花生,小麦\")'");
+        ensureColumn("products", "drug_interaction_flags", "TINYINT DEFAULT 0 COMMENT '是否命中药物-食物相互作用(1=是,需对照drug_interactions表)'");
+    }
+
+    // 迁移361: 创建 drug_interactions 表(药物-食物禁忌对照表)
+    private void createDrugInteractionsTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS drug_interactions (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "drug_class VARCHAR(50) NOT NULL COMMENT '药物类别(如CCB/他汀/抗凝药/抗生素/铁剂/甲状腺药/利尿剂/MAOI/头孢/降糖药)', " +
+                    "drug_specifics VARCHAR(100) NOT NULL COMMENT '具体药名(如硝苯地平/华法林)', " +
+                    "interacting_subst VARCHAR(100) NOT NULL COMMENT '禁忌/需间隔的食材或成分(如西柚/维生素K)', " +
+                    "risk_level TINYINT NOT NULL COMMENT '1=拦截 2=警告 3=需间隔', " +
+                    "action_type VARCHAR(20) NOT NULL COMMENT 'block=硬拦截 warn=软警告 space=间隔', " +
+                    "interval_minutes INT DEFAULT NULL COMMENT '需间隔时长(分钟)', " +
+                    "note VARCHAR(255) COMMENT '风险说明', " +
+                    "UNIQUE KEY uk_drug_food (drug_class, drug_specifics, interacting_subst), " +
+                    "INDEX idx_interacting (interacting_subst)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='药物-食物禁忌对照表'");
+            log.info("迁移361: 已创建drug_interactions表");
+        } catch (Exception e) {
+            log.warn("迁移361: 创建drug_interactions表失败: {}", e.getMessage());
+        }
+    }
+
+    // 迁移362: 创建 disease_intake_limits 表(疾病-饮食摄入阈值)
+    private void createDiseaseIntakeLimitsTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS disease_intake_limits (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "disease_type VARCHAR(50) NOT NULL COMMENT '疾病类型(慢性肾病/糖尿病/高血压/痛风/肝病/甲状腺疾病)', " +
+                    "nutrient VARCHAR(20) NOT NULL COMMENT '校验营养素(protein/potassium/phosphorus/sodium/carb/glycemic/fructose/purine/alcohol/iodine)', " +
+                    "unit VARCHAR(20) NOT NULL COMMENT '单位', " +
+                    "threshold_max DECIMAL(8,2) DEFAULT NULL COMMENT '每日上限', " +
+                    "threshold_min DECIMAL(8,2) DEFAULT NULL COMMENT '每日下限', " +
+                    "block_foods VARCHAR(500) DEFAULT NULL COMMENT '硬拦截食材列表,逗号分隔(如杨桃)', " +
+                    "stage_dependent TINYINT(1) DEFAULT 0 COMMENT '是否按病情分期(1=是 0=否)', " +
+                    "INDEX idx_disease_nutrient (disease_type, nutrient)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='疾病-饮食摄入校验阈值表'");
+            log.info("迁移362: 已创建disease_intake_limits表");
+        } catch (Exception e) {
+            log.warn("迁移362: 创建disease_intake_limits表失败: {}", e.getMessage());
+        }
+    }
+
+    // 迁移363: 初始化 drug_interactions 种子数据(高危组合,幂等)
+    private void seedDrugInteractionsData() {
+        String[][] seed = {
+                {"CCB", "硝苯地平", "西柚", "1", "block", "0", "西柚抑制药物代谢酶,血药浓度升高数倍,低血压风险,服药期间完全避免"},
+                {"CCB", "氨氯地平", "西柚", "1", "block", "0", "西柚抑制药物代谢酶,血药浓度升高数倍,低血压风险,服药期间完全避免"},
+                {"他汀类", "辛伐他汀", "西柚", "1", "block", "0", "增加肌肉损伤、肝损伤风险"},
+                {"他汀类", "阿托伐他汀", "西柚", "1", "block", "0", "增加肌肉损伤、肝损伤风险"},
+                {"抗凝药", "华法林", "维生素K", "2", "warn", "0", "维K拮抗药效,摄入量保持稳定,调整饮食结构前先与医生沟通"},
+                {"抗凝药", "华法林", "大蒜", "2", "warn", "0", "大蒜可能增强抗凝作用,增加出血风险"},
+                {"抗生素", "四环素", "牛奶", "3", "space", "120", "钙与药物结合影响吸收,需间隔2小时以上"},
+                {"抗生素", "喹诺酮类", "牛奶", "3", "space", "120", "钙与药物结合影响吸收,需间隔2小时以上"},
+                {"抗生素", "喹诺酮类", "钙剂", "3", "space", "120", "钙与药物结合影响吸收,需间隔2小时以上"},
+                {"铁剂", "硫酸亚铁", "茶", "3", "space", "60", "鞣酸抑制铁吸收,需间隔1-2小时"},
+                {"铁剂", "硫酸亚铁", "咖啡", "3", "space", "60", "鞣酸抑制铁吸收,需间隔1-2小时"},
+                {"铁剂", "硫酸亚铁", "钙剂", "3", "space", "60", "钙抑制铁吸收,需间隔1-2小时"},
+                {"铁剂", "硫酸亚铁", "牛奶", "3", "space", "60", "钙抑制铁吸收,需间隔1-2小时"},
+                {"甲状腺药", "左甲状腺素", "豆浆", "3", "space", "240", "影响吸收,需间隔4小时"},
+                {"甲状腺药", "左甲状腺素", "高纤维食物", "3", "space", "240", "影响吸收,需间隔4小时"},
+                {"甲状腺药", "左甲状腺素", "钙剂", "3", "space", "240", "影响吸收,需间隔4小时"},
+                {"保钾利尿剂", "螺内酯", "高钾食物", "2", "warn", "0", "高钾血症风险,避免香蕉、橙汁、低钠盐大量集中摄入"},
+                {"MAOI", "苯乙肼", "发酵食品", "2", "warn", "0", "酪胺反应可致血压骤升,避免奶酪/豆豉/腌制品/陈年食品"},
+                {"头孢类", "头孢类", "酒精", "1", "block", "0", "双硫仑样反应,严重可危及生命"},
+                {"甲硝唑", "甲硝唑", "酒精", "1", "block", "0", "双硫仑样反应,严重可危及生命"},
+                {"降糖药", "磺脲类", "酒精", "1", "block", "0", "低血糖风险,避免空腹饮酒"}
+        };
+        for (String[] row : seed) {
+            try {
+                Integer cnt = jdbcTemplate.queryForObject(
+                        "SELECT COUNT(*) FROM drug_interactions WHERE drug_class=? AND drug_specifics=? AND interacting_subst=?",
+                        Integer.class, row[0], row[1], row[2]);
+                if (cnt != null && cnt > 0) continue;
+                jdbcTemplate.update(
+                        "INSERT INTO drug_interactions (drug_class, drug_specifics, interacting_subst, risk_level, action_type, interval_minutes, note) VALUES (?,?,?,?,?,?,?)",
+                        row[0], row[1], row[2], Integer.valueOf(row[3]), row[4], Integer.valueOf(row[5]), row[6]);
+            } catch (Exception e) {
+                log.warn("迁移363: 插入drug_interactions种子失败: {}", e.getMessage());
+            }
+        }
+        log.info("迁移363: drug_interactions种子数据初始化完成");
+    }
+
+    // 迁移364: 初始化 disease_intake_limits 种子数据(幂等)
+    private void seedDiseaseIntakeLimitsData() {
+        String[][] seed = {
+                {"慢性肾病", "protein", "g/kg/day", "0.8", "0.6", "杨桃", "1"},
+                {"慢性肾病", "potassium", "mmol/L/day", "5.0", "3.5", "杨桃,香蕉,橙子,土豆,菠菜,菌菇", "1"},
+                {"慢性肾病", "phosphorus", "mg/day", "800", "600", "杨桃,奶制品,坚果,加工食品", "1"},
+                {"糖尿病", "carb", "g/meal", "75", "30", "", "0"},
+                {"糖尿病", "fructose", "g/day", "50", "0", "含糖饮料,果汁,蜂蜜,甜点", "0"},
+                {"高血压", "sodium", "g/day", "5", "2", "腌菜,酱菜,酱油,加工肉制品,挂面,咸味零食", "0"},
+                {"痛风", "purine", "mg/day", "300", "150", "动物内脏,部分海鲜,浓肉汤", "0"},
+                {"痛风", "fructose", "g/day", "50", "0", "含糖饮料,蜂蜜", "0"},
+                {"肝病", "protein", "g/kg/day", "1.0", "0.5", "", "1"},
+                {"甲状腺疾病", "iodine", "ug/day", "300", "150", "海带,紫菜", "0"}
+        };
+        for (String[] row : seed) {
+            try {
+                Integer cnt = jdbcTemplate.queryForObject(
+                        "SELECT COUNT(*) FROM disease_intake_limits WHERE disease_type=? AND nutrient=?",
+                        Integer.class, row[0], row[1]);
+                if (cnt != null && cnt > 0) continue;
+                jdbcTemplate.update(
+                        "INSERT INTO disease_intake_limits (disease_type, nutrient, unit, threshold_max, threshold_min, block_foods, stage_dependent) VALUES (?,?,?,?,?,?,?)",
+                        row[0], row[1], row[2], row[3], row[4], row[5], Integer.valueOf(row[6]));
+            } catch (Exception e) {
+                log.warn("迁移364: 插入disease_intake_limits种子失败: {}", e.getMessage());
+            }
+        }
+        log.info("迁移364: disease_intake_limits种子数据初始化完成");
+    }
 }

+ 23 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/DiseaseIntakeLimit.java

@@ -0,0 +1,23 @@
+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;
+
+@Data
+@TableName("disease_intake_limits")
+public class DiseaseIntakeLimit implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String diseaseType;
+    private String nutrient;
+    private String unit;
+    private BigDecimal thresholdMax;
+    private BigDecimal thresholdMin;
+    private String blockFoods;
+    private Integer stageDependent;
+}

+ 22 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/DrugInteraction.java

@@ -0,0 +1,22 @@
+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;
+
+@Data
+@TableName("drug_interactions")
+public class DrugInteraction implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String drugClass;
+    private String drugSpecifics;
+    private String interactingSubst;
+    private Integer riskLevel;
+    private String actionType;
+    private Integer intervalMinutes;
+    private String note;
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/Product.java

@@ -44,6 +44,15 @@ public class Product implements Serializable {
     private String domain;
     /** JSON: {"body":0,"mind":0,"wisdom":0,"action":0,"wealth":0} */
     private String dimensionWeights;
+
+    /** 禁忌基础疾病,逗号分隔(如"慢性肾病,肝病"),NULL=无禁忌 */
+    private String contraindicatedDiseases;
+
+    /** 过敏原标签,逗号分隔(如"花生,小麦"),NULL=无过敏原 */
+    private String allergenTags;
+
+    /** 是否命中药物-食物相互作用(1=是,需对照drug_interactions表) */
+    private Integer drugInteractionFlags;
     private String externalSource;
     private String externalId;
     private String externalData;

+ 21 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/DiseaseIntakeLimitMapper.java

@@ -0,0 +1,21 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.DiseaseIntakeLimit;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+
+@Mapper
+public interface DiseaseIntakeLimitMapper extends BaseMapper<DiseaseIntakeLimit> {
+
+    @Select("SELECT * FROM disease_intake_limits WHERE disease_type = #{diseaseType} AND nutrient = #{nutrient}")
+    DiseaseIntakeLimit findByDiseaseAndNutrient(String diseaseType, String nutrient);
+
+    @Select("SELECT * FROM disease_intake_limits WHERE disease_type = #{diseaseType}")
+    List<DiseaseIntakeLimit> findByDiseaseType(String diseaseType);
+
+    @Select("SELECT * FROM disease_intake_limits WHERE block_foods LIKE CONCAT('%', #{foodName}, '%')")
+    List<DiseaseIntakeLimit> findByBlockFood(String foodName);
+}

+ 24 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/DrugInteractionMapper.java

@@ -0,0 +1,24 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.DrugInteraction;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+
+@Mapper
+public interface DrugInteractionMapper extends BaseMapper<DrugInteraction> {
+
+    @Select("SELECT * FROM drug_interactions WHERE drug_class = #{drugClass} AND drug_specifics = #{drugSpecifics}")
+    List<DrugInteraction> findByDrugClassAndSpecifics(String drugClass, String drugSpecifics);
+
+    @Select("SELECT * FROM drug_interactions WHERE interacting_subst = #{interactingSubst}")
+    List<DrugInteraction> findByInteractingSubst(String interactingSubst);
+
+    @Select("SELECT * FROM drug_interactions WHERE drug_specifics = #{drugSpecifics}")
+    List<DrugInteraction> findByDrugSpecifics(String drugSpecifics);
+
+    @Select("SELECT * FROM drug_interactions WHERE interacting_subst = #{subst} AND risk_level = 1")
+    List<DrugInteraction> findHardBlocksByInteractingSubst(String subst);
+}

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

@@ -55,6 +55,9 @@ public class BeijingNutritionService {
     @Resource
     private FamilyMemberMapper familyMemberMapper;
 
+    @Resource
+    private HealthFilterService healthFilterService;
+
     // ============================================================
     // 核心推荐引擎
     // ============================================================
@@ -694,12 +697,21 @@ public class BeijingNutritionService {
         bacteriaStatusMap.values().forEach(mergedBacteriaStatus::putAll);
         Map<String, BacteriaFoodMapping.FoodAdjustment> adjustments = bacteriaFoodMapping.computeAdjustments(mergedBacteriaStatus);
 
+        // 加载家庭成员疾病画像(用于疾病硬拦截)
+        HealthFilterService.HealthProfile mergedHealthProfile = new HealthFilterService.HealthProfile();
+        mergedHealthProfile.profileMissing = participantIds.isEmpty();
+        for (Long memberId : participantIds) {
+            HealthFilterService.HealthProfile memberProfile = healthFilterService.loadProfile(memberId, null, null);
+            mergedHealthProfile.diseases.addAll(memberProfile.diseases);
+        }
+
         List<Food> allFoods = foodMapper.selectList(null);
         List<ScoredIngredient> scored = new ArrayList<>();
 
         for (Food food : allFoods) {
             String foodName = food.getName();
             if (allAllergies.contains(foodName) || allAvoid.contains(foodName)) continue;
+            if (healthFilterService.isFoodHardBlocked(foodName, mergedHealthProfile)) continue;
 
             BacteriaFoodMapping.FoodAdjustment adj = adjustments.get(foodName);
             double bacteriaScore = scoreBacteria(adj != null ? adj.getDirection() : null, food.getScore());

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

@@ -0,0 +1,320 @@
+package com.etotem.cfc.service;
+
+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.HealthStatus;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.mapper.DietPreferencesMapper;
+import com.etotem.cfc.mapper.DiseaseIntakeLimitMapper;
+import com.etotem.cfc.mapper.DrugInteractionMapper;
+import com.etotem.cfc.mapper.HealthStatusMapper;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 推荐引擎三级规则过滤器(硬拦截/软警告/正常推荐)
+ * 数据源:
+ * - 疾病史/用药:health_status.disease_history / medications (JSON [{name,note}])
+ * - 过敏原:diet_preferences.allergies (JSON [string])
+ * - 商品禁忌:products.contraindicated_diseases / allergen_tags
+ * - 药物-食物禁忌:drug_interactions
+ * - 疾病-饮食阈值:disease_intake_limits
+ */
+@Slf4j
+@Service
+public class HealthFilterService {
+
+    @Resource
+    private HealthStatusMapper healthStatusMapper;
+
+    @Resource
+    private DietPreferencesMapper dietPreferencesMapper;
+
+    @Resource
+    private DrugInteractionMapper drugInteractionMapper;
+
+    @Resource
+    private DiseaseIntakeLimitMapper diseaseIntakeLimitMapper;
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    /** 年龄硬性禁忌:年龄(岁,含下限) → 禁止食材关键词 */
+    private static final Map<String, String> AGE_BLOCK_RULES = new HashMap<>();
+    static {
+        AGE_BLOCK_RULES.put("1", "蜂蜜");
+        AGE_BLOCK_RULES.put("3", "整粒坚果");
+    }
+
+    /**
+     * 用户健康画像(从档案实时加载)
+     */
+    public static class HealthProfile {
+        public Long userId;
+        public Long memberId;
+        public Set<String> diseases = new HashSet<>();
+        public Set<String> allergies = new HashSet<>();
+        public Set<String> medications = new HashSet<>();
+        public Integer age;
+        public boolean profileMissing;
+        public boolean sensitivePregnant;
+        public boolean sensitivePostSurgery;
+    }
+
+    /**
+     * 过滤结果:通过的商品 + 需人工复核的软警告
+     */
+    public static class FilterResult {
+        public List<Product> allowed = new ArrayList<>();
+        public List<String> warnings = new ArrayList<>();
+        public List<String> blockedReasons = new ArrayList<>();
+    }
+
+    /**
+     * 加载用户健康画像
+     */
+    public HealthProfile loadProfile(Long memberId, Long userId, Integer age) {
+        HealthProfile p = new HealthProfile();
+        p.memberId = memberId;
+        p.userId = userId;
+        p.age = age;
+
+        HealthStatus status = null;
+        if (memberId != null) {
+            List<HealthStatus> list = healthStatusMapper.selectList(
+                    new LambdaQueryWrapper<HealthStatus>()
+                            .eq(HealthStatus::getMemberId, memberId)
+                            .last("LIMIT 1"));
+            if (!list.isEmpty()) status = list.get(0);
+        }
+        if (status == null && userId != null) {
+            List<HealthStatus> list = healthStatusMapper.selectList(
+                    new LambdaQueryWrapper<HealthStatus>()
+                            .eq(HealthStatus::getUserId, userId)
+                            .last("LIMIT 1"));
+            if (!list.isEmpty()) status = list.get(0);
+        }
+
+        if (status == null) {
+            p.profileMissing = true;
+        } else {
+            p.diseases.addAll(parseNameList(status.getDiseaseHistory()));
+            p.medications.addAll(parseNameList(status.getMedications()));
+            for (String d : p.diseases) {
+                if (d.contains("妊娠") || d.contains("怀孕") || d.contains("哺乳")) {
+                    p.sensitivePregnant = true;
+                }
+                if (d.contains("术后") || d.contains("手术")) {
+                    p.sensitivePostSurgery = true;
+                }
+            }
+        }
+
+        if (memberId != null) {
+            DietPreferences prefs = dietPreferencesMapper.selectOne(
+                    new LambdaQueryWrapper<DietPreferences>()
+                            .eq(DietPreferences::getFamilyMemberId, memberId)
+                            .last("LIMIT 1"));
+            if (prefs != null && prefs.getAllergies() != null) {
+                p.allergies.addAll(parseStringList(prefs.getAllergies()));
+            }
+        }
+        return p;
+    }
+
+    /**
+     * 核心过滤:硬拦截 + 软警告分级
+     */
+    public FilterResult filterProducts(HealthProfile profile, List<Product> products) {
+        FilterResult result = new FilterResult();
+        if (products == null) return result;
+
+        if (profile.profileMissing) {
+            result.warnings.add("健康档案缺失,按更严格规则执行,建议完善档案或咨询医生后使用");
+        }
+        if (profile.sensitivePregnant) {
+            result.warnings.add("孕哺期用户,方案需人工复核/建议咨询医生");
+        }
+        if (profile.sensitivePostSurgery) {
+            result.warnings.add("术后恢复期用户,方案需人工复核/建议咨询医生");
+        }
+
+        for (Product product : products) {
+            List<String> reasons = checkProductBlocks(profile, product);
+            if (reasons.isEmpty()) {
+                result.allowed.add(product);
+            } else {
+                result.blockedReasons.add(product.getName() + ":" + String.join(";", reasons));
+            }
+        }
+        return result;
+    }
+
+    /**
+     * 商品级硬拦截检查:返回拦截原因列表,空=通过
+     */
+    public List<String> checkProductBlocks(HealthProfile profile, Product product) {
+        List<String> reasons = new ArrayList<>();
+
+        String contraindicated = product.getContraindicatedDiseases();
+        if (contraindicated != null && !contraindicated.isEmpty()) {
+            for (String disease : profile.diseases) {
+                if (disease == null || disease.isEmpty()) continue;
+                if (containsToken(contraindicated, disease)) {
+                    reasons.add("禁忌疾病" + disease + "命中,硬拦截");
+                }
+            }
+        }
+
+        String allergens = product.getAllergenTags();
+        if (allergens != null && !allergens.isEmpty()) {
+            for (String allergy : profile.allergies) {
+                if (allergy == null || allergy.isEmpty()) continue;
+                if (containsToken(allergens, allergy)) {
+                    reasons.add("过敏原" + allergy + "命中,硬拦截");
+                }
+            }
+        }
+
+        if (profile.age != null) {
+            if (profile.age < 1 && containsToken(product.getName(), "蜂蜜")) {
+                reasons.add("1岁内禁食蜂蜜,硬拦截");
+            }
+            if (profile.age < 3 && containsToken(product.getName(), "坚果")) {
+                reasons.add("3岁内禁食整粒坚果防呛咳,硬拦截");
+            }
+        }
+
+        List<String> drugWarnings = checkDrugInteractions(profile, product);
+        reasons.addAll(drugWarnings);
+
+        return reasons;
+    }
+
+    /**
+     * 药物-食物相互作用检查:命中risk_level=1硬拦截,2/3返回软警告/间隔提示
+     */
+    public List<String> checkDrugInteractions(HealthProfile profile, Product product) {
+        List<String> warnings = new ArrayList<>();
+        if (profile.medications.isEmpty()) return warnings;
+
+        String productName = product.getName() == null ? "" : product.getName();
+        String allergenTags = product.getAllergenTags() == null ? "" : product.getAllergenTags();
+
+        List<DrugInteraction> interactions = drugInteractionMapper.selectList(null);
+        for (DrugInteraction it : interactions) {
+            String subst = it.getInteractingSubst();
+            if (subst == null || subst.isEmpty()) continue;
+            boolean hitFood = containsToken(productName, subst) || containsToken(allergenTags, 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;
+    }
+
+    /**
+     * 食材/商品是否被疾病硬拦截(表#7 block_foods)
+     */
+    public boolean isFoodHardBlocked(String foodName, HealthProfile profile) {
+        if (foodName == null || foodName.isEmpty() || profile.diseases.isEmpty()) return false;
+        List<DiseaseIntakeLimit> limits = diseaseIntakeLimitMapper.selectList(null);
+        for (DiseaseIntakeLimit limit : limits) {
+            if (!profile.diseases.contains(limit.getDiseaseType())) continue;
+            if (limit.getBlockFoods() != null && containsToken(limit.getBlockFoods(), foodName)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 营养素累计校验(表#7):返回超限原因,空=通过
+     */
+    public List<String> validateNutrients(Map<String, Double> nutrientTotals, HealthProfile profile) {
+        List<String> violations = new ArrayList<>();
+        if (nutrientTotals == null || nutrientTotals.isEmpty() || profile.diseases.isEmpty()) {
+            return violations;
+        }
+        List<DiseaseIntakeLimit> limits = diseaseIntakeLimitMapper.selectList(null);
+        for (DiseaseIntakeLimit limit : limits) {
+            if (!profile.diseases.contains(limit.getDiseaseType())) continue;
+            Double total = nutrientTotals.get(limit.getNutrient());
+            if (total == null) continue;
+            if (limit.getThresholdMax() != null && total.doubleValue() > limit.getThresholdMax().doubleValue()) {
+                violations.add(limit.getDiseaseType() + ":" + limit.getNutrient() + "每日摄入" + total
+                        + limit.getUnit() + "超上限" + limit.getThresholdMax() + limit.getUnit() + ",需替换或标记");
+            }
+            if (limit.getThresholdMin() != null && total.doubleValue() < limit.getThresholdMin().doubleValue()) {
+                violations.add(limit.getDiseaseType() + ":" + limit.getNutrient() + "每日摄入不足" + limit.getThresholdMin() + limit.getUnit());
+            }
+        }
+        return violations;
+    }
+
+    private List<String> parseNameList(String json) {
+        List<String> names = new ArrayList<>();
+        if (json == null || json.isEmpty()) return names;
+        try {
+            JsonNode node = objectMapper.readTree(json);
+            if (node.isArray()) {
+                for (JsonNode item : node) {
+                    JsonNode name = item.get("name");
+                    if (name != null && !name.asText().isEmpty()) names.add(name.asText());
+                }
+            }
+        } catch (Exception e) {
+            log.warn("解析JSON字段失败: {}", e.getMessage());
+        }
+        return names;
+    }
+
+    private List<String> parseStringList(String json) {
+        List<String> list = new ArrayList<>();
+        if (json == null || json.isEmpty()) return list;
+        try {
+            JsonNode node = objectMapper.readTree(json);
+            if (node.isArray()) {
+                for (JsonNode item : node) {
+                    if (item.isTextual() && !item.asText().isEmpty()) list.add(item.asText());
+                }
+            }
+        } catch (Exception e) {
+            log.warn("解析JSON数组失败: {}", e.getMessage());
+        }
+        return list;
+    }
+
+    private boolean containsToken(String haystack, String needle) {
+        if (haystack == null || needle == null || needle.isEmpty()) return false;
+        String[] tokens = haystack.split("[,,、\\s]+");
+        for (String t : tokens) {
+            if (t != null && !t.isEmpty() && t.equals(needle.trim())) return true;
+        }
+        return false;
+    }
+}

+ 26 - 8
cfc-backend/src/main/java/com/etotem/cfc/service/ProductRecommendationService.java

@@ -43,6 +43,9 @@ public class ProductRecommendationService {
     @Resource
     private RecommendationProductAssociationMapper recommendationProductAssociationMapper;
 
+    @Resource
+    private HealthFilterService healthFilterService;
+
     /**
      * 维度页推荐
      *
@@ -57,6 +60,9 @@ public class ProductRecommendationService {
             String dimensionCode, Long familyId, Long memberId,
             List<Long> excludeProductIds, int limit) {
 
+        // 0. 加载成员健康画像(硬拦截基础)
+        HealthFilterService.HealthProfile healthProfile = healthFilterService.loadProfile(memberId, null, null);
+
         // 1. 获取已购商品ID(90天内)
         Set<Long> purchasedIds = getPurchasedProductIds(familyId, 90);
         if (excludeProductIds != null) {
@@ -93,6 +99,9 @@ public class ProductRecommendationService {
                                 .orderByAsc(Product::getSortOrder)
                 );
 
+                HealthFilterService.FilterResult mappedFilter = healthFilterService.filterProducts(healthProfile, mapped);
+                mapped = mappedFilter.allowed;
+
                 // 构建 productId → mapping 得分
                 Map<Long, Integer> mappingScores = mappings.stream()
                         .collect(Collectors.toMap(
@@ -149,6 +158,8 @@ public class ProductRecommendationService {
             }
             SortUtil.applySort(domainQw);
             List<Product> domainMatched = productMapper.selectList(domainQw);
+             HealthFilterService.FilterResult domainFilter = healthFilterService.filterProducts(healthProfile, domainMatched);
+             domainMatched = domainFilter.allowed;
             for (Product p : domainMatched) {
                 Map<String, Object> item = new LinkedHashMap<>();
                 item.put("id", p.getId());
@@ -170,7 +181,7 @@ public class ProductRecommendationService {
 
         // 4. 关联关系推荐(动态:画像缺口、共购、复购)
         List<Map<String, Object>> assocResults = queryAssociationRecommendations(
-                dimensionCode, familyId, memberId, purchasedIds, excludeProductIds, limit - candidates.size());
+                dimensionCode, familyId, memberId, purchasedIds, excludeProductIds, limit - candidates.size(), healthProfile);
 
         // 合并并去重(关联推荐优先级高于维度映射,但保留维度的reason/matchScore)
         if (assocResults != null && !assocResults.isEmpty()) {
@@ -191,7 +202,8 @@ public class ProductRecommendationService {
      */
     private List<Map<String, Object>> queryAssociationRecommendations(
             String dimensionCode, Long familyId, Long memberId,
-            Set<Long> purchasedIds, List<Long> excludeProductIds, int reqCount) {
+            Set<Long> purchasedIds, List<Long> excludeProductIds, int reqCount,
+            HealthFilterService.HealthProfile healthProfile) {
 
         if (reqCount <= 0) return Collections.emptyList();
 
@@ -201,7 +213,7 @@ public class ProductRecommendationService {
         // 4.1 画像缺口关联:维度得分低时推荐对应商品
         if (dimensionCode != null && !dimensionCode.isEmpty()) {
             List<Map<String, Object>> profileAssocs = queryProfileGapAssociations(
-                    dimensionCode, memberScores, purchasedIds, excludeProductIds, reqCount / 3);
+                    dimensionCode, memberScores, purchasedIds, excludeProductIds, reqCount / 3, healthProfile);
             results.addAll(profileAssocs);
             if (results.size() >= reqCount) return results.subList(0, reqCount);
         }
@@ -209,7 +221,7 @@ public class ProductRecommendationService {
         // 4.2 共购关联:买了A的用户也买了B(从订单数据挖掘)
         if (purchasedIds != null && !purchasedIds.isEmpty()) {
             List<Map<String, Object>> coPurchaseAssocs = queryCoPurchaseAssociations(
-                    purchasedIds, excludeProductIds, reqCount - results.size());
+                    purchasedIds, excludeProductIds, reqCount - results.size(), healthProfile);
             results.addAll(coPurchaseAssocs);
             if (results.size() >= reqCount) return results.subList(0, reqCount);
         }
@@ -217,7 +229,7 @@ public class ProductRecommendationService {
         // 4.3 复购关联:买了A后X天再买A的关联商品
         if (purchasedIds != null && !purchasedIds.isEmpty()) {
             List<Map<String, Object>> repurchaseAssocs = queryRepurchaseAssociations(
-                    purchasedIds, excludeProductIds, reqCount - results.size());
+                    purchasedIds, excludeProductIds, reqCount - results.size(), healthProfile);
             results.addAll(repurchaseAssocs);
         }
 
@@ -229,7 +241,8 @@ public class ProductRecommendationService {
      */
     private List<Map<String, Object>> queryProfileGapAssociations(
             String dimensionCode, Map<String, Integer> memberScores,
-            Set<Long> purchasedIds, List<Long> excludeProductIds, int reqCount) {
+            Set<Long> purchasedIds, List<Long> excludeProductIds, int reqCount,
+            HealthFilterService.HealthProfile healthProfile) {
 
         if (reqCount <= 0) return Collections.emptyList();
 
@@ -262,6 +275,7 @@ public class ProductRecommendationService {
 
             Product product = productMapper.selectById(assoc.getTargetProductId());
             if (product == null || !"on_shelf".equals(product.getStatus()) || !hasSellableStock(product)) continue;
+            if (!healthFilterService.checkProductBlocks(healthProfile, product).isEmpty()) continue;
 
             Map<String, Object> item = new LinkedHashMap<>();
             item.put("id", product.getId());
@@ -300,7 +314,8 @@ public class ProductRecommendationService {
      * 共购关联:从订单数据挖掘“买了A的用户也买了B”
      */
     private List<Map<String, Object>> queryCoPurchaseAssociations(
-            Set<Long> purchasedIds, List<Long> excludeProductIds, int reqCount) {
+            Set<Long> purchasedIds, List<Long> excludeProductIds, int reqCount,
+            HealthFilterService.HealthProfile healthProfile) {
 
         if (reqCount <= 0 || purchasedIds == null || purchasedIds.isEmpty()) return Collections.emptyList();
 
@@ -328,6 +343,7 @@ public class ProductRecommendationService {
 
             Product product = productMapper.selectById(targetId);
             if (product == null || !"on_shelf".equals(product.getStatus()) || !hasSellableStock(product)) continue;
+            if (!healthFilterService.checkProductBlocks(healthProfile, product).isEmpty()) continue;
 
             Map<String, Object> item = new LinkedHashMap<>();
             item.put("id", product.getId());
@@ -355,7 +371,8 @@ public class ProductRecommendationService {
      * 复购关联:买了A后X天再买相关商品的关联
      */
     private List<Map<String, Object>> queryRepurchaseAssociations(
-            Set<Long> purchasedIds, List<Long> excludeProductIds, int reqCount) {
+            Set<Long> purchasedIds, List<Long> excludeProductIds, int reqCount,
+            HealthFilterService.HealthProfile healthProfile) {
 
         if (reqCount <= 0 || purchasedIds == null || purchasedIds.isEmpty()) return Collections.emptyList();
 
@@ -378,6 +395,7 @@ public class ProductRecommendationService {
 
             Product product = productMapper.selectById(targetId);
             if (product == null || !"on_shelf".equals(product.getStatus()) || !hasSellableStock(product)) continue;
+            if (!healthFilterService.checkProductBlocks(healthProfile, product).isEmpty()) continue;
 
             Map<String, Object> item = new LinkedHashMap<>();
             item.put("id", product.getId());

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

@@ -1190,6 +1190,9 @@ CREATE TABLE IF NOT EXISTS products (
     visible_roles JSON DEFAULT NULL COMMENT '可见角色: ["parent","child","teacher","admin"],NULL=全部可见',
     min_visible_level VARCHAR(50) DEFAULT NULL COMMENT '最低可见会员级别编码(FREE/FAMILY/PROVIDER/PREMIUM/LIFETIME,NULL=全部可见)',
     min_discount_level VARCHAR(50) DEFAULT NULL COMMENT '享受会员价所需的最低会员级别编码(FREE/FAMILY/PREMIUM/LIFETIME,NULL=所有会员均可)',
+    contraindicated_diseases VARCHAR(255) DEFAULT NULL COMMENT '禁忌基础疾病,逗号分隔(如"慢性肾病,肝病")',
+    allergen_tags VARCHAR(255) DEFAULT NULL COMMENT '过敏原标签,逗号分隔(如"花生,小麦")',
+    drug_interaction_flags TINYINT DEFAULT 0 COMMENT '是否命中药物-食物相互作用(1=是,需对照drug_interactions表)',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';
@@ -6252,3 +6255,30 @@ CREATE TABLE IF NOT EXISTS indicator_correlations (
     INDEX idx_def_a (definition_id_a),
     INDEX idx_def_b (definition_id_b)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='指标关联关系(人工定义+数据校准)';
+
+-- 迁移361: 药物-食物禁忌对照表
+CREATE TABLE IF NOT EXISTS drug_interactions (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    drug_class VARCHAR(50) NOT NULL COMMENT '药物类别(如CCB/他汀/抗凝药/抗生素/铁剂/甲状腺药/利尿剂/MAOI/头孢/降糖药)',
+    drug_specifics VARCHAR(100) NOT NULL COMMENT '具体药名(如硝苯地平/华法林)',
+    interacting_subst VARCHAR(100) NOT NULL COMMENT '禁忌/需间隔的食材或成分(如西柚/维生素K)',
+    risk_level TINYINT NOT NULL COMMENT '1=拦截 2=警告 3=需间隔',
+    action_type VARCHAR(20) NOT NULL COMMENT 'block=硬拦截 warn=软警告 space=间隔',
+    interval_minutes INT DEFAULT NULL COMMENT '需间隔时长(分钟)',
+    note VARCHAR(255) COMMENT '风险说明',
+    UNIQUE KEY uk_drug_food (drug_class, drug_specifics, interacting_subst),
+    INDEX idx_interacting (interacting_subst)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='药物-食物禁忌对照表';
+
+-- 迁移362: 疾病-饮食摄入校验阈值表
+CREATE TABLE IF NOT EXISTS disease_intake_limits (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    disease_type VARCHAR(50) NOT NULL COMMENT '疾病类型(慢性肾病/糖尿病/高血压/痛风/肝病/甲状腺疾病)',
+    nutrient VARCHAR(20) NOT NULL COMMENT '校验营养素(protein/potassium/phosphorus/sodium/carb/glycemic/fructose/purine/alcohol/iodine)',
+    unit VARCHAR(20) NOT NULL COMMENT '单位',
+    threshold_max DECIMAL(8,2) DEFAULT NULL COMMENT '每日上限',
+    threshold_min DECIMAL(8,2) DEFAULT NULL COMMENT '每日下限',
+    block_foods VARCHAR(500) DEFAULT NULL COMMENT '硬拦截食材列表,逗号分隔(如杨桃)',
+    stage_dependent TINYINT(1) DEFAULT 0 COMMENT '是否按病情分期(1=是 0=否)',
+    INDEX idx_disease_nutrient (disease_type, nutrient)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='疾病-饮食摄入校验阈值表';