|
@@ -2,19 +2,26 @@ package com.etotem.cfc.service.impl;
|
|
|
|
|
|
|
|
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
|
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
|
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
|
|
|
|
+import com.etotem.cfc.dto.AttributionInput;
|
|
|
|
|
+import com.etotem.cfc.dto.AttributionReportVO;
|
|
|
import com.etotem.cfc.dto.PlanApproveResultDTO;
|
|
import com.etotem.cfc.dto.PlanApproveResultDTO;
|
|
|
|
|
+import com.etotem.cfc.entity.DanAssessmentResult;
|
|
|
import com.etotem.cfc.entity.FamilyMember;
|
|
import com.etotem.cfc.entity.FamilyMember;
|
|
|
import com.etotem.cfc.entity.FiveDimensionSelfCheck;
|
|
import com.etotem.cfc.entity.FiveDimensionSelfCheck;
|
|
|
import com.etotem.cfc.entity.HealthPlan;
|
|
import com.etotem.cfc.entity.HealthPlan;
|
|
|
|
|
+import com.etotem.cfc.entity.HealthReport;
|
|
|
import com.etotem.cfc.entity.Task;
|
|
import com.etotem.cfc.entity.Task;
|
|
|
import com.etotem.cfc.entity.Family;
|
|
import com.etotem.cfc.entity.Family;
|
|
|
import com.etotem.cfc.entity.User;
|
|
import com.etotem.cfc.entity.User;
|
|
|
|
|
+import com.etotem.cfc.mapper.DanAssessmentResultMapper;
|
|
|
import com.etotem.cfc.mapper.FamilyMapper;
|
|
import com.etotem.cfc.mapper.FamilyMapper;
|
|
|
import com.etotem.cfc.mapper.FamilyMemberMapper;
|
|
import com.etotem.cfc.mapper.FamilyMemberMapper;
|
|
|
import com.etotem.cfc.mapper.FiveDimensionSelfCheckMapper;
|
|
import com.etotem.cfc.mapper.FiveDimensionSelfCheckMapper;
|
|
|
import com.etotem.cfc.mapper.HealthPlanMapper;
|
|
import com.etotem.cfc.mapper.HealthPlanMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.HealthReportMapper;
|
|
|
import com.etotem.cfc.mapper.TaskMapper;
|
|
import com.etotem.cfc.mapper.TaskMapper;
|
|
|
import com.etotem.cfc.mapper.UserMapper;
|
|
import com.etotem.cfc.mapper.UserMapper;
|
|
|
|
|
+import com.etotem.cfc.service.AttributionAnalysisService;
|
|
|
import com.etotem.cfc.service.HealthPlanService;
|
|
import com.etotem.cfc.service.HealthPlanService;
|
|
|
import lombok.extern.slf4j.Slf4j;
|
|
import lombok.extern.slf4j.Slf4j;
|
|
|
import org.springframework.beans.factory.annotation.Value;
|
|
import org.springframework.beans.factory.annotation.Value;
|
|
@@ -61,6 +68,15 @@ public class HealthPlanServiceImpl implements HealthPlanService {
|
|
|
@Resource
|
|
@Resource
|
|
|
private FiveDimensionSelfCheckMapper selfCheckMapper;
|
|
private FiveDimensionSelfCheckMapper selfCheckMapper;
|
|
|
|
|
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private HealthReportMapper healthReportMapper;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private DanAssessmentResultMapper danAssessmentResultMapper;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private AttributionAnalysisService attributionAnalysisService;
|
|
|
|
|
+
|
|
|
@Resource
|
|
@Resource
|
|
|
private com.etotem.cfc.service.AiGateway aiGateway;
|
|
private com.etotem.cfc.service.AiGateway aiGateway;
|
|
|
|
|
|
|
@@ -276,8 +292,25 @@ public class HealthPlanServiceImpl implements HealthPlanService {
|
|
|
|
|
|
|
|
@Override
|
|
@Override
|
|
|
public Object generatePlanResult(Long familyId, String memberIds, String dimensions, String goal) {
|
|
public Object generatePlanResult(Long familyId, String memberIds, String dimensions, String goal) {
|
|
|
|
|
+ Object result = generatePlanResultWithFollowUp(familyId, memberIds, dimensions, goal, null);
|
|
|
|
|
+ // 向后兼容:单人生成返回 String(planContent);多人返回 Map(结构同上,多 followUpQuestions 键)
|
|
|
|
|
+ if (result instanceof Map) {
|
|
|
|
|
+ Map<?, ?> m = (Map<?, ?>) result;
|
|
|
|
|
+ if (m.containsKey("planContent") && !m.containsKey("common")) {
|
|
|
|
|
+ return m.get("planContent");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public Object generatePlanResultWithFollowUp(Long familyId, String memberIds, String dimensions,
|
|
|
|
|
+ String goal, java.util.Map<String, Object> followUpAnswers) {
|
|
|
if (langgraphBaseUrl == null || langgraphBaseUrl.trim().isEmpty()) {
|
|
if (langgraphBaseUrl == null || langgraphBaseUrl.trim().isEmpty()) {
|
|
|
- return buildSamplePlan(memberIds, dimensions, goal);
|
|
|
|
|
|
|
+ Map<String, Object> fallback = new HashMap<>();
|
|
|
|
|
+ fallback.put("planContent", buildSamplePlan(memberIds, dimensions, goal));
|
|
|
|
|
+ fallback.put("followUpQuestions", new ArrayList<>());
|
|
|
|
|
+ return fallback;
|
|
|
}
|
|
}
|
|
|
try {
|
|
try {
|
|
|
RestTemplate restTemplate = new RestTemplate();
|
|
RestTemplate restTemplate = new RestTemplate();
|
|
@@ -286,21 +319,29 @@ public class HealthPlanServiceImpl implements HealthPlanService {
|
|
|
body.put("member_ids", memberIds);
|
|
body.put("member_ids", memberIds);
|
|
|
body.put("dimensions", dimensions);
|
|
body.put("dimensions", dimensions);
|
|
|
body.put("goal", goal);
|
|
body.put("goal", goal);
|
|
|
|
|
+ // R3:用户对话补充回答(首次为 null,LangGraph 据此返回不确定点 followUpQuestions)
|
|
|
|
|
+ if (followUpAnswers != null && !followUpAnswers.isEmpty()) {
|
|
|
|
|
+ body.put("follow_up_answers", followUpAnswers);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ body.put("follow_up_answers", new java.util.LinkedHashMap<String, Object>());
|
|
|
|
|
+ }
|
|
|
String url = langgraphBaseUrl + "/api/v1/health/plan/generate";
|
|
String url = langgraphBaseUrl + "/api/v1/health/plan/generate";
|
|
|
ResponseEntity<String> response = restTemplate.postForEntity(url, body, String.class);
|
|
ResponseEntity<String> response = restTemplate.postForEntity(url, body, String.class);
|
|
|
if (response.getBody() != null && response.getStatusCode().is2xxSuccessful()) {
|
|
if (response.getBody() != null && response.getStatusCode().is2xxSuccessful()) {
|
|
|
JsonNode root = objectMapper.readTree(response.getBody());
|
|
JsonNode root = objectMapper.readTree(response.getBody());
|
|
|
if (root.has("success") && root.get("success").asBoolean()) {
|
|
if (root.has("success") && root.get("success").asBoolean()) {
|
|
|
JsonNode data = root.get("data");
|
|
JsonNode data = root.get("data");
|
|
|
|
|
+ List<Map<String, Object>> followUpQuestions = extractFollowUpQuestions(data);
|
|
|
// 多人方案:common(共性)+ individual(各成员个性)
|
|
// 多人方案:common(共性)+ individual(各成员个性)
|
|
|
if (data != null && data.has("common")) {
|
|
if (data != null && data.has("common")) {
|
|
|
Map<String, Object> multi = new HashMap<>();
|
|
Map<String, Object> multi = new HashMap<>();
|
|
|
multi.put("planContent", buildMultiMemberPlanContent(data));
|
|
multi.put("planContent", buildMultiMemberPlanContent(data));
|
|
|
if (data.has("common")) multi.put("common", data.get("common"));
|
|
if (data.has("common")) multi.put("common", data.get("common"));
|
|
|
if (data.has("individual")) multi.put("individual", data.get("individual"));
|
|
if (data.has("individual")) multi.put("individual", data.get("individual"));
|
|
|
|
|
+ multi.put("followUpQuestions", followUpQuestions);
|
|
|
return multi;
|
|
return multi;
|
|
|
}
|
|
}
|
|
|
- // 单人生成:保持原 flat 结构
|
|
|
|
|
|
|
+ // 单人生成:{planContent, followUpQuestions} 结构化返回(前端可先收集不确定点再回传)
|
|
|
if (data != null && data.has("overview")) {
|
|
if (data != null && data.has("overview")) {
|
|
|
StringBuilder sb = new StringBuilder();
|
|
StringBuilder sb = new StringBuilder();
|
|
|
sb.append("## 方案概述\n\n").append(data.get("overview").asText()).append("\n\n");
|
|
sb.append("## 方案概述\n\n").append(data.get("overview").asText()).append("\n\n");
|
|
@@ -310,16 +351,342 @@ public class HealthPlanServiceImpl implements HealthPlanService {
|
|
|
sb.append(section.get("content").asText()).append("\n\n");
|
|
sb.append(section.get("content").asText()).append("\n\n");
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
- return sb.toString();
|
|
|
|
|
|
|
+ Map<String, Object> single = new HashMap<>();
|
|
|
|
|
+ single.put("planContent", sb.toString());
|
|
|
|
|
+ single.put("followUpQuestions", followUpQuestions);
|
|
|
|
|
+ return single;
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
return root.has("data") && root.get("data").has("raw")
|
|
return root.has("data") && root.get("data").has("raw")
|
|
|
- ? root.get("data").get("raw").asText() : response.getBody();
|
|
|
|
|
|
|
+ ? wrapPlanResult(root.get("data").get("raw").asText()) : wrapPlanResult(response.getBody());
|
|
|
}
|
|
}
|
|
|
} catch (Exception e) {
|
|
} catch (Exception e) {
|
|
|
log.warn("调用LangGraph生成方案失败: {}", e.getMessage());
|
|
log.warn("调用LangGraph生成方案失败: {}", e.getMessage());
|
|
|
}
|
|
}
|
|
|
- return buildSamplePlan(memberIds, dimensions, goal);
|
|
|
|
|
|
|
+ return wrapPlanResult(buildSamplePlan(memberIds, dimensions, goal));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 抽取 LangGraph 返回的不确定点(follow_up_questions)为 List<Map>;缺失返回空列表 */
|
|
|
|
|
+ private List<Map<String, Object>> extractFollowUpQuestions(JsonNode data) {
|
|
|
|
|
+ List<Map<String, Object>> out = new ArrayList<>();
|
|
|
|
|
+ if (data == null || !data.has("follow_up_questions") || !data.get("follow_up_questions").isArray()) {
|
|
|
|
|
+ return out;
|
|
|
|
|
+ }
|
|
|
|
|
+ for (JsonNode q : data.get("follow_up_questions")) {
|
|
|
|
|
+ Map<String, Object> item = new LinkedHashMap<>();
|
|
|
|
|
+ if (q.has("id")) item.put("id", q.get("id").asText());
|
|
|
|
|
+ item.put("question", q.has("question") ? q.get("question").asText() : "");
|
|
|
|
|
+ if (q.has("hint")) item.put("hint", q.get("hint").asText());
|
|
|
|
|
+ out.add(item);
|
|
|
|
|
+ }
|
|
|
|
|
+ return out;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 将任意生成结果包装为统一的 {planContent, followUpQuestions:[]} 结构(降级/原始兜底分支) */
|
|
|
|
|
+ private Map<String, Object> wrapPlanResult(String planContent) {
|
|
|
|
|
+ Map<String, Object> out = new HashMap<>();
|
|
|
|
|
+ out.put("planContent", planContent != null ? planContent : "");
|
|
|
|
|
+ out.put("followUpQuestions", new ArrayList<>());
|
|
|
|
|
+ return out;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ===== HL-03/R2 归因方案(复用 AttributionAnalysisService 归因逻辑)=====
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public Map<String, Object> generateAttributionSummary(Long familyId, String memberIds, String goal) {
|
|
|
|
|
+ List<Long> memberIdList = parseIds(memberIds);
|
|
|
|
|
+ Map<String, Object> result = new LinkedHashMap<>();
|
|
|
|
|
+
|
|
|
|
|
+ // 1) 收集每个成员的检测数据(菌群报告 / DAN 结果,各取最新)
|
|
|
|
|
+ List<Long> gutReportIds = findGutReportIds(familyId, memberIdList);
|
|
|
|
|
+ List<Long> danResultIds = findDanResultIds(familyId, memberIdList);
|
|
|
|
|
+
|
|
|
|
|
+ // 2) 有检测数据 → 真实归因分析(复用 AttributionAnalysisService.analyze,绕开关卡直调)
|
|
|
|
|
+ if (!gutReportIds.isEmpty() || !danResultIds.isEmpty()) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ AttributionInput input = new AttributionInput();
|
|
|
|
|
+ input.setFamilyId(familyId);
|
|
|
|
|
+ input.setMemberIds(memberIdList);
|
|
|
|
|
+ input.setGutReportIds(gutReportIds);
|
|
|
|
|
+ input.setDanResultIds(danResultIds);
|
|
|
|
|
+ AttributionReportVO vo = attributionAnalysisService.analyze(input);
|
|
|
|
|
+ result.put("mode", "analysis");
|
|
|
|
|
+ result.put("attributionId", vo.getAttributionId());
|
|
|
|
|
+ result.putAll(extractAttributionSummary(vo.getReportJson()));
|
|
|
|
|
+ if (vo.getWarnings() != null) result.put("warnings", vo.getWarnings());
|
|
|
|
|
+ if (goal != null && !goal.trim().isEmpty()) result.put("goal", goal);
|
|
|
|
|
+ return result;
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("真实归因分析失败,降级健康自检规则归因: {}", e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 3) 无检测数据 / 归因失败 → 五维健康自检规则降级
|
|
|
|
|
+ result.put("mode", "fallback");
|
|
|
|
|
+ result.putAll(buildFallbackAttribution(familyId, memberIdList, goal));
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 按成员取最新菌群报告ID(health_reports.report_type='gut_flora',subjectId=成员ID) */
|
|
|
|
|
+ private List<Long> findGutReportIds(Long familyId, List<Long> memberIdList) {
|
|
|
|
|
+ List<Long> out = new ArrayList<>();
|
|
|
|
|
+ if (memberIdList == null || memberIdList.isEmpty()) {
|
|
|
|
|
+ return out;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ QueryWrapper<HealthReport> qw = new QueryWrapper<>();
|
|
|
|
|
+ qw.in("subject_id", memberIdList)
|
|
|
|
|
+ .eq("report_type", "gut_flora")
|
|
|
|
|
+ .eq("status", "active")
|
|
|
|
|
+ .orderByDesc("report_date").orderByDesc("id");
|
|
|
|
|
+ List<HealthReport> reports = healthReportMapper.selectList(qw);
|
|
|
|
|
+ // 每个成员只取最新一条(orderByDesc 后首个出现者)
|
|
|
|
|
+ java.util.Set<Long> seen = new java.util.LinkedHashSet<>();
|
|
|
|
|
+ for (HealthReport r : reports) {
|
|
|
|
|
+ if (r.getSubjectId() != null && seen.add(r.getSubjectId())) {
|
|
|
|
|
+ out.add(r.getId());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("查询菌群报告失败: {}", e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ return out;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 按成员取最新 DAN 结果ID(dan_assessment_results.family_member_id=成员ID,completed 状态) */
|
|
|
|
|
+ private List<Long> findDanResultIds(Long familyId, List<Long> memberIdList) {
|
|
|
|
|
+ List<Long> out = new ArrayList<>();
|
|
|
|
|
+ if (memberIdList == null || memberIdList.isEmpty()) {
|
|
|
|
|
+ return out;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ QueryWrapper<DanAssessmentResult> qw = new QueryWrapper<>();
|
|
|
|
|
+ qw.in("family_member_id", memberIdList)
|
|
|
|
|
+ .eq("status", "completed")
|
|
|
|
|
+ .orderByDesc("id");
|
|
|
|
|
+ List<DanAssessmentResult> dans = danAssessmentResultMapper.selectList(qw);
|
|
|
|
|
+ java.util.Set<Long> seen = new java.util.LinkedHashSet<>();
|
|
|
|
|
+ for (DanAssessmentResult d : dans) {
|
|
|
|
|
+ if (d.getFamilyMemberId() != null && seen.add(d.getFamilyMemberId())) {
|
|
|
|
|
+ out.add(d.getId());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("查询 DAN 测评结果失败: {}", e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ return out;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 从十节归因报告 JSON 提取前端「归因方案」摘要 */
|
|
|
|
|
+ private Map<String, Object> extractAttributionSummary(String reportJson) {
|
|
|
|
|
+ Map<String, Object> out = new LinkedHashMap<>();
|
|
|
|
|
+ if (reportJson == null || reportJson.trim().isEmpty()) {
|
|
|
|
|
+ return out;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ JsonNode root = objectMapper.readTree(reportJson);
|
|
|
|
|
+ JsonNode sections = root.path("sections");
|
|
|
|
|
+ JsonNode conclusion = sections.path("conclusion");
|
|
|
|
|
+ out.put("conclusion", conclusion.path("headline").asText(""));
|
|
|
|
|
+ if (conclusion.path("mainDimensions").isArray()) {
|
|
|
|
|
+ out.put("mainDimensions", jsonArrayToList(conclusion.path("mainDimensions")));
|
|
|
|
|
+ }
|
|
|
|
|
+ out.put("summary", sections.path("closing").path("line").asText(""));
|
|
|
|
|
+
|
|
|
|
|
+ // 逐人归因(surface/essence/短板维度)
|
|
|
|
|
+ if (sections.path("perMember").isArray()) {
|
|
|
|
|
+ List<Map<String, Object>> members = new ArrayList<>();
|
|
|
|
|
+ for (JsonNode m : sections.path("perMember")) {
|
|
|
|
|
+ Map<String, Object> row = new LinkedHashMap<>();
|
|
|
|
|
+ row.put("memberId", m.path("memberId").asLong());
|
|
|
|
|
+ row.put("name", m.path("name").asText(""));
|
|
|
|
|
+ row.put("roleLabel", m.path("roleLabel").asText(""));
|
|
|
|
|
+ row.put("surface", m.path("surface").asText(""));
|
|
|
|
|
+ row.put("essence", m.path("essence").asText(""));
|
|
|
|
|
+ row.put("weakDimensions", jsonArrayToList(m.path("weakDimensions")));
|
|
|
|
|
+ members.add(row);
|
|
|
|
|
+ }
|
|
|
|
|
+ out.put("members", members);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 教练提问序列
|
|
|
|
|
+ if (sections.path("coachQuestions").isArray()) {
|
|
|
|
|
+ out.put("coachQuestions", jsonArrayToList(sections.path("coachQuestions")));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 12 周可检验目标(前 8 条)
|
|
|
|
|
+ if (sections.path("goals").isArray()) {
|
|
|
|
|
+ List<Map<String, Object>> goals = new ArrayList<>();
|
|
|
|
|
+ int cnt = 0;
|
|
|
|
|
+ for (JsonNode g : sections.path("goals")) {
|
|
|
|
|
+ if (cnt++ >= 8) break;
|
|
|
|
|
+ Map<String, Object> row = new LinkedHashMap<>();
|
|
|
|
|
+ row.put("indicator", g.path("indicator").asText(""));
|
|
|
|
|
+ row.put("now", g.path("now").asText(""));
|
|
|
|
|
+ row.put("target", g.path("target").asText(""));
|
|
|
|
|
+ row.put("owner", g.path("owner").asText(""));
|
|
|
|
|
+ goals.add(row);
|
|
|
|
|
+ }
|
|
|
|
|
+ out.put("goals", goals);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 数据来源摘要(按 normSource 归类)
|
|
|
|
|
+ List<String> dataSources = new ArrayList<>();
|
|
|
|
|
+ int gut = 0;
|
|
|
|
|
+ int dan = 0;
|
|
|
|
|
+ for (JsonNode o : sections.path("overview")) {
|
|
|
|
|
+ String src = o.path("normSource").asText("");
|
|
|
|
|
+ if (src.contains("cognitive_norms") || src.contains("health_norm_reference") || src.contains("dan")) {
|
|
|
|
|
+ dan++;
|
|
|
|
|
+ } else if (src.contains("populationLevel") || src.contains("attribution_norm_values")
|
|
|
|
|
+ || src.contains("gut") || src.contains("flora")) {
|
|
|
|
|
+ gut++;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (gut > 0) dataSources.add("菌群读数 " + gut + " 项");
|
|
|
|
|
+ if (dan > 0) dataSources.add("DAN 读数 " + dan + " 项");
|
|
|
|
|
+ if (dataSources.isEmpty()) dataSources.add("归因分析(检测数据)");
|
|
|
|
|
+ out.put("dataSources", dataSources);
|
|
|
|
|
+
|
|
|
|
|
+ // 命中红线(仅提示,不阻断)
|
|
|
|
|
+ if (sections.path("redlines").isArray()) {
|
|
|
|
|
+ List<String> hits = new ArrayList<>();
|
|
|
|
|
+ for (JsonNode r : sections.path("redlines")) {
|
|
|
|
|
+ if (r.path("hit").asBoolean(false)) {
|
|
|
|
|
+ hits.add(r.path("code").asText(""));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!hits.isEmpty()) out.put("redlineHits", hits);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("解析归因报告摘要失败: {}", e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ return out;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 降级:无检测数据时基于五维健康自检的低分维度做规则归因 */
|
|
|
|
|
+ private Map<String, Object> buildFallbackAttribution(Long familyId, List<Long> memberIdList, String goal) {
|
|
|
|
|
+ Map<String, Object> out = new LinkedHashMap<>();
|
|
|
|
|
+ out.put("conclusion", "健康自检维度提示(暂无检测报告,按五维自检低分维度规则归因)");
|
|
|
|
|
+ out.put("summary", "当前未关联肠道菌群/儿童成人测评数据,基于五维家庭自检的低分维度做初步归因;补充检测报告后可更精准定位问题。");
|
|
|
|
|
+ out.put("dataSources", java.util.Collections.singletonList("五维家庭自检"));
|
|
|
|
|
+
|
|
|
|
|
+ List<Map<String, Object>> members = new ArrayList<>();
|
|
|
|
|
+ if (memberIdList != null) {
|
|
|
|
|
+ for (Long mid : memberIdList) {
|
|
|
|
|
+ Map<String, Object> row = new LinkedHashMap<>();
|
|
|
|
|
+ row.put("memberId", mid);
|
|
|
|
|
+ String name = memberName(mid);
|
|
|
|
|
+ row.put("name", name);
|
|
|
|
|
+ row.put("roleLabel", name);
|
|
|
|
|
+ FiveDimensionSelfCheck check = latestSelfCheck(mid);
|
|
|
|
|
+ List<String> weak = new ArrayList<>();
|
|
|
|
|
+ Map<String, Object> dims = new LinkedHashMap<>();
|
|
|
|
|
+ if (check != null && check.getScoresJson() != null && !check.getScoresJson().trim().isEmpty()) {
|
|
|
|
|
+ dims.putAll(parseScoreMap(check.getScoresJson()));
|
|
|
|
|
+ for (Map.Entry<String, Object> e : dims.entrySet()) {
|
|
|
|
|
+ Object v = e.getValue();
|
|
|
|
|
+ if (v instanceof Number && !(v instanceof java.math.BigDecimal)
|
|
|
|
|
+ && ((Number) v).doubleValue() <= 5.0d) {
|
|
|
|
|
+ weak.add(e.getKey());
|
|
|
|
|
+ } else if (v instanceof java.math.BigDecimal
|
|
|
|
|
+ && ((java.math.BigDecimal) v).doubleValue() <= 5.0d) {
|
|
|
|
|
+ weak.add(e.getKey());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ row.put("weakDimensions", weak);
|
|
|
|
|
+ row.put("dimScores", dims);
|
|
|
|
|
+ row.put("surface", weak.isEmpty() ? "暂无明显低分维度" : "低分维度:" + String.join("/", weak));
|
|
|
|
|
+ row.put("essence", "需结合健康自检与生活习惯进一步确认(无检测数据)");
|
|
|
|
|
+ members.add(row);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ out.put("members", members);
|
|
|
|
|
+ if (goal != null && !goal.trim().isEmpty()) {
|
|
|
|
|
+ out.put("goal", goal);
|
|
|
|
|
+ }
|
|
|
|
|
+ return out;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 成员昵称(family_members.nickname),取不到时回退 "成员{id}" */
|
|
|
|
|
+ private String memberName(Long memberId) {
|
|
|
|
|
+ if (memberId == null) {
|
|
|
|
|
+ return "成员";
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ FamilyMember fm = familyMemberMapper.selectById(memberId);
|
|
|
|
|
+ if (fm != null && fm.getNickname() != null && !fm.getNickname().trim().isEmpty()) {
|
|
|
|
|
+ return fm.getNickname();
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception ignored) {
|
|
|
|
|
+ // 成员不存在时回退
|
|
|
|
|
+ }
|
|
|
|
|
+ return "成员" + memberId;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 成员最近一次五维自检(无则 null) */
|
|
|
|
|
+ private FiveDimensionSelfCheck latestSelfCheck(Long memberId) {
|
|
|
|
|
+ if (memberId == null) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ QueryWrapper<FiveDimensionSelfCheck> qw = new QueryWrapper<>();
|
|
|
|
|
+ qw.eq("family_member_id", memberId).orderByDesc("created_at").last("LIMIT 1");
|
|
|
|
|
+ return selfCheckMapper.selectOne(qw);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("读取五维自检失败 memberId={}: {}", memberId, e.getMessage());
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 解析 {@code {"body":6,"wisdom":7,...}} 维度分数 JSON */
|
|
|
|
|
+ private Map<String, Object> parseScoreMap(String json) {
|
|
|
|
|
+ Map<String, Object> out = new LinkedHashMap<>();
|
|
|
|
|
+ if (json == null || json.trim().isEmpty()) {
|
|
|
|
|
+ return out;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ JsonNode node = objectMapper.readTree(json);
|
|
|
|
|
+ if (node != null && node.isObject()) {
|
|
|
|
|
+ java.util.Iterator<Map.Entry<String, JsonNode>> it = node.fields();
|
|
|
|
|
+ while (it.hasNext()) {
|
|
|
|
|
+ Map.Entry<String, JsonNode> e = it.next();
|
|
|
|
|
+ out.put(e.getKey(), e.getValue().isNumber() ? e.getValue().numberValue() : e.getValue().asText());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("解析五维自检分数失败: {}", e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ return out;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 逗号分隔 ID → List<Long> */
|
|
|
|
|
+ private List<Long> parseIds(String csv) {
|
|
|
|
|
+ List<Long> out = new ArrayList<>();
|
|
|
|
|
+ if (csv == null || csv.trim().isEmpty()) {
|
|
|
|
|
+ return out;
|
|
|
|
|
+ }
|
|
|
|
|
+ for (String s : csv.split(",")) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (!s.trim().isEmpty()) {
|
|
|
|
|
+ out.add(Long.valueOf(s.trim()));
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (NumberFormatException ignored) {
|
|
|
|
|
+ // 跳过非法 ID
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return out;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private List<String> jsonArrayToList(JsonNode arr) {
|
|
|
|
|
+ List<String> out = new ArrayList<>();
|
|
|
|
|
+ if (arr != null && arr.isArray()) {
|
|
|
|
|
+ for (JsonNode n : arr) {
|
|
|
|
|
+ out.add(n.asText());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return out;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|