|
|
@@ -9,12 +9,17 @@ import com.etotem.cfc.entity.HealthPlan;
|
|
|
import com.etotem.cfc.entity.Task;
|
|
|
import com.etotem.cfc.entity.Family;
|
|
|
import com.etotem.cfc.entity.User;
|
|
|
+import com.etotem.cfc.entity.HealthStatus;
|
|
|
+import com.etotem.cfc.entity.DietPreferences;
|
|
|
import com.etotem.cfc.mapper.FamilyMapper;
|
|
|
import com.etotem.cfc.mapper.FamilyMemberMapper;
|
|
|
import com.etotem.cfc.mapper.FiveDimensionSelfCheckMapper;
|
|
|
import com.etotem.cfc.mapper.HealthPlanMapper;
|
|
|
import com.etotem.cfc.mapper.TaskMapper;
|
|
|
import com.etotem.cfc.mapper.UserMapper;
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.etotem.cfc.mapper.HealthStatusMapper;
|
|
|
+import com.etotem.cfc.mapper.DietPreferencesMapper;
|
|
|
import com.etotem.cfc.service.HealthPlanService;
|
|
|
import lombok.extern.slf4j.Slf4j;
|
|
|
import org.springframework.beans.factory.annotation.Value;
|
|
|
@@ -61,6 +66,12 @@ public class HealthPlanServiceImpl implements HealthPlanService {
|
|
|
@Resource
|
|
|
private FiveDimensionSelfCheckMapper selfCheckMapper;
|
|
|
|
|
|
+ @Resource
|
|
|
+ private HealthStatusMapper healthStatusMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private DietPreferencesMapper dietPreferencesMapper;
|
|
|
+
|
|
|
@Resource
|
|
|
private com.etotem.cfc.service.AiGateway aiGateway;
|
|
|
|
|
|
@@ -322,6 +333,434 @@ public class HealthPlanServiceImpl implements HealthPlanService {
|
|
|
return buildSamplePlan(memberIds, dimensions, goal);
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * R2 归因方案(HL-03):基于成员健康档案 + 饮食偏好 + 目标,规则驱动生成归因说明。
|
|
|
+ * 讲清当前要解决的问题;不含治疗方案(RL05 合规:不宣称治疗,只陈述数据事实与建议方向)。
|
|
|
+ */
|
|
|
+ @Override
|
|
|
+ public Map<String, Object> generateAttributionPlans(Long familyId, String memberIds, String goal) {
|
|
|
+ Map<String, Object> result = new LinkedHashMap<>();
|
|
|
+ result.put("goal", goal != null ? goal.trim() : "");
|
|
|
+ List<Map<String, Object>> members = new ArrayList<>();
|
|
|
+ List<String> memberIdList = splitMemberIds(memberIds);
|
|
|
+ for (String rawId : memberIdList) {
|
|
|
+ Long memberId = Long.valueOf(rawId.trim());
|
|
|
+ FamilyMember fm = familyMemberMapper.selectById(memberId);
|
|
|
+ Map<String, Object> m = new LinkedHashMap<>();
|
|
|
+ m.put("memberId", memberId);
|
|
|
+ m.put("name", fm != null && fm.getNickname() != null ? fm.getNickname() : "成员" + memberId);
|
|
|
+ if (fm != null && fm.getBirthday() != null) {
|
|
|
+ m.put("age", computeAge(fm.getBirthday()));
|
|
|
+ } else {
|
|
|
+ m.put("age", null);
|
|
|
+ }
|
|
|
+ List<String> problems = new ArrayList<>();
|
|
|
+ List<String> riskNotes = new ArrayList<>();
|
|
|
+ List<String> dataGaps = new ArrayList<>();
|
|
|
+
|
|
|
+ HealthStatus hs = loadHealthStatus(memberId);
|
|
|
+ DietPreferences dp = loadDietPreferences(memberId);
|
|
|
+
|
|
|
+ collectAttributionFromHealth(hs, problems, riskNotes, dataGaps);
|
|
|
+ collectAttributionFromDiet(dp, problems, dataGaps);
|
|
|
+
|
|
|
+ m.put("problems", problems);
|
|
|
+ m.put("riskNotes", riskNotes);
|
|
|
+ m.put("dataGaps", dataGaps);
|
|
|
+ members.add(m);
|
|
|
+ }
|
|
|
+ result.put("members", members);
|
|
|
+
|
|
|
+ // 摘要:把每个成员的问题合并为「要解决的问题」陈述(目标对齐)
|
|
|
+ StringBuilder summary = new StringBuilder();
|
|
|
+ summary.append("基于成员健康问卷与饮食档案,当前重点需要解决的问题如下");
|
|
|
+ if (goal != null && !goal.trim().isEmpty()) {
|
|
|
+ summary.append("(围绕目标「").append(goal.trim().length() > 30 ? goal.trim().substring(0, 30) + "…" : goal.trim()).append("」)");
|
|
|
+ }
|
|
|
+ summary.append("。");
|
|
|
+ result.put("summary", summary.toString());
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * R3 不确定点检测(HL-03):问卷有数据但关键信息缺失时,返回需对话补充的问题。
|
|
|
+ * 无数据(跳过问卷)或信息充足 → 空列表(不影响既有回归)。
|
|
|
+ */
|
|
|
+ @Override
|
|
|
+ public List<Map<String, Object>> detectFollowUpQuestions(Long familyId, String memberIds, String goal) {
|
|
|
+ List<Map<String, Object>> questions = new ArrayList<>();
|
|
|
+ List<String> memberIdList = splitMemberIds(memberIds);
|
|
|
+ if (memberIdList.isEmpty()) return questions;
|
|
|
+ boolean goalSleep = containsAny(goal, "睡眠", "失眠", "入睡", "熬夜", "早起");
|
|
|
+ boolean goalGut = containsAny(goal, "肠胃", "肠道", "消化", "排便", "便秘", "腹泻", "菌群", "益生");
|
|
|
+ boolean goalAllergy = containsAny(goal, "过敏", "湿疹", "皮肤", "鼻炎", "荨麻疹");
|
|
|
+
|
|
|
+ for (String rawId : memberIdList) {
|
|
|
+ Long memberId = Long.valueOf(rawId.trim());
|
|
|
+ HealthStatus hs = loadHealthStatus(memberId);
|
|
|
+ DietPreferences dp = loadDietPreferences(memberId);
|
|
|
+ if (hs == null && dp == null) continue; // 无问卷数据 → 不打扰(保持「跳过问卷直接生成」回归)
|
|
|
+
|
|
|
+ List<String> allergies = parseNameList(dp != null ? dp.getAllergies() : null);
|
|
|
+
|
|
|
+ // 1) 睡眠信息缺失且目标涉及睡眠
|
|
|
+ if (goalSleep && hs != null && questions.size() < 3) {
|
|
|
+ Map<String, Object> ls = parseJsonMap(hs.getLifestyle());
|
|
|
+ Object sleepDuration = ls != null ? ls.get("sleepDuration") : null;
|
|
|
+ Object bedtime = ls != null ? ls.get("bedtime") : null;
|
|
|
+ boolean sleepMissing = isEmpty(sleepDuration) && isEmpty(bedtime);
|
|
|
+ boolean sleepShort = "不足6小时".equals(sleepDuration) || "6-7小时".equals(sleepDuration);
|
|
|
+ if (sleepMissing || sleepShort) {
|
|
|
+ Map<String, Object> q = new LinkedHashMap<>();
|
|
|
+ q.put("questionId", "followup_sleep_" + memberId);
|
|
|
+ q.put("question", "关于睡眠:通常几点入睡、每晚大约睡几小时?夜间是否有醒来/入睡困难?(用于校准睡眠建议)");
|
|
|
+ questions.add(q);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // 2) 肠道/排便信息缺失且目标涉及肠道
|
|
|
+ if (goalGut && hs != null && questions.size() < 3) {
|
|
|
+ Map<String, Object> ls = parseJsonMap(hs.getLifestyle());
|
|
|
+ Object bowelFrequency = ls != null ? ls.get("bowelFrequency") : null;
|
|
|
+ Object bowelForm = ls != null ? ls.get("bowelForm") : null;
|
|
|
+ if (isEmpty(bowelFrequency) && isEmpty(bowelForm)) {
|
|
|
+ Map<String, Object> q = new LinkedHashMap<>();
|
|
|
+ q.put("questionId", "followup_bowel_" + memberId);
|
|
|
+ q.put("question", "关于肠道:通常几天排便一次、大便形态大致如何(干硬/成型/稀便)?是否有腹胀等不适?");
|
|
|
+ questions.add(q);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // 3) 过敏信息缺失且目标涉及过敏/皮肤(仅问卷未填过敏时补充)
|
|
|
+ if (goalAllergy && allergies.isEmpty() && questions.size() < 3 && dp != null) {
|
|
|
+ boolean noPref = Boolean.TRUE.equals(dp.getNoPreference());
|
|
|
+ if (!noPref) {
|
|
|
+ Map<String, Object> q = new LinkedHashMap<>();
|
|
|
+ q.put("questionId", "followup_allergy_" + memberId);
|
|
|
+ q.put("question", "关于过敏:是否有已知食物/药物过敏,或出现过皮肤湿疹、鼻炎等反应?具体是什么引发的?");
|
|
|
+ questions.add(q);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // 4) 儿童在服药物 → 剂量需医生确认(RL03 儿童剂量医生定)
|
|
|
+ if (hs != null && questions.size() < 3 && isChild(hs, memberId)) {
|
|
|
+ List<String> meds = parseNameList(hs.getMedications());
|
|
|
+ if (!meds.isEmpty()) {
|
|
|
+ Map<String, Object> q = new LinkedHashMap<>();
|
|
|
+ q.put("questionId", "followup_child_med_" + memberId);
|
|
|
+ q.put("question", "关于用药:当前在服药物(" + String.join("、", meds) + ")是否由医生开具?具体剂量与用法?(儿童用量须遵医嘱,用于规避与营养方案冲突)");
|
|
|
+ questions.add(q);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return questions;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * R3 带补充答案生成方案(HL-03):答案并入目标上下文后复用既有 4 参生成链路。
|
|
|
+ */
|
|
|
+ @Override
|
|
|
+ public Object generatePlanResult(Long familyId, String memberIds, String dimensions, String goal,
|
|
|
+ Map<String, Object> followUpAnswers) {
|
|
|
+ String enhancedGoal = goal;
|
|
|
+ if (followUpAnswers != null && !followUpAnswers.isEmpty()) {
|
|
|
+ StringBuilder sb = new StringBuilder(goal == null ? "" : goal);
|
|
|
+ boolean headerAppended = false;
|
|
|
+ for (Map.Entry<String, Object> e : followUpAnswers.entrySet()) {
|
|
|
+ // QA 修复(HL-03):全部跳过时前端用 __dismissed__ 占位标记保证携带,
|
|
|
+ // 此处跳过标记键,不把占位拼入 goal
|
|
|
+ if ("__dismissed__".equals(e.getKey())) continue;
|
|
|
+ String v = e.getValue() == null ? "" : String.valueOf(e.getValue());
|
|
|
+ if (v.trim().isEmpty()) continue;
|
|
|
+ if (!headerAppended) {
|
|
|
+ sb.append("\n\n【补充信息】");
|
|
|
+ headerAppended = true;
|
|
|
+ }
|
|
|
+ sb.append("\n").append(demapQuestionId(e.getKey())).append(":").append(v.trim());
|
|
|
+ }
|
|
|
+ enhancedGoal = sb.toString();
|
|
|
+ }
|
|
|
+ return generatePlanResult(familyId, memberIds, dimensions, enhancedGoal);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== R2/R3 私有工具(HL-03) ====================
|
|
|
+
|
|
|
+ private List<String> splitMemberIds(String memberIds) {
|
|
|
+ List<String> out = new ArrayList<>();
|
|
|
+ if (memberIds == null || memberIds.trim().isEmpty()) return out;
|
|
|
+ for (String s : memberIds.split(",")) {
|
|
|
+ if (s != null && !s.trim().isEmpty()) out.add(s.trim());
|
|
|
+ }
|
|
|
+ return out;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 读取成员健康档案(memberId 维度,口径与 HealthStatusServiceImpl.getByMemberIdOnly 一致) */
|
|
|
+ private HealthStatus loadHealthStatus(Long memberId) {
|
|
|
+ HealthStatus s = null;
|
|
|
+ if (memberId != null) {
|
|
|
+ List<HealthStatus> list = healthStatusMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<HealthStatus>()
|
|
|
+ .eq(HealthStatus::getMemberId, memberId)
|
|
|
+ .orderByDesc(HealthStatus::getId)
|
|
|
+ .last("LIMIT 1"));
|
|
|
+ if (!list.isEmpty()) s = list.get(0);
|
|
|
+ }
|
|
|
+ return s;
|
|
|
+ }
|
|
|
+
|
|
|
+ private DietPreferences loadDietPreferences(Long memberId) {
|
|
|
+ if (memberId == null) return null;
|
|
|
+ return dietPreferencesMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<DietPreferences>()
|
|
|
+ .eq(DietPreferences::getFamilyMemberId, memberId)
|
|
|
+ .last("LIMIT 1"));
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 从健康档案归纳「要解决的问题」(RL05:只陈述数据事实 + 就医建议方向,不宣称治疗) */
|
|
|
+ private void collectAttributionFromHealth(HealthStatus hs, List<String> problems,
|
|
|
+ List<String> riskNotes, List<String> dataGaps) {
|
|
|
+ if (hs == null) {
|
|
|
+ dataGaps.add("未填写身体数据(血压/血糖/血脂、疾病史、用药、生活方式)");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ // 血压
|
|
|
+ String bp = hs.getBloodPressure();
|
|
|
+ if (bp != null && !bp.trim().isEmpty()) {
|
|
|
+ String[] parts = bp.split("/");
|
|
|
+ try {
|
|
|
+ int sys = Integer.parseInt(parts[0].trim());
|
|
|
+ int dia = parts.length > 1 ? Integer.parseInt(parts[1].trim()) : 0;
|
|
|
+ if (sys >= 140 || dia >= 90) {
|
|
|
+ problems.add("血压偏高(" + bp + "):需要关注血压管理,建议在医生指导下监测与干预");
|
|
|
+ } else if (sys < 90 || (dia > 0 && dia < 60)) {
|
|
|
+ problems.add("血压偏低(" + bp + "):需要关注低血压风险,避免剧烈变化");
|
|
|
+ }
|
|
|
+ } catch (NumberFormatException ignored) {
|
|
|
+ dataGaps.add("血压数据格式异常(" + bp + "),请核对");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // 血糖
|
|
|
+ if (hs.getBloodGlucose() != null && hs.getBloodGlucose().doubleValue() > 0) {
|
|
|
+ double g = hs.getBloodGlucose().doubleValue();
|
|
|
+ if (g >= 7.0) {
|
|
|
+ problems.add("空腹血糖偏高(" + g + " mmol/L):需要关注血糖管理,建议就医复测并按医嘱处理");
|
|
|
+ } else if (g > 6.1) {
|
|
|
+ problems.add("空腹血糖处于偏高区间(" + g + " mmol/L):建议复查并调整饮食结构");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // 血脂
|
|
|
+ List<Map<String, Object>> lipids = parseJsonArray(hs.getBloodLipids());
|
|
|
+ for (Map<String, Object> lp : lipids) {
|
|
|
+ String name = String.valueOf(lp.getOrDefault("name", "血脂"));
|
|
|
+ Object value = lp.get("value");
|
|
|
+ if (value != null && !String.valueOf(value).trim().isEmpty()) {
|
|
|
+ try {
|
|
|
+ double v = Double.parseDouble(String.valueOf(value));
|
|
|
+ // QA 修复(HL-03):按血脂项目细分阈值——总胆固醇/TC 5.2、LDL/低密度 3.4、
|
|
|
+ // 甘油三酯及其他 1.7;HDL 高好低坏不判高(>2.2 仅软提示偏高区间,不进 problems)
|
|
|
+ String upper = name.toUpperCase();
|
|
|
+ if (upper.contains("HDL") || name.contains("高密度")) {
|
|
|
+ if (v > 2.2) {
|
|
|
+ riskNotes.add(name + "数值处于偏高区间(" + v + "):高密度脂蛋白偏高通常无需干预,个别情况建议就医复查确认");
|
|
|
+ }
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ double threshold;
|
|
|
+ if (name.contains("总胆固醇") || upper.contains("TC")) {
|
|
|
+ threshold = 5.2;
|
|
|
+ } else if (upper.contains("LDL") || name.contains("低密度")) {
|
|
|
+ threshold = 3.4;
|
|
|
+ } else {
|
|
|
+ threshold = 1.7;
|
|
|
+ }
|
|
|
+ if (v > threshold) {
|
|
|
+ problems.add(name + "偏高(" + v + "):需要关注血脂水平,建议结合饮食调整并就医复查");
|
|
|
+ }
|
|
|
+ } catch (NumberFormatException ignored) {
|
|
|
+ dataGaps.add(name + "数据异常,请核对");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // 疾病史
|
|
|
+ List<String> diseases = parseNameList(hs.getDiseaseHistory());
|
|
|
+ if (!diseases.isEmpty()) {
|
|
|
+ problems.add("已记录疾病史:" + String.join("、", diseases) + "(方案需与此兼容,建议遵医嘱管理)");
|
|
|
+ }
|
|
|
+ // 用药
|
|
|
+ List<String> meds = parseNameList(hs.getMedications());
|
|
|
+ if (!meds.isEmpty()) {
|
|
|
+ riskNotes.add("在服药物:" + String.join("、", meds) + "(营养/饮食方案须规避相互作用,用药调整一律遵医嘱)");
|
|
|
+ }
|
|
|
+ // 生活方式
|
|
|
+ Map<String, Object> ls = parseJsonMap(hs.getLifestyle());
|
|
|
+ if (ls == null || ls.isEmpty()) {
|
|
|
+ dataGaps.add("未填写生活方式(睡眠/排便/情绪/抗生素史/过敏表现)");
|
|
|
+ } else {
|
|
|
+ Object sleepDuration = ls.get("sleepDuration");
|
|
|
+ if (isEmpty(sleepDuration)) dataGaps.add("未填写睡眠时长");
|
|
|
+ else if ("不足6小时".equals(sleepDuration) || "6-7小时".equals(sleepDuration)) {
|
|
|
+ problems.add("睡眠时长偏短(" + sleepDuration + "):睡眠不足会影响身体恢复与免疫力");
|
|
|
+ }
|
|
|
+ Object bowel = ls.get("bowelFrequency");
|
|
|
+ Object bowelForm = ls.get("bowelForm");
|
|
|
+ boolean bowelKnown = !isEmpty(bowel) || !isEmpty(bowelForm);
|
|
|
+ if (!bowelKnown) dataGaps.add("未填写排便信息");
|
|
|
+ Object antibiotic = ls.get("antibioticHistory");
|
|
|
+ if (antibiotic != null && String.valueOf(antibiotic).contains("是")) {
|
|
|
+ riskNotes.add("近 2 年曾使用抗生素:提示肠道菌群可能受影响,建议关注菌群重建(食品/生活方式层面)");
|
|
|
+ }
|
|
|
+ Object allergies = ls.get("allergySymptoms");
|
|
|
+ if (allergies instanceof List && !((List<?>) allergies).isEmpty()) {
|
|
|
+ problems.add("有过敏表现:" + ((List<?>) allergies).stream().map(String::valueOf).reduce((a, b) -> a + "、" + b).orElse(""));
|
|
|
+ }
|
|
|
+ Object mood = ls.get("mood");
|
|
|
+ if (mood != null && (String.valueOf(mood).contains("差") || String.valueOf(mood).contains("焦虑")
|
|
|
+ || String.valueOf(mood).contains("低落") || String.valueOf(mood).contains("烦躁"))) {
|
|
|
+ riskNotes.add("情绪状态需关注(" + mood + "):建议睡眠与压力管理,必要时寻求专业支持");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 从饮食偏好归纳「要解决的问题」 */
|
|
|
+ private void collectAttributionFromDiet(DietPreferences dp, List<String> problems, List<String> dataGaps) {
|
|
|
+ if (dp == null) {
|
|
|
+ dataGaps.add("未填写饮食习惯");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ Map<String, Object> ds = parseJsonMap(dp.getDietStructure());
|
|
|
+ if (ds != null) {
|
|
|
+ Object veg = ds.get("vegetableFruit");
|
|
|
+ if (veg != null && (String.valueOf(veg).contains("很少") || String.valueOf(veg).contains("不吃")
|
|
|
+ || String.valueOf(veg).contains("≤1份") || String.valueOf(veg).contains("<1份"))) {
|
|
|
+ problems.add("蔬菜水果摄入偏少(" + veg + "):膳食纤维与维生素可能不足,建议逐步增加种类与频率");
|
|
|
+ }
|
|
|
+ Object sugary = ds.get("sugaryDrinks");
|
|
|
+ if (sugary != null && (String.valueOf(sugary).contains("每天") || String.valueOf(sugary).contains("经常"))) {
|
|
|
+ problems.add("含糖饮料偏多(" + sugary + "):与血糖/体重/肠道菌群相关,建议减量并用白水/无糖替代");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ List<String> allergies = parseNameList(dp.getAllergies());
|
|
|
+ List<String> avoid = parseNameList(dp.getAbsoluteAvoid());
|
|
|
+ if (!allergies.isEmpty()) {
|
|
|
+ problems.add("食物过敏/不耐受:" + String.join("、", allergies) + "(饮食方案须规避)");
|
|
|
+ } else if (avoid.isEmpty() && !Boolean.TRUE.equals(dp.getNoPreference())) {
|
|
|
+ dataGaps.add("未确认食物过敏/忌口情况");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean isChild(HealthStatus hs, Long memberId) {
|
|
|
+ FamilyMember fm = memberId != null ? familyMemberMapper.selectById(memberId) : null;
|
|
|
+ if (fm != null && fm.getBirthday() != null) {
|
|
|
+ return computeAge(fm.getBirthday()) < 18;
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ private int computeAge(java.util.Date birthday) {
|
|
|
+ if (birthday == null) return -1;
|
|
|
+ java.util.Calendar now = java.util.Calendar.getInstance();
|
|
|
+ java.util.Calendar birth = java.util.Calendar.getInstance();
|
|
|
+ birth.setTime(birthday);
|
|
|
+ int age = now.get(java.util.Calendar.YEAR) - birth.get(java.util.Calendar.YEAR);
|
|
|
+ if (now.get(java.util.Calendar.DAY_OF_YEAR) < birth.get(java.util.Calendar.DAY_OF_YEAR)) {
|
|
|
+ age--;
|
|
|
+ }
|
|
|
+ return age;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, Object> parseJsonMap(String json) {
|
|
|
+ if (json == null || json.trim().isEmpty()) return null;
|
|
|
+ try {
|
|
|
+ JsonNode node = objectMapper.readTree(json);
|
|
|
+ if (node == null || !node.isObject()) return null;
|
|
|
+ Map<String, Object> out = new LinkedHashMap<>();
|
|
|
+ java.util.Iterator<Map.Entry<String, JsonNode>> it = node.fields();
|
|
|
+ while (it.hasNext()) {
|
|
|
+ Map.Entry<String, JsonNode> e = it.next();
|
|
|
+ if (e.getValue().isTextual()) out.put(e.getKey(), e.getValue().asText());
|
|
|
+ else if (e.getValue().isArray()) {
|
|
|
+ List<String> arr = new ArrayList<>();
|
|
|
+ for (JsonNode n : e.getValue()) arr.add(n.asText());
|
|
|
+ out.put(e.getKey(), arr);
|
|
|
+ } else out.put(e.getKey(), e.getValue().asText());
|
|
|
+ }
|
|
|
+ return out;
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("解析 JSON Map 失败: {}", e.getMessage());
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private List<Map<String, Object>> parseJsonArray(String json) {
|
|
|
+ List<Map<String, Object>> out = new ArrayList<>();
|
|
|
+ if (json == null || json.trim().isEmpty()) return out;
|
|
|
+ try {
|
|
|
+ JsonNode node = objectMapper.readTree(json);
|
|
|
+ if (node != null && node.isArray()) {
|
|
|
+ for (JsonNode n : node) {
|
|
|
+ Map<String, Object> row = new LinkedHashMap<>();
|
|
|
+ if (n.isObject()) {
|
|
|
+ java.util.Iterator<Map.Entry<String, JsonNode>> it = n.fields();
|
|
|
+ while (it.hasNext()) {
|
|
|
+ Map.Entry<String, JsonNode> e = it.next();
|
|
|
+ row.put(e.getKey(), e.getValue().isTextual() ? e.getValue().asText() : e.getValue());
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ row.put("name", n.asText());
|
|
|
+ }
|
|
|
+ out.add(row);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("解析 JSON Array 失败: {}", e.getMessage());
|
|
|
+ }
|
|
|
+ return out;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 解析 JSON 字符串数组 [{name,note}] 或纯字符串数组为名称列表 */
|
|
|
+ private List<String> parseNameList(String json) {
|
|
|
+ List<String> out = new ArrayList<>();
|
|
|
+ if (json == null || json.trim().isEmpty()) return out;
|
|
|
+ try {
|
|
|
+ JsonNode node = objectMapper.readTree(json);
|
|
|
+ if (node != null && node.isArray()) {
|
|
|
+ for (JsonNode n : node) {
|
|
|
+ if (n.isTextual()) {
|
|
|
+ if (!n.asText().trim().isEmpty()) out.add(n.asText().trim());
|
|
|
+ } else if (n.isObject() && n.has("name")) {
|
|
|
+ String name = n.get("name").asText("");
|
|
|
+ if (!name.trim().isEmpty()) out.add(name.trim());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ // 非 JSON:按逗号/顿号分隔尝试
|
|
|
+ for (String s : json.split("[,,、]")) {
|
|
|
+ if (s != null && !s.trim().isEmpty()) out.add(s.trim());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return out;
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean isEmpty(Object v) {
|
|
|
+ return v == null || String.valueOf(v).trim().isEmpty();
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean containsAny(String text, String... keys) {
|
|
|
+ if (text == null) return false;
|
|
|
+ for (String k : keys) {
|
|
|
+ if (text.contains(k)) return true;
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** followup_xxx_<memberId> → 可读问题主题 */
|
|
|
+ private String demapQuestionId(String questionId) {
|
|
|
+ if (questionId == null) return "补充说明";
|
|
|
+ if (questionId.startsWith("followup_sleep")) return "睡眠情况";
|
|
|
+ if (questionId.startsWith("followup_bowel")) return "肠道/排便情况";
|
|
|
+ if (questionId.startsWith("followup_allergy")) return "过敏情况";
|
|
|
+ if (questionId.startsWith("followup_child_med")) return "用药情况";
|
|
|
+ return "补充说明";
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* 渲染多人方案 planContent:先共性,再各成员个性。
|
|
|
* LangGraph 返回结构:{common: {overview, sections}, individual: {memberId: {overview, sections}}}
|