|
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
import com.etotem.cfc.entity.DietPreferences;
|
|
import com.etotem.cfc.entity.DietPreferences;
|
|
|
import com.etotem.cfc.entity.DiseaseIntakeLimit;
|
|
import com.etotem.cfc.entity.DiseaseIntakeLimit;
|
|
|
import com.etotem.cfc.entity.DrugInteraction;
|
|
import com.etotem.cfc.entity.DrugInteraction;
|
|
|
|
|
+import com.etotem.cfc.entity.Food;
|
|
|
import com.etotem.cfc.entity.HealthStatus;
|
|
import com.etotem.cfc.entity.HealthStatus;
|
|
|
import com.etotem.cfc.entity.Product;
|
|
import com.etotem.cfc.entity.Product;
|
|
|
import com.etotem.cfc.mapper.DietPreferencesMapper;
|
|
import com.etotem.cfc.mapper.DietPreferencesMapper;
|
|
@@ -113,6 +114,11 @@ public class HealthFilterService {
|
|
|
} else {
|
|
} else {
|
|
|
p.diseases.addAll(parseNameList(status.getDiseaseHistory()));
|
|
p.diseases.addAll(parseNameList(status.getDiseaseHistory()));
|
|
|
p.medications.addAll(parseNameList(status.getMedications()));
|
|
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) {
|
|
for (String d : p.diseases) {
|
|
|
if (d.contains("妊娠") || d.contains("怀孕") || d.contains("哺乳")) {
|
|
if (d.contains("妊娠") || d.contains("怀孕") || d.contains("哺乳")) {
|
|
|
p.sensitivePregnant = true;
|
|
p.sensitivePregnant = true;
|
|
@@ -237,6 +243,249 @@ public class HealthFilterService {
|
|
|
return warnings;
|
|
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)
|
|
* 食材/商品是否被疾病硬拦截(表#7 block_foods)
|
|
|
*/
|
|
*/
|