HealthReportService.java 104 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394
  1. package com.etotem.cfc.service;
  2. import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
  3. import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
  4. import com.etotem.cfc.dto.MemberMatchResult;
  5. import com.etotem.cfc.dto.ParsedReportPayload;
  6. import com.etotem.cfc.entity.*;
  7. import com.etotem.cfc.mapper.FamilyMemberMapper;
  8. import com.etotem.cfc.mapper.HealthDiseaseRiskMapper;
  9. import com.etotem.cfc.mapper.HealthGutFloraMapper;
  10. import com.etotem.cfc.mapper.HealthIndicatorMapper;
  11. import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
  12. import com.etotem.cfc.mapper.IndicatorValueMapper;
  13. import com.etotem.cfc.mapper.HealthReportDraftMapper;
  14. import com.etotem.cfc.mapper.HealthReportEditLogMapper;
  15. import com.etotem.cfc.mapper.HealthReportMapper;
  16. import com.etotem.cfc.mapper.HealthReportPermissionMapper;
  17. import com.etotem.cfc.mapper.UserMapper;
  18. import com.etotem.cfc.mapper.FoodMapper;
  19. import com.etotem.cfc.mapper.ReportBlockMapper;
  20. import com.etotem.cfc.mapper.ReportFoodSuitabilityMapper;
  21. import com.fasterxml.jackson.databind.ObjectMapper;
  22. import lombok.extern.slf4j.Slf4j;
  23. import org.springframework.dao.DuplicateKeyException;
  24. import org.springframework.jdbc.core.JdbcTemplate;
  25. import org.springframework.stereotype.Service;
  26. import org.springframework.transaction.annotation.Transactional;
  27. import javax.annotation.Resource;
  28. import java.util.ArrayList;
  29. import java.util.Arrays;
  30. import java.util.Collections;
  31. import java.util.Comparator;
  32. import java.util.Date;
  33. import java.util.HashMap;
  34. import java.util.HashSet;
  35. import java.util.List;
  36. import java.math.BigDecimal;
  37. import java.util.Map;
  38. import com.etotem.cfc.entity.MemberIndicatorCurrent;
  39. import java.util.Set;
  40. import com.etotem.cfc.util.SortUtil;
  41. /**
  42. * 健康检测报告服务 — 管理体检报告/菌群检测报告的录入与查询
  43. * 为五维能量中的"身(body)"维度提供数据源
  44. */
  45. @Slf4j
  46. @Service
  47. public class HealthReportService {
  48. @Resource
  49. private HealthReportMapper healthReportMapper;
  50. @Resource
  51. protected JdbcTemplate jdbcTemplate;
  52. @Resource
  53. private HealthIndicatorMapper healthIndicatorMapper;
  54. @Resource
  55. private HealthGutFloraMapper healthGutFloraMapper;
  56. @Resource
  57. private HealthDiseaseRiskMapper healthDiseaseRiskMapper;
  58. @Resource
  59. private FamilyMemberMapper familyMemberMapper;
  60. @Resource
  61. private UserMapper userMapper;
  62. @Resource
  63. private IndicatorDefinitionMapper indicatorDefinitionMapper;
  64. @Resource
  65. private IndicatorValueMapper indicatorValueMapper;
  66. @Resource
  67. private HealthReportPermissionMapper healthReportPermissionMapper;
  68. @Resource
  69. private HealthReportDraftMapper draftMapper;
  70. @Resource
  71. private HealthReportEditLogMapper healthReportEditLogMapper;
  72. @Resource
  73. private ReportSummaryService reportSummaryService;
  74. @Resource
  75. private ObjectMapper objectMapper;
  76. @Resource
  77. private DimensionScoreService dimensionScoreService;
  78. // @Lazy 打破循环依赖:FoodRecommendService 内部注入 HealthReportService
  79. @Resource
  80. @org.springframework.context.annotation.Lazy
  81. private FoodRecommendService foodRecommendService;
  82. @Resource
  83. private ReportBlockService reportBlockService;
  84. @Resource
  85. private ReportBlockAssembler reportBlockAssembler;
  86. @Resource
  87. private ReportFoodSuitabilityMapper reportFoodSuitabilityMapper;
  88. @Resource
  89. private ReportBlockMapper reportBlockMapper;
  90. @Resource
  91. private FoodMapper foodMapper;
  92. @Resource
  93. @org.springframework.context.annotation.Lazy
  94. private MemberIndicatorCurrentService memberIndicatorCurrentService;
  95. @Resource
  96. private com.etotem.cfc.service.NormalizeIndicatorPipeline normalizeIndicatorPipeline;
  97. /**
  98. * 创建健康报告(含指标列表)
  99. */
  100. @Transactional
  101. public HealthReport createReport(HealthReport report, List<HealthIndicator> indicators) {
  102. report.setCreatedAt(new Date());
  103. report.setUpdatedAt(new Date());
  104. if (report.getStatus() == null) {
  105. report.setStatus("active");
  106. }
  107. healthReportMapper.insert(report);
  108. try {
  109. reportSummaryService.syncFromHealthReport(report);
  110. } catch (Exception e) {
  111. log.warn("同步健康报告汇总失败 id={}", report.getId(), e);
  112. }
  113. if (indicators != null && !indicators.isEmpty()) {
  114. // 指标去重: 同一批中相同 indicatorName 的只保留最后一条
  115. java.util.Map<String, HealthIndicator> dedupMap = new java.util.LinkedHashMap<>();
  116. for (HealthIndicator ind : indicators) {
  117. String key = (ind.getCategory() != null ? ind.getCategory() : "") + "|" + (ind.getIndicatorName() != null ? ind.getIndicatorName() : "");
  118. dedupMap.put(key, ind);
  119. }
  120. for (HealthIndicator ind : dedupMap.values()) {
  121. ind.setReportId(report.getId());
  122. if (ind.getCollectionDate() == null) {
  123. ind.setCollectionDate(report.getReportDate());
  124. }
  125. ind.setCreatedAt(new Date());
  126. truncateIndicatorColumns(ind);
  127. healthIndicatorMapper.insert(ind);
  128. }
  129. }
  130. log.info("健康报告已创建: id={}, userId={}, type={}, score={}",
  131. report.getId(), report.getUserId(), report.getReportType(), report.getOverallScore());
  132. return report;
  133. }
  134. /**
  135. * 截断指标字符串字段到数据库列最大长度(null 安全)。
  136. * 自由文本来源(LangGraph/opencode 解析、手动录入)status/category 等字段无长度约束,
  137. * 超长写入会触发 MySQL Data too long 导致整单入库失败。
  138. */
  139. private void truncateIndicatorColumns(HealthIndicator ind) {
  140. ind.setCategory(truncate(ind.getCategory(), 50));
  141. ind.setIndicatorName(truncate(ind.getIndicatorName(), 100));
  142. ind.setIndicatorValue(truncate(ind.getIndicatorValue(), 100));
  143. ind.setUnit(truncate(ind.getUnit(), 50));
  144. ind.setRefRange(truncate(ind.getRefRange(), 200));
  145. ind.setStatus(truncate(ind.getStatus(), 100));
  146. }
  147. private String truncate(String s, int maxLen) {
  148. if (s == null || s.length() <= maxLen) return s;
  149. return s.substring(0, maxLen);
  150. }
  151. /**
  152. * 创建健康报告(含指标列表 + 菌群数据 + 疾病风险评估)
  153. */
  154. @Transactional
  155. public HealthReport createReport(HealthReport report, List<HealthIndicator> indicators,
  156. List<HealthGutFlora> gutFlora, List<HealthDiseaseRisk> diseaseRisks) {
  157. // 调用已有逻辑创建报告和指标
  158. createReport(report, indicators);
  159. // 保存菌群数据
  160. if (gutFlora != null && !gutFlora.isEmpty()) {
  161. saveGutFlora(report.getId(), gutFlora);
  162. }
  163. // 保存疾病风险评估
  164. if (diseaseRisks != null && !diseaseRisks.isEmpty()) {
  165. saveDiseaseRisks(report.getId(), diseaseRisks);
  166. }
  167. return report;
  168. }
  169. /**
  170. * 获取孩子最新的健康报告
  171. */
  172. public HealthReport getLatestReport(Long userId) {
  173. return healthReportMapper.selectOne(
  174. new LambdaQueryWrapper<HealthReport>()
  175. .and(w -> w.eq(HealthReport::getSubjectId, userId)
  176. .or(w2 -> w2.isNull(HealthReport::getSubjectId)
  177. .eq(HealthReport::getUserId, userId)))
  178. .eq(HealthReport::getStatus, "active")
  179. .orderByDesc(HealthReport::getReportDate)
  180. .last("LIMIT 1")
  181. );
  182. }
  183. /**
  184. * 按文件MD5查找已入库的报告ID(同一报告类型下),用于重复上传检测
  185. */
  186. public Long findReportIdByFileHash(String fileHash, String reportType) {
  187. if (fileHash == null || reportType == null) {
  188. return null;
  189. }
  190. HealthReport report = healthReportMapper.selectOne(
  191. new LambdaQueryWrapper<HealthReport>()
  192. .eq(HealthReport::getFileHash, fileHash)
  193. .eq(HealthReport::getReportType, reportType)
  194. .eq(HealthReport::getStatus, "active")
  195. .last("LIMIT 1")
  196. );
  197. return report != null ? report.getId() : null;
  198. }
  199. /**
  200. * 按文件MD5查找正在采集中的草稿ID,用于避免重复提交采集
  201. */
  202. public Long findCollectingDraftIdByFileHash(String fileHash) {
  203. if (fileHash == null) {
  204. return null;
  205. }
  206. HealthReportDraft draft = draftMapper.selectOne(
  207. new LambdaQueryWrapper<HealthReportDraft>()
  208. .eq(HealthReportDraft::getFileHash, fileHash)
  209. .eq(HealthReportDraft::getParseStatus, "collecting")
  210. .last("LIMIT 1")
  211. );
  212. return draft != null ? draft.getId() : null;
  213. }
  214. /**
  215. * 获取全家所有健康报告(用于方案页选择已有报告)
  216. * 优先通过家庭所有成员(subject_id/user_id)反查报告,兼容报告表 family_id 未填充的历史数据;
  217. * 同时补充按报告表 family_id 匹配的报告,合并去重。
  218. */
  219. public List<HealthReport> getFamilyReports(Long familyId) {
  220. if (familyId == null) {
  221. return Collections.emptyList();
  222. }
  223. // 1. 查家庭所有成员 id(family_members.id + user_id)
  224. Set<Long> memberIds = new HashSet<>();
  225. try {
  226. List<FamilyMember> members = familyMemberMapper.selectList(
  227. new LambdaQueryWrapper<FamilyMember>().eq(FamilyMember::getFamilyId, familyId));
  228. for (FamilyMember m : members) {
  229. memberIds.add(m.getId());
  230. if (m.getUserId() != null) {
  231. memberIds.add(m.getUserId());
  232. }
  233. }
  234. } catch (Exception e) {
  235. log.warn("查询家庭{}成员失败: {}", familyId, e.getMessage());
  236. }
  237. // 2. 按成员反查报告(subject_id/user_id 匹配)
  238. List<HealthReport> reports = new ArrayList<>();
  239. if (!memberIds.isEmpty()) {
  240. reports = healthReportMapper.selectList(
  241. new LambdaQueryWrapper<HealthReport>()
  242. .and(w -> w.in(HealthReport::getSubjectId, memberIds)
  243. .or(w2 -> w2.isNull(HealthReport::getSubjectId)
  244. .in(HealthReport::getUserId, memberIds)))
  245. .eq(HealthReport::getStatus, "active")
  246. .orderByDesc(HealthReport::getCreatedAt));
  247. }
  248. // 3. 补充按报告表 family_id 匹配的报告(去重)
  249. List<HealthReport> byFamily = healthReportMapper.selectList(
  250. new LambdaQueryWrapper<HealthReport>()
  251. .eq(HealthReport::getFamilyId, familyId)
  252. .eq(HealthReport::getStatus, "active")
  253. .orderByDesc(HealthReport::getCreatedAt));
  254. Set<Long> seen = new HashSet<>();
  255. for (HealthReport r : reports) {
  256. seen.add(r.getId());
  257. }
  258. for (HealthReport r : byFamily) {
  259. if (seen.add(r.getId())) {
  260. reports.add(r);
  261. }
  262. }
  263. // 按上传时间倒序(createdAt),默认按上传时间排序
  264. reports.sort((a, b) -> {
  265. java.util.Date da = a.getCreatedAt();
  266. java.util.Date db = b.getCreatedAt();
  267. if (da == null) return 1;
  268. if (db == null) return -1;
  269. return db.compareTo(da);
  270. });
  271. return reports;
  272. }
  273. /**
  274. * 获取某家庭成员的所有健康报告
  275. * 兼容 subject_id 的两种历史语义(users.id / family_members.id):
  276. * 先通过 family_members 表归一该成员的两种 id,再用 IN 匹配 subject_id/user_id
  277. */
  278. public List<HealthReport> getMemberReports(Long memberId) {
  279. return getMemberReports(memberId, null);
  280. }
  281. /**
  282. * 获取成员的报告列表(家庭隔离)。
  283. *
  284. * @param memberId 成员ID(兼容 users.id / family_members.id)
  285. * @param viewerFamilyId 查看者家庭ID(来自 JWT)。非空时以其为家庭边界,
  286. * 与 reassignReport 的权限校验保持一致,避免列表展示
  287. * 的报告在「重新分配归属」时被判为「报告不属于该家庭」。
  288. */
  289. public List<HealthReport> getMemberReports(Long memberId, Long viewerFamilyId) {
  290. if (memberId == null) {
  291. return Collections.emptyList();
  292. }
  293. Set<Long> ids = new HashSet<>();
  294. ids.add(memberId);
  295. Long memberFamilyId = null;
  296. try {
  297. FamilyMember fm = familyMemberMapper.selectById(memberId);
  298. if (fm != null) {
  299. ids.add(fm.getId());
  300. if (fm.getUserId() != null) {
  301. ids.add(fm.getUserId());
  302. }
  303. memberFamilyId = fm.getFamilyId();
  304. }
  305. // memberId 可能是 users.id,反查其关联的 family_members 行
  306. List<FamilyMember> linked = familyMemberMapper.selectList(
  307. new LambdaQueryWrapper<FamilyMember>().eq(FamilyMember::getUserId, memberId));
  308. for (FamilyMember item : linked) {
  309. ids.add(item.getId());
  310. if (memberFamilyId == null) {
  311. memberFamilyId = item.getFamilyId();
  312. }
  313. }
  314. } catch (Exception e) {
  315. log.warn("归一家庭成员ID失败 memberId={}: {}", memberId, e.getMessage());
  316. }
  317. // 家庭隔离:报告必须归属该家庭成员。列表侧必须与权限侧(reassignReport)遵守同一条家庭边界。
  318. // 查看者家庭(JWT)优先,与权限校验一致;为空时退回成员家庭。
  319. // 修复:此前以成员家庭为边界,当成员为跨家庭 users.id(多家庭)时与查看者家庭不一致,
  320. // 小程序列表展示后可点「取消归属」,却在 reassignReport 处抛「报告不属于该家庭」。
  321. Long boundaryFamilyId = (viewerFamilyId != null) ? viewerFamilyId : memberFamilyId;
  322. if (boundaryFamilyId == null) {
  323. log.warn("无法解析家庭边界,返回空报告列表 memberId={}", memberId);
  324. return Collections.emptyList();
  325. }
  326. // 严格绑定:经 reassignReport 取消归属(subject_id 被清空)的报告不在此列出,
  327. // 家庭「全部」视图 getFamilyReports 仍会展示它们。
  328. return healthReportMapper.selectList(
  329. new LambdaQueryWrapper<HealthReport>()
  330. .in(HealthReport::getSubjectId, ids)
  331. .eq(HealthReport::getFamilyId, boundaryFamilyId)
  332. .eq(HealthReport::getStatus, "active")
  333. .orderByDesc(HealthReport::getCreatedAt)
  334. );
  335. }
  336. /**
  337. * 获取某份报告的所有指标
  338. */
  339. public List<HealthIndicator> getReportIndicators(Long reportId) {
  340. return healthIndicatorMapper.selectList(
  341. new LambdaQueryWrapper<HealthIndicator>()
  342. .eq(HealthIndicator::getReportId, reportId)
  343. .orderByAsc(HealthIndicator::getSortOrder)
  344. );
  345. }
  346. /**
  347. * 计算孩子身体维度得分 (0-100)
  348. * <p>
  349. * 数据源优先级:
  350. * 1. 最新的健康检测报告 overallScore(如果1年内有效)
  351. * 2. 多份报告加权平均(如有体检报告+菌群报告)
  352. * 3. 无报告时返回null(由调用方决定降级策略)
  353. *
  354. * @param childId 孩子ID
  355. * @return 身体维度得分 (0-100), null表示无数据
  356. */
  357. public Integer calculateBodyScore(Long childId) {
  358. List<HealthReport> reports = healthReportMapper.selectList(
  359. new LambdaQueryWrapper<HealthReport>()
  360. .and(w -> w.eq(HealthReport::getSubjectId, childId)
  361. .or(w2 -> w2.isNull(HealthReport::getSubjectId)
  362. .eq(HealthReport::getUserId, childId)))
  363. .eq(HealthReport::getStatus, "active")
  364. .orderByDesc(HealthReport::getReportDate)
  365. );
  366. if (reports.isEmpty()) {
  367. return null;
  368. }
  369. // 取最新一份有overallScore的报告
  370. for (HealthReport r : reports) {
  371. if (r.getOverallScore() != null) {
  372. return clampScore(r.getOverallScore());
  373. }
  374. }
  375. // 所有报告都没有总分时,取子分值的加权平均
  376. int totalWeight = 0;
  377. int weightedSum = 0;
  378. int reportCount = reports.size();
  379. for (int i = 0; i < reportCount; i++) {
  380. HealthReport r = reports.get(i);
  381. int weight = reportCount - i;
  382. if (r.getOverallScore() != null) {
  383. weightedSum += r.getOverallScore() * weight;
  384. totalWeight += weight;
  385. }
  386. if (r.getGutHealthScore() != null) {
  387. weightedSum += r.getGutHealthScore() * weight;
  388. totalWeight += weight;
  389. }
  390. if (r.getChronicDiseaseScore() != null) {
  391. weightedSum += r.getChronicDiseaseScore() * weight;
  392. totalWeight += weight;
  393. }
  394. if (r.getNutritionScore() != null) {
  395. weightedSum += r.getNutritionScore() * weight;
  396. totalWeight += weight;
  397. }
  398. }
  399. if (totalWeight == 0) return null;
  400. return clampScore(weightedSum / totalWeight);
  401. }
  402. /**
  403. * 获取孩子身体维度的数据详情(健康指标解读)
  404. */
  405. public List<HealthIndicator> getBodyDetail(Long userId) {
  406. HealthReport latest = getLatestReport(userId);
  407. if (latest == null) return Collections.emptyList();
  408. return getReportIndicators(latest.getId());
  409. }
  410. /**
  411. * 将报告标记为已归档
  412. */
  413. @Transactional
  414. public void archiveReport(Long reportId) {
  415. HealthReport report = healthReportMapper.selectById(reportId);
  416. if (report != null) {
  417. report.setStatus("archived");
  418. report.setUpdatedAt(new Date());
  419. healthReportMapper.updateById(report);
  420. }
  421. }
  422. /**
  423. * 批量归档报告(软删除)— 逐个归档,返回实际归档成功的数量
  424. */
  425. @Transactional
  426. public int archiveReportsBatch(List<Long> reportIds) {
  427. if (reportIds == null || reportIds.isEmpty()) {
  428. return 0;
  429. }
  430. int archived = 0;
  431. for (Long reportId : reportIds) {
  432. if (reportId == null) continue;
  433. HealthReport report = healthReportMapper.selectById(reportId);
  434. if (report != null && !"archived".equals(report.getStatus())) {
  435. report.setStatus("archived");
  436. report.setUpdatedAt(new Date());
  437. healthReportMapper.updateById(report);
  438. archived++;
  439. }
  440. }
  441. return archived;
  442. }
  443. // ======================== 菌群+疾病风险专数据 ========================
  444. /**
  445. * 获取报告的菌群数据
  446. */
  447. public List<HealthGutFlora> getGutFloraByReportId(Long reportId) {
  448. return healthGutFloraMapper.selectList(
  449. new LambdaQueryWrapper<HealthGutFlora>()
  450. .eq(HealthGutFlora::getReportId, reportId)
  451. .orderByAsc(HealthGutFlora::getSortOrder)
  452. );
  453. }
  454. /**
  455. * 保存菌群数据
  456. */
  457. @Transactional
  458. public void saveGutFlora(Long reportId, List<HealthGutFlora> floraList) {
  459. if (floraList == null || floraList.isEmpty()) return;
  460. for (int i = 0; i < floraList.size(); i++) {
  461. HealthGutFlora item = floraList.get(i);
  462. item.setReportId(reportId);
  463. item.setSortOrder(i);
  464. item.setCreatedAt(new Date());
  465. healthGutFloraMapper.insert(item);
  466. }
  467. }
  468. /**
  469. * 获取报告的疾病风险评估
  470. */
  471. public List<HealthDiseaseRisk> getDiseaseRisksByReportId(Long reportId) {
  472. return healthDiseaseRiskMapper.selectList(
  473. new LambdaQueryWrapper<HealthDiseaseRisk>()
  474. .eq(HealthDiseaseRisk::getReportId, reportId)
  475. );
  476. }
  477. /**
  478. * 保存疾病风险评估
  479. */
  480. @Transactional
  481. public void saveDiseaseRisks(Long reportId, List<HealthDiseaseRisk> riskList) {
  482. if (riskList == null || riskList.isEmpty()) return;
  483. for (HealthDiseaseRisk item : riskList) {
  484. item.setReportId(reportId);
  485. item.setCreatedAt(new Date());
  486. healthDiseaseRiskMapper.insert(item);
  487. }
  488. }
  489. /**
  490. * 获取身体维度异常指标(身体页面"指标告警"专供)
  491. * 返回:疾病风险(非低风险) + 主要营养评估异常 + 氨基酸评估异常
  492. * 正常指标不显示,减少信息噪音
  493. */
  494. public Map<String, Object> getBodyAbnormalIndicators(Long memberId) {
  495. HealthReport latest = getLatestReport(memberId);
  496. Map<String, Object> result = new HashMap<>();
  497. result.put("diseaseRisks", Collections.emptyList());
  498. result.put("nutritionAbnormal", Collections.emptyList());
  499. result.put("aminoAcidAbnormal", Collections.emptyList());
  500. result.put("hasReport", latest != null);
  501. if (latest == null) return result;
  502. // 疾病风险:排除低风险
  503. List<HealthDiseaseRisk> allRisks = getDiseaseRisksByReportId(latest.getId());
  504. List<Map<String, Object>> abnormalRisks = new ArrayList<>();
  505. for (HealthDiseaseRisk r : allRisks) {
  506. if (r.getRiskLevel() != null && !"低风险".equals(r.getRiskLevel())) {
  507. Map<String, Object> item = new HashMap<>();
  508. item.put("name", r.getDiseaseName());
  509. item.put("value", r.getRiskValue());
  510. item.put("level", r.getRiskLevel());
  511. abnormalRisks.add(item);
  512. }
  513. }
  514. result.put("diseaseRisks", abnormalRisks);
  515. // 主要营养评估 + 氨基酸评估:只保留异常状态
  516. List<HealthIndicator> allIndicators = getReportIndicators(latest.getId());
  517. List<Map<String, Object>> nutritionAbnormal = new ArrayList<>();
  518. List<Map<String, Object>> aminoAcidAbnormal = new ArrayList<>();
  519. for (HealthIndicator ind : allIndicators) {
  520. String category = ind.getCategory();
  521. String status = ind.getStatus();
  522. if (status == null || "正常".equals(status) || "normal".equalsIgnoreCase(status)
  523. || "低风险".equals(status)) continue;
  524. Map<String, Object> item = new HashMap<>();
  525. item.put("name", ind.getIndicatorName());
  526. item.put("value", ind.getIndicatorValue());
  527. item.put("unit", ind.getUnit());
  528. item.put("status", status);
  529. item.put("refRange", ind.getRefRange());
  530. if ("主要营养评估".equals(category)) {
  531. nutritionAbnormal.add(item);
  532. } else if ("氨基酸评估".equals(category)) {
  533. aminoAcidAbnormal.add(item);
  534. }
  535. }
  536. result.put("nutritionAbnormal", nutritionAbnormal);
  537. result.put("aminoAcidAbnormal", aminoAcidAbnormal);
  538. return result;
  539. }
  540. /**
  541. * 获取报告完整详情(含指标明细 + 菌群数据 + 疾病风险评估)
  542. */
  543. public Map<String, Object> getReportDetail(Long reportId) {
  544. Map<String, Object> detail = new HashMap<>();
  545. HealthReport report = healthReportMapper.selectById(reportId);
  546. detail.put("report", report);
  547. if (report != null) {
  548. List<HealthIndicator> indicators = getReportIndicators(reportId);
  549. detail.put("indicators", indicators);
  550. List<HealthGutFlora> gutFlora = getGutFloraByReportId(reportId);
  551. // 读回归一化:兼容历史数据(归一化前入库的 category='菌X构成' + level='门/纲/...')
  552. normalizeGutFloraRead(gutFlora);
  553. detail.put("gutFlora", gutFlora);
  554. List<HealthDiseaseRisk> diseaseRisks = getDiseaseRisksByReportId(reportId);
  555. detail.put("diseaseRisks", diseaseRisks);
  556. // 通用展示块(blocks 为空时前端回退旧字段)
  557. detail.put("blocks", reportBlockService.getBlocks(report.getReportType(), reportId));
  558. // 报告解读(前端 gut-flora-detail 读取 reportInterpretation.gutAgeAssessment)
  559. Map<String, Object> interp = new HashMap<>();
  560. StringBuilder sb = new StringBuilder();
  561. if (report.getGutAge() != null && !report.getGutAge().isEmpty()) {
  562. sb.append("您的肠道年龄为 ").append(report.getGutAge()).append(" 岁");
  563. }
  564. if (report.getGutType() != null && !report.getGutType().isEmpty()) {
  565. if (sb.length() > 0) sb.append(",");
  566. sb.append("肠道类型为 ").append(report.getGutType());
  567. }
  568. if (sb.length() > 0) {
  569. interp.put("gutAgeAssessment", sb.toString());
  570. detail.put("reportInterpretation", interp);
  571. }
  572. // 编辑回填: payload_json 快照 → 嵌套 payload 结构
  573. if (report.getPayloadJson() != null && !report.getPayloadJson().isEmpty()) {
  574. try {
  575. detail.put("payload", objectMapper.readValue(report.getPayloadJson(), ParsedReportPayload.Payload.class));
  576. } catch (Exception e) {
  577. log.warn("解析payload_json失败 reportId={}: {}", reportId, e.getMessage());
  578. }
  579. }
  580. }
  581. return detail;
  582. }
  583. /**
  584. * 获取报告 payload(编辑回填用,按需加载)
  585. */
  586. public Map<String, Object> getReportPayload(Long reportId) {
  587. Map<String, Object> result = new HashMap<>();
  588. HealthReport report = healthReportMapper.selectById(reportId);
  589. if (report == null) {
  590. return result;
  591. }
  592. if (report.getPayloadJson() != null && !report.getPayloadJson().isEmpty()) {
  593. try {
  594. result.put("payload", objectMapper.readValue(report.getPayloadJson(), ParsedReportPayload.Payload.class));
  595. } catch (Exception e) {
  596. log.warn("解析payload_json失败 reportId={}: {}", reportId, e.getMessage());
  597. }
  598. }
  599. return result;
  600. }
  601. private int clampScore(Integer score) {
  602. if (score == null) return 0;
  603. return Math.min(Math.max(score, 0), 100);
  604. }
  605. /**
  606. * 根据姓名模糊匹配家庭成员(优先匹配 FamilyMember,其次匹配 User)
  607. *
  608. * @param name 从PDF提取的姓名
  609. * @param familyId 家庭ID
  610. * @return 匹配到的成员ID(FamilyMember.id 或 User.id),未匹配返回 null
  611. */
  612. public Long matchFamilyMemberByName(String name, Long familyId) {
  613. if (name == null || name.trim().isEmpty() || familyId == null) {
  614. return null;
  615. }
  616. String cleanName = name.trim().replaceAll("\\s+", "");
  617. if (cleanName.isEmpty()) {
  618. return null;
  619. }
  620. // 第1轮:匹配 FamilyMember 表(孩子昵称)
  621. Long childId = matchFamilyMemberByNameInternal(cleanName, familyId);
  622. if (childId != null) {
  623. return childId;
  624. }
  625. // 第2轮:匹配 User 表(父母的 realName / nickname)
  626. Long userId = matchUserByName(cleanName, familyId);
  627. if (userId != null) {
  628. return userId;
  629. }
  630. log.warn("无法匹配姓名[{}]到家庭{}的成员", name, familyId);
  631. return null;
  632. }
  633. /**
  634. * 在 FamilyMember 表中匹配姓名
  635. */
  636. private Long matchFamilyMemberByNameInternal(String cleanName, Long familyId) {
  637. List<FamilyMember> children = familyMemberMapper.selectList(
  638. new LambdaQueryWrapper<FamilyMember>()
  639. .eq(FamilyMember::getFamilyId, familyId)
  640. );
  641. if (children == null || children.isEmpty()) {
  642. return null;
  643. }
  644. // 精确匹配
  645. for (FamilyMember child : children) {
  646. if (child.getNickname() != null) {
  647. String nn = child.getNickname().trim().replaceAll("\\s+", "");
  648. if (nn.equals(cleanName)) {
  649. log.info("FamilyMember 姓名精确匹配: {} -> childId={}", cleanName, child.getId());
  650. return child.getId();
  651. }
  652. }
  653. }
  654. // 模糊包含匹配
  655. for (FamilyMember child : children) {
  656. if (child.getNickname() != null) {
  657. String nn = child.getNickname();
  658. if (cleanName.contains(nn) || nn.contains(cleanName)) {
  659. log.info("FamilyMember 姓名模糊匹配: {} -> childId={}", cleanName, child.getId());
  660. return child.getId();
  661. }
  662. }
  663. }
  664. // fuzzyMatch 匹配
  665. for (FamilyMember child : children) {
  666. if (child.getNickname() != null && fuzzyMatch(cleanName, child.getNickname())) {
  667. log.info("FamilyMember 姓名fuzzy匹配: {} -> childId={}", cleanName, child.getId());
  668. return child.getId();
  669. }
  670. }
  671. return null;
  672. }
  673. /**
  674. * 在 User 表中匹配姓名(realName / nickname)
  675. */
  676. private Long matchUserByName(String cleanName, Long familyId) {
  677. List<User> users = userMapper.selectList(
  678. new LambdaQueryWrapper<User>()
  679. .eq(User::getFamilyId, familyId)
  680. );
  681. if (users == null || users.isEmpty()) {
  682. return null;
  683. }
  684. for (User user : users) {
  685. // 优先匹配 realName,其次 nickname
  686. String nameToMatch = user.getRealName() != null ? user.getRealName() : user.getNickname();
  687. if (nameToMatch == null) continue;
  688. String nm = nameToMatch.trim().replaceAll("\\s+", "");
  689. if (nm.equals(cleanName)) {
  690. log.info("User 姓名精确匹配: {} -> userId={}", cleanName, user.getId());
  691. return resolveSubjectToMemberId(user.getId(), familyId);
  692. }
  693. }
  694. for (User user : users) {
  695. String nameToMatch = user.getRealName() != null ? user.getRealName() : user.getNickname();
  696. if (nameToMatch == null) continue;
  697. if (cleanName.contains(nameToMatch) || nameToMatch.contains(cleanName)) {
  698. log.info("User 姓名模糊匹配: {} -> userId={}", cleanName, user.getId());
  699. return resolveSubjectToMemberId(user.getId(), familyId);
  700. }
  701. }
  702. for (User user : users) {
  703. String nameToMatch = user.getRealName() != null ? user.getRealName() : user.getNickname();
  704. if (nameToMatch == null) continue;
  705. if (fuzzyMatch(cleanName, nameToMatch)) {
  706. log.info("User 姓名fuzzy匹配: {} -> userId={}", cleanName, user.getId());
  707. return resolveSubjectToMemberId(user.getId(), familyId);
  708. }
  709. }
  710. return null;
  711. }
  712. /**
  713. * 模糊匹配:检查 str1 是否大致包含 str2(用于处理生僻字等情况)
  714. */
  715. private boolean fuzzyMatch(String str1, String str2) {
  716. if (str1 == null || str2 == null) return false;
  717. if (str1.contains(str2) || str2.contains(str1)) return true;
  718. // 检查 str1 的前2个字符是否在 str2 中(处理"王小明"匹配"小明"的情况)
  719. if (str1.length() >= 2) {
  720. String prefix = str1.substring(0, 2);
  721. if (str2.contains(prefix)) return true;
  722. }
  723. // 检查 str2 的前2个字符是否在 str1 中
  724. if (str2.length() >= 2) {
  725. String prefix = str2.substring(0, 2);
  726. if (str1.contains(prefix)) return true;
  727. }
  728. return false;
  729. }
  730. public Long matchFamilyMemberIdByName(String name, Long familyId) {
  731. if (name == null || familyId == null) return null;
  732. List<FamilyMember> children = familyMemberMapper.selectList(
  733. new LambdaQueryWrapper<FamilyMember>()
  734. .eq(FamilyMember::getFamilyId, familyId)
  735. );
  736. for (FamilyMember c : children) {
  737. if (name.contains(c.getNickname()) || (c.getNickname() != null && c.getNickname().contains(name))) {
  738. return c.getId();
  739. }
  740. }
  741. return null;
  742. }
  743. /**
  744. * 多字段匹配家庭成员 — 基于姓名+性别+年龄综合评分
  745. * 返回置信度最高的匹配结果及候选列表
  746. */
  747. public MemberMatchResult matchMemberByProfile(String name, String gender, Integer age, Long familyId) {
  748. MemberMatchResult result = new MemberMatchResult();
  749. result.setCandidates(new ArrayList<>());
  750. if (familyId == null) {
  751. result.setMatchedMemberId(null);
  752. result.setConfidence(0.0);
  753. result.setMatchLabel("no_match");
  754. return result;
  755. }
  756. // 收集所有候选成员(FamilyMember + User)
  757. List<FamilyMember> familyMembers = familyMemberMapper.selectList(
  758. new LambdaQueryWrapper<FamilyMember>()
  759. .eq(FamilyMember::getFamilyId, familyId)
  760. );
  761. List<User> users = userMapper.selectList(
  762. new LambdaQueryWrapper<User>()
  763. .eq(User::getFamilyId, familyId)
  764. );
  765. List<MemberMatchResult.MemberCandidate> allCandidates = new ArrayList<>();
  766. // 评估 FamilyMember
  767. if (familyMembers != null) {
  768. for (FamilyMember fm : familyMembers) {
  769. MemberMatchResult.MemberCandidate candidate = new MemberMatchResult.MemberCandidate();
  770. candidate.setId(fm.getId());
  771. candidate.setNickname(fm.getNickname());
  772. candidate.setName(fm.getNickname());
  773. candidate.setGender(fm.getGender());
  774. candidate.setAge(fm.getAge());
  775. double score = 0.0;
  776. List<String> matchedFields = new ArrayList<>();
  777. if (name != null && !name.trim().isEmpty()) {
  778. double nameScore = computeNameSimilarity(name.trim(), fm.getNickname());
  779. score += nameScore * 0.6;
  780. if (nameScore > 0.5) matchedFields.add("name");
  781. }
  782. if (gender != null && !gender.trim().isEmpty()) {
  783. String dbGender = fm.getGender();
  784. if ("male".equalsIgnoreCase(gender) && "male".equals(dbGender)) {
  785. score += 0.2;
  786. matchedFields.add("gender");
  787. } else if ("female".equalsIgnoreCase(gender) && "female".equals(dbGender)) {
  788. score += 0.2;
  789. matchedFields.add("gender");
  790. }
  791. }
  792. if (age != null && age > 0) {
  793. int dbAge = fm.getAge();
  794. if (dbAge > 0) {
  795. int diff = Math.abs(age - dbAge);
  796. if (diff <= 1) {
  797. score += 0.2;
  798. matchedFields.add("age");
  799. } else if (diff <= 3) {
  800. score += 0.1;
  801. matchedFields.add("age");
  802. } else if (diff <= 5) {
  803. score += 0.05;
  804. matchedFields.add("age");
  805. }
  806. }
  807. }
  808. score = Math.min(score, 1.0);
  809. candidate.setSimilarityScore(Math.round(score * 1000.0) / 1000.0);
  810. candidate.setMatchFields(String.join(",", matchedFields));
  811. if (score > 0) {
  812. allCandidates.add(candidate);
  813. }
  814. }
  815. }
  816. // 评估 User(父母)
  817. if (users != null) {
  818. for (User u : users) {
  819. String displayName = u.getRealName() != null ? u.getRealName() : u.getNickname();
  820. if (displayName == null || displayName.trim().isEmpty()) continue;
  821. MemberMatchResult.MemberCandidate candidate = new MemberMatchResult.MemberCandidate();
  822. candidate.setId(u.getId());
  823. candidate.setName(displayName);
  824. candidate.setGender(u.getGender());
  825. candidate.setNickname(u.getNickname());
  826. // 从生日计算年龄
  827. Integer userAge = null;
  828. if (u.getBirthday() != null) {
  829. java.time.LocalDate birth = u.getBirthday().toInstant()
  830. .atZone(java.time.ZoneId.systemDefault()).toLocalDate();
  831. userAge = java.time.Period.between(birth, java.time.LocalDate.now()).getYears();
  832. }
  833. candidate.setAge(userAge);
  834. double score = 0.0;
  835. List<String> matchedFields = new ArrayList<>();
  836. if (name != null && !name.trim().isEmpty()) {
  837. double nameScore = computeNameSimilarity(name.trim(), displayName);
  838. score += nameScore * 0.6;
  839. if (nameScore > 0.5) matchedFields.add("name");
  840. }
  841. if (gender != null && !gender.trim().isEmpty()) {
  842. String dbGender = u.getGender();
  843. if ("male".equalsIgnoreCase(gender) && "male".equals(dbGender)) {
  844. score += 0.2;
  845. matchedFields.add("gender");
  846. } else if ("female".equalsIgnoreCase(gender) && "female".equals(dbGender)) {
  847. score += 0.2;
  848. matchedFields.add("gender");
  849. }
  850. }
  851. if (age != null && age > 0 && userAge != null && userAge > 0) {
  852. int diff = Math.abs(age - userAge);
  853. if (diff <= 2) {
  854. score += 0.2;
  855. matchedFields.add("age");
  856. } else if (diff <= 5) {
  857. score += 0.1;
  858. matchedFields.add("age");
  859. }
  860. }
  861. score = Math.min(score, 1.0);
  862. candidate.setSimilarityScore(Math.round(score * 1000.0) / 1000.0);
  863. candidate.setMatchFields(String.join(",", matchedFields));
  864. if (score > 0) {
  865. allCandidates.add(candidate);
  866. }
  867. }
  868. }
  869. // 按相似度降序排序
  870. allCandidates.sort(Comparator.comparing(MemberMatchResult.MemberCandidate::getSimilarityScore).reversed());
  871. // 取前5个候选
  872. int limit = Math.min(5, allCandidates.size());
  873. result.setCandidates(allCandidates.subList(0, limit));
  874. if (limit > 0 && allCandidates.get(0).getSimilarityScore() >= 0.6) {
  875. result.setMatchedMemberId(allCandidates.get(0).getId());
  876. result.setConfidence(allCandidates.get(0).getSimilarityScore());
  877. result.setMatchLabel(allCandidates.get(0).getSimilarityScore() >= 0.9 ? "exact" : "profile_match");
  878. } else {
  879. result.setMatchedMemberId(null);
  880. result.setConfidence(limit > 0 ? allCandidates.get(0).getSimilarityScore() : 0.0);
  881. result.setMatchLabel("no_match");
  882. }
  883. return result;
  884. }
  885. /**
  886. * 计算两个姓名的相似度(0.0-1.0)
  887. */
  888. private double computeNameSimilarity(String reportName, String dbName) {
  889. if (reportName == null || dbName == null) return 0.0;
  890. String a = reportName.trim().toLowerCase();
  891. String b = dbName.trim().toLowerCase();
  892. // 精确匹配
  893. if (a.equals(b)) return 1.0;
  894. // 互相包含
  895. if (a.contains(b) || b.contains(a)) return 0.85;
  896. // 前2字匹配
  897. if (a.length() >= 2 && b.length() >= 2) {
  898. String aPrefix = a.substring(0, 2);
  899. String bPrefix = b.substring(0, 2);
  900. if (aPrefix.equals(bPrefix)) return 0.7;
  901. }
  902. // 最后一个字相同
  903. if (a.length() > 0 && b.length() > 0) {
  904. if (String.valueOf(a.charAt(a.length() - 1)).equals(String.valueOf(b.charAt(b.length() - 1)))) {
  905. return 0.4;
  906. }
  907. }
  908. return 0.0;
  909. }
  910. /**
  911. * Get mind-related indicators from the latest gut microbiome report.
  912. * Filters indicators by categories related to mood/emotion:
  913. * - 神经递质与激素 (neurotransmitters & hormones)
  914. * - 短链脂肪酸 (short-chain fatty acids, gut-brain axis)
  915. *
  916. * @param childId the child's ID
  917. * @return list of mood-related HealthIndicator
  918. */
  919. public List<HealthIndicator> getMindRelatedIndicators(Long childId) {
  920. // Get latest gut flora report for this child
  921. LambdaQueryWrapper<HealthReport> reportWrapper = new LambdaQueryWrapper<>();
  922. reportWrapper.eq(HealthReport::getUserId, childId);
  923. reportWrapper.eq(HealthReport::getReportType, "gut_flora");
  924. reportWrapper.orderByDesc(HealthReport::getCreatedAt);
  925. reportWrapper.last("LIMIT 1");
  926. SortUtil.applySort(reportWrapper);
  927. HealthReport report = healthReportMapper.selectOne(reportWrapper);
  928. if (report == null) {
  929. return Collections.emptyList();
  930. }
  931. // Get indicators matching mood-related categories
  932. LambdaQueryWrapper<HealthIndicator> indicatorWrapper = new LambdaQueryWrapper<>();
  933. indicatorWrapper.eq(HealthIndicator::getReportId, report.getId());
  934. indicatorWrapper.in(HealthIndicator::getCategory, "神经递质与激素", "短链脂肪酸");
  935. indicatorWrapper.orderByAsc(HealthIndicator::getSortOrder);
  936. SortUtil.applySort(indicatorWrapper);
  937. return healthIndicatorMapper.selectList(indicatorWrapper);
  938. }
  939. /**
  940. * 情绪相关指标关键词(知识库"神经递质及激素"分类中与情绪状态直接相关的指标)
  941. * 体现"心生身"概念:情绪状态通过神经递质反映在身体上
  942. */
  943. private static final Set<String> EMOTION_INDICATOR_KEYWORDS = new HashSet<>(Arrays.asList(
  944. "血清素", "5-HT", "多巴胺", "GABA", "γ-氨基丁酸", "色氨酸", "褪黑素", "皮质醇"
  945. ));
  946. /**
  947. * 认知相关指标关键词(与学习记忆、智力、注意力相关的肠脑轴指标)
  948. * 含谷氨酸(学习记忆)、多巴胺(专注力,用户确认)、色氨酸(褪黑素前体→睡眠→记忆力)
  949. */
  950. private static final Set<String> COGNITION_INDICATOR_KEYWORDS = new HashSet<>(Arrays.asList(
  951. "谷氨酸", "Glutamate", "乙酰胆碱", "多巴胺", "色氨酸", "丁酸", "丙酸", "乙酸", "组胺", "短链脂肪酸"
  952. ));
  953. /**
  954. * 根据关键词过滤指标列表
  955. */
  956. private List<HealthIndicator> filterIndicatorsByKeywords(List<HealthIndicator> indicators, Set<String> keywords) {
  957. if (indicators == null || indicators.isEmpty()) return Collections.emptyList();
  958. List<HealthIndicator> result = new ArrayList<>();
  959. for (HealthIndicator ind : indicators) {
  960. if (ind.getIndicatorName() == null) continue;
  961. String name = ind.getIndicatorName();
  962. for (String kw : keywords) {
  963. if (name.contains(kw)) {
  964. result.add(ind);
  965. break;
  966. }
  967. }
  968. }
  969. return result;
  970. }
  971. /**
  972. * 获取与情绪状态相关的菌群报告指标(心页面"情绪与肠胃")
  973. * 体现"心生身":从菌群报告提取与情绪调节直接相关的神经递质指标
  974. *
  975. * @param childId the child's ID
  976. * @return list of emotion-related HealthIndicator
  977. */
  978. public List<HealthIndicator> getEmotionIndicators(Long childId) {
  979. List<HealthIndicator> all = getMindRelatedIndicators(childId);
  980. return filterIndicatorsByKeywords(all, EMOTION_INDICATOR_KEYWORDS);
  981. }
  982. /**
  983. * 获取与认知/智力相关的菌群报告指标(智页面"肠胃与认知")
  984. * 体现"肠脑轴":肠道菌群代谢产物影响认知功能
  985. *
  986. * @param childId the child's ID
  987. * @return list of cognition-related HealthIndicator
  988. */
  989. public List<HealthIndicator> getCognitionIndicators(Long childId) {
  990. // Get latest gut flora report for this child
  991. LambdaQueryWrapper<HealthReport> reportWrapper = new LambdaQueryWrapper<>();
  992. reportWrapper.eq(HealthReport::getUserId, childId);
  993. reportWrapper.eq(HealthReport::getReportType, "gut_flora");
  994. reportWrapper.orderByDesc(HealthReport::getCreatedAt);
  995. reportWrapper.last("LIMIT 1");
  996. SortUtil.applySort(reportWrapper);
  997. HealthReport report = healthReportMapper.selectOne(reportWrapper);
  998. if (report == null) {
  999. return Collections.emptyList();
  1000. }
  1001. LambdaQueryWrapper<HealthIndicator> indicatorWrapper = new LambdaQueryWrapper<>();
  1002. indicatorWrapper.eq(HealthIndicator::getReportId, report.getId());
  1003. indicatorWrapper.in(HealthIndicator::getCategory, "神经递质与激素", "短链脂肪酸");
  1004. indicatorWrapper.orderByAsc(HealthIndicator::getSortOrder);
  1005. SortUtil.applySort(indicatorWrapper);
  1006. List<HealthIndicator> all = healthIndicatorMapper.selectList(indicatorWrapper);
  1007. return filterIndicatorsByKeywords(all, COGNITION_INDICATOR_KEYWORDS);
  1008. }
  1009. /**
  1010. * Get gut-emotion insight summary based on neurotransmitter and SCFA indicators.
  1011. *
  1012. * @param childId the child's ID
  1013. * @return map with summary text and indicator highlights
  1014. */
  1015. public Map<String, Object> getGutEmotionInsight(Long childId) {
  1016. List<HealthIndicator> indicators = getEmotionIndicators(childId);
  1017. if (indicators.isEmpty()) {
  1018. Map<String, Object> empty = new HashMap<>();
  1019. empty.put("summary", "暂无菌群报告数据");
  1020. empty.put("indicators", Collections.emptyList());
  1021. return empty;
  1022. }
  1023. Map<String, Object> result = new HashMap<>();
  1024. result.put("indicators", indicators);
  1025. // Simple summary based on indicator statuses
  1026. long abnormalCount = indicators.stream()
  1027. .filter(i -> "abnormal".equals(i.getStatus()) || "偏高".equals(i.getStatus()) || "偏低".equals(i.getStatus()))
  1028. .count();
  1029. if (abnormalCount == 0) {
  1030. result.put("summary", "神经递质指标正常,肠道情绪状态良好");
  1031. } else if (abnormalCount <= 2) {
  1032. result.put("summary", "部分神经递质指标略有波动,可关注相关营养补充");
  1033. } else {
  1034. result.put("summary", "多项神经递质指标异常,建议咨询专业医生或营养师");
  1035. }
  1036. return result;
  1037. }
  1038. public com.baomidou.mybatisplus.extension.plugins.pagination.Page<Map<String, Object>> listAllReportsForAdmin(int page, int size) {
  1039. com.baomidou.mybatisplus.extension.plugins.pagination.Page<HealthReport> p =
  1040. new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page, size);
  1041. LambdaQueryWrapper<HealthReport> wrapper = new LambdaQueryWrapper<HealthReport>()
  1042. .eq(HealthReport::getStatus, "active")
  1043. .orderByDesc(HealthReport::getReportDate);
  1044. SortUtil.applySort(wrapper);
  1045. p = healthReportMapper.selectPage(p, wrapper);
  1046. List<Map<String, Object>> records = new ArrayList<>();
  1047. for (HealthReport r : p.getRecords()) {
  1048. Map<String, Object> m = new HashMap<>();
  1049. m.put("id", r.getId());
  1050. m.put("reportType", r.getReportType());
  1051. m.put("overallScore", r.getOverallScore());
  1052. m.put("gutHealthScore", r.getGutHealthScore());
  1053. m.put("nutritionScore", r.getNutritionScore());
  1054. m.put("reportDate", r.getReportDate());
  1055. m.put("personName", r.getPersonName());
  1056. m.put("fileUrl", r.getFileUrl());
  1057. m.put("gutAge", r.getGutAge());
  1058. m.put("gutType", r.getGutType());
  1059. Long subjectId = r.getSubjectId() != null ? r.getSubjectId() : r.getUserId();
  1060. m.put("memberId", subjectId);
  1061. if (subjectId != null) {
  1062. User user = userMapper.selectById(subjectId);
  1063. if (user != null) {
  1064. m.put("childName", user.getRealName() != null ? user.getRealName() : user.getNickname());
  1065. }
  1066. }
  1067. if (m.get("childName") == null) {
  1068. m.put("childName", r.getPersonName());
  1069. }
  1070. records.add(m);
  1071. }
  1072. com.baomidou.mybatisplus.extension.plugins.pagination.Page<Map<String, Object>> result =
  1073. new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page, size);
  1074. result.setRecords(records);
  1075. result.setTotal(p.getTotal());
  1076. result.setPages(p.getPages());
  1077. return result;
  1078. }
  1079. public HealthReport getReportById(Long reportId) {
  1080. return healthReportMapper.selectById(reportId);
  1081. }
  1082. /**
  1083. * 仅更新报告的 payload_json 列(用于食材重新解析回填,只覆盖 foods 键,不影响其他字段)。
  1084. * 使用 JdbcTemplate 直改单列,避免 MyBatis-Plus NOT_NULL 策略下其他 null 字段被误覆盖。
  1085. */
  1086. public void updatePayloadJson(Long reportId, String payloadJson) {
  1087. jdbcTemplate.update("UPDATE health_reports SET payload_json = ?, updated_at = NOW() WHERE id = ?",
  1088. payloadJson, reportId);
  1089. }
  1090. /**
  1091. * 归一化 subject_id 到 family_members.id 口径(A方案:报告统一挂 member_id)。
  1092. *
  1093. * <p>历史 subject_id 混用 users.id / family_members.id:确认/采集/编辑各链路都可能
  1094. * 把 users.id 直接写入。本方法把"users.id 或跨家庭成员 id"回查为指定家庭内的
  1095. * family_members.id,保证报告、维度分、食材推荐全部按成员卡统一挂接,
  1096. * 成员迁移(改 family_id 保留 member_id)后自动跟随。</p>
  1097. *
  1098. * @param subjectId 待归一化的被检测人 id(可能是 users.id 或 family_members.id)
  1099. * @param familyId 报告所属家庭
  1100. * @return 该家庭内的 family_members.id;无法解析时原样返回
  1101. */
  1102. public Long resolveSubjectToMemberId(Long subjectId, Long familyId) {
  1103. if (subjectId == null || familyId == null) {
  1104. return subjectId;
  1105. }
  1106. try {
  1107. // 已是本家庭成员 id -> 原样返回
  1108. FamilyMember direct = familyMemberMapper.selectById(subjectId);
  1109. if (direct != null && familyId.equals(direct.getFamilyId())) {
  1110. return subjectId;
  1111. }
  1112. // subjectId 是 users.id -> 回查该用户在本家庭的成员行
  1113. User u = userMapper.selectById(subjectId);
  1114. if (u != null) {
  1115. FamilyMember byUser = familyMemberMapper.selectOne(
  1116. new LambdaQueryWrapper<FamilyMember>()
  1117. .eq(FamilyMember::getFamilyId, familyId)
  1118. .eq(FamilyMember::getUserId, subjectId)
  1119. .last("LIMIT 1"));
  1120. if (byUser != null) {
  1121. return byUser.getId();
  1122. }
  1123. }
  1124. // subjectId 是跨家庭 member 行 -> 按该成员的 user_id 回查本家庭
  1125. if (direct != null && direct.getUserId() != null) {
  1126. FamilyMember same = familyMemberMapper.selectOne(
  1127. new LambdaQueryWrapper<FamilyMember>()
  1128. .eq(FamilyMember::getFamilyId, familyId)
  1129. .eq(FamilyMember::getUserId, direct.getUserId())
  1130. .last("LIMIT 1"));
  1131. if (same != null) {
  1132. return same.getId();
  1133. }
  1134. }
  1135. } catch (Exception e) {
  1136. log.warn("subjectId归一化失败 subjectId={}, familyId={}: {}", subjectId, familyId, e.getMessage());
  1137. }
  1138. return subjectId;
  1139. }
  1140. public void bindReportToMember(Long reportId, Long subjectId) {
  1141. // 使用 JdbcTemplate 直接 UPDATE,避免 MyBatis-Plus NOT_NULL 策略导致 NULL 值不更新
  1142. String sql = "UPDATE health_reports SET subject_id = ? WHERE id = ?";
  1143. Long previousSubjectId = null;
  1144. // A方案:写库前归一化为 family_members.id 口径
  1145. if (subjectId != null && subjectId > 0) {
  1146. HealthReport rep = healthReportMapper.selectById(reportId);
  1147. if (rep != null) {
  1148. previousSubjectId = rep.getSubjectId();
  1149. Long resolved = resolveSubjectToMemberId(subjectId, rep.getFamilyId());
  1150. if (resolved != null && !resolved.equals(subjectId)) {
  1151. log.info("subjectId归一化 reportId={}, {} -> {}", reportId, subjectId, resolved);
  1152. }
  1153. subjectId = resolved;
  1154. }
  1155. }
  1156. int updatedRows = jdbcTemplate.update(sql, subjectId, reportId);
  1157. if (updatedRows == 0) {
  1158. log.warn("reportId={}, subjectId={} 更新失败 - 报告不存在", reportId, subjectId);
  1159. return;
  1160. }
  1161. log.info("已绑定报告 {} 到被检测人 {}", reportId, subjectId);
  1162. // G3(DS-2026-10-25-01):上传时未选成员 → NormalizeIndicatorPipeline 因 subjectId==null 静默跳过,
  1163. // 仅 UPDATE subject_id 不会补写时序,用户会看到「绑了成员却没有历史」。
  1164. // 只在「此前无归属」时补跑(即 G3 缺口),失败不阻断绑定;processReport 底层 upsert 幂等。
  1165. if (previousSubjectId == null && subjectId != null) {
  1166. try {
  1167. HealthReport rep = healthReportMapper.selectById(reportId);
  1168. if (rep != null && rep.getSubjectId() != null && rep.getPayloadJson() != null) {
  1169. ParsedReportPayload.Payload payload =
  1170. objectMapper.readValue(rep.getPayloadJson(), ParsedReportPayload.Payload.class);
  1171. normalizeIndicatorPipeline.processReport(rep, payload);
  1172. log.info("绑定成员后补写时序 reportId={}, subjectId={}", reportId, subjectId);
  1173. }
  1174. } catch (Exception e) {
  1175. log.warn("绑定成员后补写时序失败 reportId={}: {}", reportId, e.getMessage());
  1176. }
  1177. }
  1178. }
  1179. /**
  1180. * 重新分配报告归属
  1181. *
  1182. * <p>将报告从旧归属切换到新归属,或解除归属(subjectId 设为 null)。
  1183. * 归属变更后自动重新计算五维能量维度和食材推荐指数。</p>
  1184. *
  1185. * <p>流程:</p>
  1186. * <ol>
  1187. * <li>权限检查:确保报告属于当前家庭</li>
  1188. * <li>记录旧归属 subjectId</li>
  1189. * <li>更新 health_reports.subject_id</li>
  1190. * <li>若旧归属不为空:refreshFromGutReport + recalculateForUser</li>
  1191. * <li>若新归属不为空:refreshFromGutReport + recalculateForUser</li>
  1192. * <li>记录操作日志</li>
  1193. * </ol>
  1194. *
  1195. * @param reportId 报告ID
  1196. * @param newSubjectId 新的归属成员ID(null 表示解除绑定)
  1197. * @param familyId 家庭ID,用于权限验证
  1198. * @throws RuntimeException 当报告不存在或不属于该家庭时抛出
  1199. */
  1200. @Transactional
  1201. public void reassignReport(Long reportId, Long newSubjectId, Long familyId) {
  1202. HealthReport report = healthReportMapper.selectById(reportId);
  1203. if (report == null) {
  1204. throw new RuntimeException("报告不存在");
  1205. }
  1206. // 权限检查:确保报告属于该家庭(familyId 为空视为未加入家庭,防 NPE)
  1207. if (familyId == null) {
  1208. throw new RuntimeException("请先加入家庭");
  1209. }
  1210. if (!familyId.equals(report.getFamilyId())) {
  1211. throw new RuntimeException("报告不属于该家庭");
  1212. }
  1213. Long oldSubjectId = report.getSubjectId();
  1214. // 无实际变化则直接返回
  1215. if (oldSubjectId != null && newSubjectId != null && oldSubjectId.equals(newSubjectId)) {
  1216. return;
  1217. }
  1218. // 1. 更新 subject_id
  1219. String updateSql = "UPDATE health_reports SET subject_id = ? WHERE id = ?";
  1220. jdbcTemplate.update(updateSql, newSubjectId, reportId);
  1221. log.info("报告 {} 归属已更新:oldSubjectId={}, newSubjectId={}", reportId, oldSubjectId, newSubjectId);
  1222. // 2. 重算旧成员维度(如果原来有绑定)
  1223. if (oldSubjectId != null) {
  1224. dimensionScoreService.rebuildFromMemberReports(oldSubjectId);
  1225. foodRecommendService.recalculateForUser(oldSubjectId);
  1226. log.info("已重算旧归属成员 {} 的五维能量与食材推荐", oldSubjectId);
  1227. }
  1228. // 3. 重算新成员维度(如果有新归属)
  1229. if (newSubjectId != null) {
  1230. dimensionScoreService.refreshFromGutReport(newSubjectId, reportId);
  1231. foodRecommendService.recalculateForUser(newSubjectId);
  1232. log.info("已重算新归属成员 {} 的五维能量与食材推荐", newSubjectId);
  1233. }
  1234. log.info("报告归属变更: reportId={}, oldSubjectId={}, newSubjectId={}",
  1235. reportId, oldSubjectId, newSubjectId);
  1236. }
  1237. /**
  1238. * 获取家庭内所有未绑定归属的报告
  1239. *
  1240. * @param familyId 家庭ID
  1241. * @return 未绑定归属的报告列表
  1242. */
  1243. /**
  1244. * 获取严格绑定到该成员的有效报告(不含 subject_id 为空的未绑定报告)。
  1245. * 用于重建 REPORT 来源维度分——与 getMemberReports 语义不同,
  1246. * 后者会带出 user_id 匹配的未绑定报告,不适合参与维度分派生。
  1247. */
  1248. public List<HealthReport> getMemberBoundReports(Long memberId) {
  1249. if (memberId == null) {
  1250. return Collections.emptyList();
  1251. }
  1252. return healthReportMapper.selectList(
  1253. new LambdaQueryWrapper<HealthReport>()
  1254. .eq(HealthReport::getSubjectId, memberId)
  1255. .eq(HealthReport::getStatus, "active")
  1256. .orderByAsc(HealthReport::getReportDate)
  1257. .orderByAsc(HealthReport::getId)
  1258. );
  1259. }
  1260. public List<HealthReport> getUnlinkedReports(Long familyId) {
  1261. return healthReportMapper.selectList(
  1262. new LambdaQueryWrapper<HealthReport>()
  1263. .eq(HealthReport::getFamilyId, familyId)
  1264. .isNull(HealthReport::getSubjectId)
  1265. .eq(HealthReport::getStatus, "active")
  1266. .orderByDesc(HealthReport::getReportDate)
  1267. );
  1268. }
  1269. public void addReportPermission(Long reportId, Long userId) {
  1270. HealthReportPermission permission = new HealthReportPermission();
  1271. permission.setReportId(reportId);
  1272. permission.setUserId(userId);
  1273. try {
  1274. healthReportPermissionMapper.insert(permission);
  1275. } catch (DuplicateKeyException e) {
  1276. // already exists, ignore
  1277. }
  1278. }
  1279. public void removeReportPermission(Long reportId, Long userId) {
  1280. healthReportPermissionMapper.delete(
  1281. new LambdaQueryWrapper<HealthReportPermission>()
  1282. .eq(HealthReportPermission::getReportId, reportId)
  1283. .eq(HealthReportPermission::getUserId, userId)
  1284. );
  1285. }
  1286. public boolean hasReportPermission(Long reportId, Long userId) {
  1287. return healthReportPermissionMapper.selectCount(
  1288. new LambdaQueryWrapper<HealthReportPermission>()
  1289. .eq(HealthReportPermission::getReportId, reportId)
  1290. .eq(HealthReportPermission::getUserId, userId)
  1291. ) > 0;
  1292. }
  1293. // ======================== 自动创建成员 + 审核 ========================
  1294. /**
  1295. * 从PDF身份信息自动创建家庭成员
  1296. * 如果姓名已存在则返回已有成员ID;否则创建新成员并返回新ID
  1297. */
  1298. @Transactional(rollbackFor = Exception.class)
  1299. public Long autoCreateMemberFromReport(String name, String gender, Integer age,
  1300. Long familyId, String reportType,
  1301. String fileUrl, String originalFilename,
  1302. String payloadJson) {
  1303. if (name == null || name.trim().isEmpty()) {
  1304. throw new IllegalArgumentException("姓名不能为空");
  1305. }
  1306. if (familyId == null) {
  1307. throw new IllegalArgumentException("familyId不能为空");
  1308. }
  1309. String cleanName = name.trim().replaceAll("\\s+", "");
  1310. // 第1步:尝试匹配已有成员
  1311. Long existingMemberId = matchFamilyMemberByName(cleanName, familyId);
  1312. if (existingMemberId != null) {
  1313. log.info("姓名[{}]已匹配到现有成员 id={}", cleanName, existingMemberId);
  1314. return existingMemberId;
  1315. }
  1316. // 第2步:创建新家庭成员
  1317. FamilyMember newMember = new FamilyMember();
  1318. newMember.setFamilyId(familyId);
  1319. newMember.setNickname(cleanName);
  1320. newMember.setGender(gender);
  1321. newMember.setShowToFamily(1);
  1322. newMember.setTotalPoints(0);
  1323. Date now = new Date();
  1324. newMember.setCreatedAt(now);
  1325. newMember.setUpdatedAt(now);
  1326. // 如果有年龄,反推出生日期
  1327. if (age != null && age > 0) {
  1328. java.time.LocalDate birthDate = java.time.LocalDate.now()
  1329. .minusYears(age)
  1330. .minusDays(java.time.LocalDate.now().getDayOfYear() - 1);
  1331. newMember.setBirthday(java.sql.Date.valueOf(birthDate));
  1332. }
  1333. familyMemberMapper.insert(newMember);
  1334. log.info("已从健康报告自动创建新成员: id={}, name={}, gender={}, age={}, familyId={}",
  1335. newMember.getId(), cleanName, gender, age, familyId);
  1336. return newMember.getId();
  1337. }
  1338. /**
  1339. * 审核确认报告 — 将草稿转为正式报告并绑定到指定成员
  1340. */
  1341. @Transactional(rollbackFor = Exception.class)
  1342. public Map<String, Object> auditConfirm(Long draftId, Long subjectId, Long auditorId) {
  1343. HealthReportDraft draft = getActiveDraftOrThrow(draftId);
  1344. if (draft == null) {
  1345. throw new IllegalArgumentException("草稿不存在或已过期");
  1346. }
  1347. try {
  1348. ParsedReportPayload.Payload payload = objectMapper.readValue(
  1349. draft.getPayloadJson(), ParsedReportPayload.Payload.class);
  1350. Long matchedMemberId = subjectId != null ? subjectId
  1351. : matchFamilyMemberByName(
  1352. payload.getSummary() != null ? payload.getSummary().getPersonName() : null,
  1353. draft.getFamilyId());
  1354. HealthReport report = buildReportFromPayload(draft, payload, matchedMemberId);
  1355. List<HealthIndicator> indicators = buildIndicatorsFromPayload(payload);
  1356. List<HealthGutFlora> gutFlora = buildGutFloraFromPayload(payload);
  1357. List<HealthDiseaseRisk> diseaseRisks = buildDiseaseRisksFromPayload(payload);
  1358. HealthReport created = createReport(report, indicators, gutFlora, diseaseRisks);
  1359. if (matchedMemberId != null) {
  1360. bindReportToMember(created.getId(), matchedMemberId);
  1361. }
  1362. // 保存食材推荐数据
  1363. saveFoodSuitability(created.getId(), payload.getFoods());
  1364. markDraftConfirmed(draftId, created.getId());
  1365. Map<String, Object> result = new HashMap<>();
  1366. result.put("reportId", created.getId());
  1367. result.put("subjectId", matchedMemberId);
  1368. result.put("status", "confirmed");
  1369. return result;
  1370. } catch (Exception e) {
  1371. log.error("审核确认失败", e);
  1372. throw new RuntimeException("审核确认失败: " + e.getMessage());
  1373. }
  1374. }
  1375. /**
  1376. * 审核丢弃报告 — 删除草稿,不入库
  1377. */
  1378. @Transactional(rollbackFor = Exception.class)
  1379. public void auditDiscard(Long draftId) {
  1380. HealthReportDraft draft = getActiveDraftOrThrow(draftId);
  1381. if (draft == null) {
  1382. throw new IllegalArgumentException("草稿不存在或已过期");
  1383. }
  1384. markDraftDiscarded(draftId);
  1385. log.info("审核丢弃草稿: draftId={}", draftId);
  1386. }
  1387. /**
  1388. * 获取待审核的草稿列表(分页)
  1389. */
  1390. public List<HealthReportDraft> listPendingAudits(int page, int size) {
  1391. // MyBatis-Plus 分页查询
  1392. com.baomidou.mybatisplus.extension.plugins.pagination.Page<HealthReportDraft> p =
  1393. new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page, size);
  1394. LambdaQueryWrapper<HealthReportDraft> wrapper = new LambdaQueryWrapper<>();
  1395. wrapper.eq(HealthReportDraft::getStatus, "pending")
  1396. .orderByDesc(HealthReportDraft::getCreatedAt);
  1397. SortUtil.applySort(wrapper);
  1398. p = draftMapper.selectPage(p, wrapper);
  1399. return p.getRecords();
  1400. }
  1401. /**
  1402. * 获取草稿(内部方法)
  1403. */
  1404. private HealthReportDraft getActiveDraftOrThrow(Long draftId) {
  1405. HealthReportDraft draft = draftMapper.selectById(draftId);
  1406. if (draft == null || !"pending".equals(draft.getStatus())) {
  1407. return null;
  1408. }
  1409. if (draft.getExpireAt() != null && draft.getExpireAt().before(new Date())) {
  1410. return null;
  1411. }
  1412. return draft;
  1413. }
  1414. private void markDraftConfirmed(Long draftId, Long reportId) {
  1415. LambdaUpdateWrapper<HealthReportDraft> uw = new LambdaUpdateWrapper<>();
  1416. uw.eq(HealthReportDraft::getId, draftId)
  1417. .set(HealthReportDraft::getStatus, "confirmed")
  1418. .set(HealthReportDraft::getConfirmedReportId, reportId)
  1419. .set(HealthReportDraft::getUpdatedAt, new Date());
  1420. draftMapper.update(null, uw);
  1421. }
  1422. private void markDraftDiscarded(Long draftId) {
  1423. LambdaUpdateWrapper<HealthReportDraft> uw = new LambdaUpdateWrapper<>();
  1424. uw.eq(HealthReportDraft::getId, draftId)
  1425. .set(HealthReportDraft::getStatus, "discarded")
  1426. .set(HealthReportDraft::getUpdatedAt, new Date());
  1427. draftMapper.update(null, uw);
  1428. }
  1429. // ======================== Payload builders (shared with controller) ========================
  1430. HealthReport buildReportFromPayload(HealthReportDraft draft, ParsedReportPayload.Payload payload, Long subjectId) {
  1431. HealthReport report = new HealthReport();
  1432. report.setUserId(draft.getUserId());
  1433. report.setFamilyId(draft.getFamilyId());
  1434. report.setSubjectId(subjectId);
  1435. report.setReportType(draft.getReportType());
  1436. report.setFileUrl(draft.getFileUrl());
  1437. ParsedReportPayload.Summary s = payload.getSummary();
  1438. if (s != null) {
  1439. report.setOverallScore(s.getOverallScore());
  1440. report.setGutHealthScore(s.getGutHealthScore());
  1441. report.setChronicDiseaseScore(s.getChronicDiseaseScore());
  1442. report.setNutritionScore(s.getNutritionScore());
  1443. report.setBalanceScore(s.getBalanceScore());
  1444. report.setDiversityScore(s.getDiversityScore());
  1445. report.setBeneficialScore(s.getBeneficialScore());
  1446. report.setHarmfulScore(s.getHarmfulScore());
  1447. report.setCoreGenusScore(s.getCoreGenusScore());
  1448. report.setGutAge(s.getGutAge());
  1449. report.setGutType(s.getGutType());
  1450. report.setReportNumber(s.getReportNumber());
  1451. report.setPersonName(s.getPersonName());
  1452. if (s.getReportDate() != null && !s.getReportDate().isEmpty()) {
  1453. try {
  1454. java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd");
  1455. report.setReportDate(sdf.parse(s.getReportDate()));
  1456. } catch (Exception e) {
  1457. report.setReportDate(new Date());
  1458. }
  1459. } else {
  1460. report.setReportDate(new Date());
  1461. }
  1462. } else {
  1463. report.setReportDate(new Date());
  1464. }
  1465. report.setRemark("募极生物肠道菌群检测报告");
  1466. return report;
  1467. }
  1468. List<HealthIndicator> buildIndicatorsFromPayload(ParsedReportPayload.Payload payload) {
  1469. List<ParsedReportPayload.Indicator> list = payload.getIndicators();
  1470. if (list == null || list.isEmpty()) return new ArrayList<>();
  1471. List<HealthIndicator> indicators = new ArrayList<>();
  1472. for (int i = 0; i < list.size(); i++) {
  1473. ParsedReportPayload.Indicator pi = list.get(i);
  1474. HealthIndicator ind = new HealthIndicator();
  1475. ind.setCategory(pi.getCategory());
  1476. ind.setIndicatorName(pi.getIndicatorName());
  1477. ind.setIndicatorValue(pi.getIndicatorValue());
  1478. ind.setUnit(pi.getUnit());
  1479. ind.setRefRange(pi.getRefRange());
  1480. ind.setStatus(pi.getStatus());
  1481. ind.setSymptoms(pi.getSymptoms());
  1482. ind.setSortOrder(i);
  1483. if (pi.getCollectionDate() != null && !pi.getCollectionDate().isEmpty()) {
  1484. try {
  1485. java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd");
  1486. ind.setCollectionDate(sdf.parse(pi.getCollectionDate()));
  1487. } catch (Exception e) {
  1488. // ignore parse error
  1489. }
  1490. }
  1491. indicators.add(ind);
  1492. }
  1493. return indicators;
  1494. }
  1495. List<HealthGutFlora> buildGutFloraFromPayload(ParsedReportPayload.Payload payload) {
  1496. List<HealthGutFlora> result = new ArrayList<>();
  1497. // 核心菌属 + 有害菌属等
  1498. addFloraList(result, payload.getGutFlora());
  1499. // 益生菌种
  1500. addFloraList(result, payload.getProbioticSpecies());
  1501. // Taxonomy 层级
  1502. addFloraList(result, payload.getTaxonomyClass());
  1503. addFloraList(result, payload.getTaxonomyOrder());
  1504. addFloraList(result, payload.getTaxonomyFamily());
  1505. addFloraList(result, payload.getTaxonomyGenus());
  1506. addFloraList(result, payload.getTaxonomySpecies());
  1507. // 病原菌
  1508. addFloraList(result, payload.getPathogenGenus());
  1509. addFloraList(result, payload.getPathogenDetection());
  1510. return result;
  1511. }
  1512. private void addFloraList(List<HealthGutFlora> target, List<ParsedReportPayload.Flora> source) {
  1513. if (source != null) {
  1514. for (ParsedReportPayload.Flora pf : source) {
  1515. target.add(convertToGutFlora(pf));
  1516. }
  1517. }
  1518. }
  1519. private HealthGutFlora convertToGutFlora(ParsedReportPayload.Flora pf) {
  1520. HealthGutFlora gf = new HealthGutFlora();
  1521. gf.setBacteriaName(pf.getBacteriaName());
  1522. gf.setBacteriaValue(pf.getBacteriaValue());
  1523. gf.setNormalRange(pf.getNormalRange());
  1524. gf.setPopulationLevel(pf.getPopulationLevel());
  1525. gf.setDetectionRate(pf.getDetectionRate());
  1526. gf.setDescription(pf.getDescription());
  1527. gf.setCategory(normalizeFloraCategory(pf.getCategory()));
  1528. gf.setLevel(normalizeFloraLevel(pf.getCategory(), pf.getLevel()));
  1529. return gf;
  1530. }
  1531. private String normalizeFloraCategory(String category) {
  1532. if (category == null) return null;
  1533. if (category.endsWith("构成")) return HealthGutFlora.CATEGORY_TAXONOMY;
  1534. return category;
  1535. }
  1536. private String normalizeFloraLevel(String category, String level) {
  1537. if (level == null) return null;
  1538. String normalized = level;
  1539. if ("门".equals(level)) normalized = HealthGutFlora.LEVEL_PHYLUM;
  1540. else if ("纲".equals(level)) normalized = HealthGutFlora.LEVEL_CLASS;
  1541. else if ("目".equals(level)) normalized = HealthGutFlora.LEVEL_ORDER;
  1542. else if ("科".equals(level)) normalized = HealthGutFlora.LEVEL_FAMILY;
  1543. else if ("属".equals(level)) normalized = HealthGutFlora.LEVEL_GENUS;
  1544. else if ("种".equals(level)) normalized = HealthGutFlora.LEVEL_SPECIES;
  1545. return normalized;
  1546. }
  1547. /**
  1548. * 读回归一化:兼容历史数据(归一化前入库的 category='菌X构成' + level='门/纲/...')。
  1549. * 仅转换,不落库(避免读操作触发写)。
  1550. */
  1551. private void normalizeGutFloraRead(List<HealthGutFlora> gutFlora) {
  1552. if (gutFlora == null) return;
  1553. for (HealthGutFlora gf : gutFlora) {
  1554. String rawCategory = gf.getCategory();
  1555. gf.setCategory(normalizeFloraCategory(rawCategory));
  1556. gf.setLevel(normalizeFloraLevel(rawCategory, gf.getLevel()));
  1557. }
  1558. }
  1559. List<HealthDiseaseRisk> buildDiseaseRisksFromPayload(ParsedReportPayload.Payload payload) {
  1560. List<ParsedReportPayload.DiseaseRisk> list = payload.getDiseaseRisks();
  1561. if (list == null || list.isEmpty()) return new ArrayList<>();
  1562. List<HealthDiseaseRisk> risks = new ArrayList<>();
  1563. for (ParsedReportPayload.DiseaseRisk pr : list) {
  1564. HealthDiseaseRisk dr = new HealthDiseaseRisk();
  1565. dr.setDiseaseName(pr.getDiseaseName());
  1566. dr.setRiskValue(pr.getRiskValue());
  1567. dr.setRiskLevel(pr.getRiskLevel());
  1568. risks.add(dr);
  1569. }
  1570. return risks;
  1571. }
  1572. /**
  1573. * 从 Payload 构建食材推荐数据并入库
  1574. * 查找 foodId(匹配 foods 表),未匹配时留空
  1575. */
  1576. @Transactional
  1577. public void saveFoodSuitability(Long reportId, List<ParsedReportPayload.FoodItem> foods) {
  1578. if (foods == null || foods.isEmpty()) {
  1579. log.info("报告{}无食材推荐数据,跳过入库", reportId);
  1580. return;
  1581. }
  1582. // 批量查询所有食材名称→ID映射
  1583. Map<String, Long> foodNameIdMap = new HashMap<>();
  1584. List<Food> allFoods = foodMapper.selectList(null);
  1585. for (Food f : allFoods) {
  1586. if (f.getName() != null) {
  1587. foodNameIdMap.put(f.getName().trim(), f.getId());
  1588. }
  1589. }
  1590. Date now = new Date();
  1591. int saved = 0;
  1592. for (ParsedReportPayload.FoodItem item : foods) {
  1593. if (item.getName() == null || item.getName().trim().isEmpty()) continue;
  1594. ReportFoodSuitability suit = new ReportFoodSuitability();
  1595. suit.setReportId(reportId);
  1596. suit.setFoodName(item.getName().trim());
  1597. suit.setCategory(item.getCategory());
  1598. suit.setScore(item.getScore());
  1599. suit.setCreatedAt(now);
  1600. // 匹配 foodId
  1601. Long foodId = foodNameIdMap.get(item.getName().trim());
  1602. if (foodId == null) {
  1603. // 模糊匹配:尝试包含匹配
  1604. for (Map.Entry<String, Long> entry : foodNameIdMap.entrySet()) {
  1605. if (entry.getKey().contains(item.getName().trim()) ||
  1606. item.getName().trim().contains(entry.getKey())) {
  1607. foodId = entry.getValue();
  1608. break;
  1609. }
  1610. }
  1611. }
  1612. suit.setFoodId(foodId);
  1613. reportFoodSuitabilityMapper.insert(suit);
  1614. saved++;
  1615. }
  1616. log.info("已保存{}条食材推荐到报告{}", saved, reportId);
  1617. }
  1618. /**
  1619. * 更新已发布报告的编辑数据
  1620. * 逻辑: 只更新已有字段的值,不增删条目(全量替换)
  1621. * payload 支持嵌套结构 {summary:{...}, indicators:[], gutFlora:[], diseaseRisks:[]},
  1622. * 也兼容旧版平铺结构 {overallScore:..., indicators:[], ...}
  1623. */
  1624. @Transactional(rollbackFor = Exception.class)
  1625. public void updateReportFromPayload(Long reportId, Map<String, Object> payload, Long subjectId, Long userId) {
  1626. // 0. 编辑前快照(用于编辑日志)
  1627. HealthReport oldReport = healthReportMapper.selectById(reportId);
  1628. if (oldReport == null) {
  1629. throw new RuntimeException("报告不存在: " + reportId);
  1630. }
  1631. String beforePayload = oldReport.getPayloadJson();
  1632. // 取评分子段: 嵌套 {summary:{...}} 或平铺
  1633. @SuppressWarnings("unchecked")
  1634. Map<String, Object> summary = (payload.get("summary") instanceof Map)
  1635. ? (Map<String, Object>) payload.get("summary") : null;
  1636. Map<String, Object> scoreSource = summary != null ? summary : payload;
  1637. // 1. 更新 health_report 评分数值
  1638. Integer overallScore = toInteger(scoreSource.get("overallScore"));
  1639. if (overallScore != null) oldReport.setOverallScore(overallScore);
  1640. Integer gutHealthScore = toInteger(scoreSource.get("gutHealthScore"));
  1641. if (gutHealthScore != null) oldReport.setGutHealthScore(gutHealthScore);
  1642. Integer chronicDiseaseScore = toInteger(scoreSource.get("chronicDiseaseScore"));
  1643. if (chronicDiseaseScore != null) oldReport.setChronicDiseaseScore(chronicDiseaseScore);
  1644. Integer nutritionScore = toInteger(scoreSource.get("nutritionScore"));
  1645. if (nutritionScore != null) oldReport.setNutritionScore(nutritionScore);
  1646. Integer gutBalanceScore = toInteger(scoreSource.get("gutBalanceScore"));
  1647. if (gutBalanceScore != null) oldReport.setGutBalanceScore(gutBalanceScore);
  1648. Integer gutDiversityScore = toInteger(scoreSource.get("gutDiversityScore"));
  1649. if (gutDiversityScore != null) oldReport.setGutDiversityScore(gutDiversityScore);
  1650. Integer beneficialBacteriaScore = toInteger(scoreSource.get("beneficialBacteriaScore"));
  1651. if (beneficialBacteriaScore != null) oldReport.setBeneficialBacteriaScore(beneficialBacteriaScore);
  1652. Integer harmfulBacteriaScore = toInteger(scoreSource.get("harmfulBacteriaScore"));
  1653. if (harmfulBacteriaScore != null) oldReport.setHarmfulBacteriaScore(harmfulBacteriaScore);
  1654. Integer coreSpeciesScore = toInteger(scoreSource.get("coreSpeciesScore"));
  1655. if (coreSpeciesScore != null) oldReport.setCoreSpeciesScore(coreSpeciesScore);
  1656. if (scoreSource.get("shannonIndex") != null) {
  1657. oldReport.setShannonIndex(scoreSource.get("shannonIndex").toString());
  1658. }
  1659. // 用户可手动修正报告日期(AI 解析结果不准确时)
  1660. Object reportDateObj = scoreSource.get("reportDate");
  1661. if (reportDateObj != null && !String.valueOf(reportDateObj).trim().isEmpty()) {
  1662. String reportDateStr = String.valueOf(reportDateObj).trim();
  1663. try {
  1664. java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd");
  1665. java.util.Date parsedDate = sdf.parse(reportDateStr);
  1666. if (parsedDate != null) {
  1667. oldReport.setReportDate(new java.sql.Date(parsedDate.getTime()));
  1668. log.info("报告日期已更新: reportId={}, reportDate={}", reportId, reportDateStr);
  1669. }
  1670. } catch (Exception e) {
  1671. log.warn("reportDate 格式解析失败 reportId={}, value={}: {}", reportId, reportDateStr, e.getMessage());
  1672. }
  1673. }
  1674. oldReport.setUpdatedAt(new Date());
  1675. healthReportMapper.updateById(oldReport);
  1676. // 2. 更新 indicators(删除旧→插入新)
  1677. Object indicatorsRaw = payload.get("indicators");
  1678. if (indicatorsRaw instanceof List) {
  1679. @SuppressWarnings("unchecked")
  1680. List<Map<String, Object>> indicators = (List<Map<String, Object>>) indicatorsRaw;
  1681. healthIndicatorMapper.delete(new LambdaQueryWrapper<HealthIndicator>()
  1682. .eq(HealthIndicator::getReportId, reportId));
  1683. for (int i = 0; i < indicators.size(); i++) {
  1684. Map<String, Object> item = indicators.get(i);
  1685. HealthIndicator ind = new HealthIndicator();
  1686. ind.setReportId(reportId);
  1687. ind.setCategory((String) item.get("category"));
  1688. ind.setIndicatorName((String) item.get("indicatorName"));
  1689. ind.setIndicatorValue(item.get("indicatorValue") != null
  1690. ? item.get("indicatorValue").toString() : null);
  1691. ind.setUnit((String) item.get("unit"));
  1692. ind.setRefRange((String) item.get("refRange"));
  1693. ind.setStatus((String) item.get("status"));
  1694. ind.setSymptoms((String) item.get("symptoms"));
  1695. ind.setSortOrder(i);
  1696. healthIndicatorMapper.insert(ind);
  1697. }
  1698. }
  1699. // 3. 更新 gutFlora
  1700. Object gutFloraRaw = payload.get("gutFlora");
  1701. if (gutFloraRaw instanceof List) {
  1702. @SuppressWarnings("unchecked")
  1703. List<Map<String, Object>> gutFlora = (List<Map<String, Object>>) gutFloraRaw;
  1704. healthGutFloraMapper.delete(new LambdaQueryWrapper<HealthGutFlora>()
  1705. .eq(HealthGutFlora::getReportId, reportId));
  1706. for (int i = 0; i < gutFlora.size(); i++) {
  1707. Map<String, Object> item = gutFlora.get(i);
  1708. HealthGutFlora flora = new HealthGutFlora();
  1709. flora.setReportId(reportId);
  1710. flora.setBacteriaName((String) item.get("bacteriaName"));
  1711. flora.setBacteriaValue(item.get("bacteriaValue") != null
  1712. ? item.get("bacteriaValue").toString() : null);
  1713. flora.setNormalRange((String) item.get("normalRange"));
  1714. flora.setStatus((String) item.get("status"));
  1715. flora.setCategory(normalizeFloraCategory((String) item.get("category")));
  1716. flora.setPopulationLevel((String) item.get("populationLevel"));
  1717. flora.setDetectionRate((String) item.get("detectionRate"));
  1718. flora.setLevel(normalizeFloraLevel((String) item.get("category"),
  1719. (String) item.get("level")));
  1720. flora.setSortOrder(i);
  1721. healthGutFloraMapper.insert(flora);
  1722. }
  1723. }
  1724. // 4. 更新 diseaseRisks
  1725. Object risksRaw = payload.get("diseaseRisks");
  1726. if (risksRaw instanceof List) {
  1727. @SuppressWarnings("unchecked")
  1728. List<Map<String, Object>> diseaseRisks = (List<Map<String, Object>>) risksRaw;
  1729. healthDiseaseRiskMapper.delete(new LambdaQueryWrapper<HealthDiseaseRisk>()
  1730. .eq(HealthDiseaseRisk::getReportId, reportId));
  1731. for (Map<String, Object> item : diseaseRisks) {
  1732. HealthDiseaseRisk risk = new HealthDiseaseRisk();
  1733. risk.setReportId(reportId);
  1734. risk.setDiseaseName((String) item.get("diseaseName"));
  1735. risk.setRiskValue(item.get("riskValue") != null
  1736. ? item.get("riskValue").toString() : null);
  1737. risk.setRiskLevel((String) item.get("riskLevel"));
  1738. healthDiseaseRiskMapper.insert(risk);
  1739. }
  1740. }
  1741. // 5. 刷新 7 维健康评分(支持成员重选:旧成员和新成员都需重算)
  1742. Long oldSubjectId = oldReport.getSubjectId();
  1743. // subjectId 语义:
  1744. // null → 主动解绑(移除该报告对原成员的影响)
  1745. // <= 0 → 保持不变(子页面局部编辑,不触碰成员绑定)
  1746. // > 0 → 绑定或切换成员(重算新成员 + 原成员)
  1747. if (subjectId != null && subjectId > 0) {
  1748. // 更新报告绑定的成员(可能为新成员或切换成员)
  1749. if (oldSubjectId == null || !oldSubjectId.equals(subjectId)) {
  1750. bindReportToMember(reportId, subjectId);
  1751. }
  1752. try {
  1753. dimensionScoreService.refreshFromGutReport(subjectId, reportId);
  1754. log.info("编辑后已刷新成员{}的7维健康评分", subjectId);
  1755. } catch (Exception e) {
  1756. log.warn("刷新7维健康评分失败: {}", e.getMessage());
  1757. }
  1758. try {
  1759. foodRecommendService.recalculateForUser(subjectId);
  1760. log.info("编辑后已刷新成员{}的食材推荐指数", subjectId);
  1761. } catch (Exception e) {
  1762. log.warn("刷新食材推荐指数失败: {}", e.getMessage());
  1763. }
  1764. // 若有旧成员(解绑或切换成员),也需重算旧成员的维度
  1765. if (oldSubjectId != null && !oldSubjectId.equals(subjectId)) {
  1766. try {
  1767. dimensionScoreService.rebuildFromMemberReports(oldSubjectId);
  1768. log.info("编辑后已重算旧成员{}的7维健康评分(成员已切换)", oldSubjectId);
  1769. } catch (Exception e) {
  1770. log.warn("刷新旧成员{}健康评分失败: {}", oldSubjectId, e.getMessage());
  1771. }
  1772. try {
  1773. foodRecommendService.recalculateForUser(oldSubjectId);
  1774. } catch (Exception e) {
  1775. log.warn("刷新旧成员{}食材推荐指数失败: {}", oldSubjectId, e.getMessage());
  1776. }
  1777. }
  1778. } else if (subjectId == null && oldSubjectId != null) {
  1779. // 用户主动解绑(subjectId=null),重算旧成员(移除该报告的影响)
  1780. try {
  1781. dimensionScoreService.rebuildFromMemberReports(oldSubjectId);
  1782. log.info("解绑成员后已重算旧成员{}的7维健康评分", oldSubjectId);
  1783. } catch (Exception e) {
  1784. log.warn("刷新旧成员{}健康评分失败: {}", oldSubjectId, e.getMessage());
  1785. }
  1786. try {
  1787. foodRecommendService.recalculateForUser(oldSubjectId);
  1788. } catch (Exception e) {
  1789. log.warn("刷新旧成员{}食材推荐指数失败: {}", oldSubjectId, e.getMessage());
  1790. }
  1791. }
  1792. // 6. 保存 payload 快照 + 编辑日志
  1793. try {
  1794. String afterPayload = objectMapper.writeValueAsString(payload);
  1795. oldReport.setPayloadJson(afterPayload);
  1796. oldReport.setUpdatedAt(new Date());
  1797. healthReportMapper.updateById(oldReport);
  1798. HealthReportEditLog editLog = new HealthReportEditLog();
  1799. editLog.setReportId(reportId);
  1800. editLog.setUserId(userId);
  1801. editLog.setReportType("gut_flora");
  1802. editLog.setAction("edit");
  1803. editLog.setBeforePayload(beforePayload);
  1804. editLog.setAfterPayload(afterPayload);
  1805. editLog.setCreatedAt(new Date());
  1806. healthReportEditLogMapper.insert(editLog);
  1807. } catch (Exception e) {
  1808. log.warn("保存payload快照/编辑日志失败 reportId={}: {}", reportId, e.getMessage());
  1809. }
  1810. // 7. 编辑后重新组装展示块(第一套键 Map → 标准 Payload → blocks)
  1811. try {
  1812. ParsedReportPayload.Payload editedPayload = reportBlockAssembler.fromMap(payload);
  1813. String reportType = oldReport.getReportType();
  1814. reportBlockService.save(reportType, reportId,
  1815. reportBlockAssembler.assembleWithTemplate(reportType, editedPayload));
  1816. } catch (Exception e) {
  1817. log.warn("编辑后刷新展示块失败 reportId={}: {}", reportId, e.getMessage());
  1818. }
  1819. // 归一化管道:编辑后重写统一指标层(同事务;payload 为编辑后最新值)
  1820. try {
  1821. ParsedReportPayload.Payload editedPayload = reportBlockAssembler.fromMap(payload);
  1822. oldReport.setSubjectId(subjectId != null && subjectId > 0 ? subjectId : oldSubjectId);
  1823. normalizeIndicatorPipeline.processReport(oldReport, editedPayload);
  1824. } catch (Exception e) {
  1825. log.warn("归一化管道刷新失败 reportId={}: {}", reportId, e.getMessage());
  1826. }
  1827. }
  1828. private Integer toInteger(Object value) {
  1829. if (value == null) return null;
  1830. try {
  1831. return Integer.valueOf(value.toString());
  1832. } catch (NumberFormatException e) {
  1833. return null;
  1834. }
  1835. }
  1836. /**
  1837. * 保存 payload 快照并记录编辑日志(action: create/edit)
  1838. */
  1839. public void savePayloadSnapshot(Long reportId, Long userId, String action,
  1840. String beforePayload, String afterPayload) {
  1841. try {
  1842. HealthReport report = healthReportMapper.selectById(reportId);
  1843. if (report != null) {
  1844. report.setPayloadJson(afterPayload);
  1845. report.setUpdatedAt(new Date());
  1846. healthReportMapper.updateById(report);
  1847. }
  1848. HealthReportEditLog editLog = new HealthReportEditLog();
  1849. editLog.setReportId(reportId);
  1850. editLog.setUserId(userId);
  1851. editLog.setReportType("gut_flora");
  1852. editLog.setAction(action);
  1853. editLog.setBeforePayload(beforePayload);
  1854. editLog.setAfterPayload(afterPayload);
  1855. editLog.setCreatedAt(new Date());
  1856. healthReportEditLogMapper.insert(editLog);
  1857. } catch (Exception e) {
  1858. log.warn("保存payload快照/编辑日志失败 reportId={}: {}", reportId, e.getMessage());
  1859. }
  1860. }
  1861. /**
  1862. * 设置代上传人信息
  1863. */
  1864. public void setProxyUploader(Long reportId, Long proxyUploaderId) {
  1865. HealthReport report = healthReportMapper.selectById(reportId);
  1866. if (report != null) {
  1867. report.setProxyUploaderId(proxyUploaderId);
  1868. report.setUpdatedAt(new Date());
  1869. healthReportMapper.updateById(report);
  1870. }
  1871. }
  1872. /**
  1873. * 设置AI解读结果
  1874. */
  1875. public void setAiAnalysis(Long reportId, Map<String, Object> aiData) {
  1876. try {
  1877. com.alibaba.fastjson.JSON json = com.alibaba.fastjson.JSON.parseObject(
  1878. com.alibaba.fastjson.JSON.toJSONString(aiData));
  1879. HealthReport report = healthReportMapper.selectById(reportId);
  1880. if (report != null) {
  1881. report.setAiAnalysis(json.toJSONString());
  1882. report.setUpdatedAt(new Date());
  1883. healthReportMapper.updateById(report);
  1884. }
  1885. } catch (Exception e) {
  1886. log.warn("保存AI解读失败: {}", e.getMessage());
  1887. }
  1888. }
  1889. /**
  1890. * 删除健康报告(逻辑删除: 设置 status=deleted,同时清理关联数据)
  1891. */
  1892. @Transactional
  1893. public void deleteReport(Long reportId) {
  1894. // 逻辑删除前记录 subjectId,用于删除后重算维度分
  1895. Long subjectId = null;
  1896. HealthReport report = healthReportMapper.selectById(reportId);
  1897. if (report != null) {
  1898. subjectId = report.getSubjectId();
  1899. }
  1900. // 清理关联指标
  1901. healthIndicatorMapper.delete(new LambdaQueryWrapper<HealthIndicator>()
  1902. .eq(HealthIndicator::getReportId, reportId));
  1903. // 清理菌群数据
  1904. healthGutFloraMapper.delete(new LambdaQueryWrapper<HealthGutFlora>()
  1905. .eq(HealthGutFlora::getReportId, reportId));
  1906. // 清理疾病风险评估
  1907. healthDiseaseRiskMapper.delete(new LambdaQueryWrapper<HealthDiseaseRisk>()
  1908. .eq(HealthDiseaseRisk::getReportId, reportId));
  1909. // 清理查看权限
  1910. healthReportPermissionMapper.delete(new LambdaQueryWrapper<HealthReportPermission>()
  1911. .eq(HealthReportPermission::getReportId, reportId));
  1912. // 清理编辑日志
  1913. healthReportEditLogMapper.delete(new LambdaQueryWrapper<HealthReportEditLog>()
  1914. .eq(HealthReportEditLog::getReportId, reportId));
  1915. // 清理展示块
  1916. reportBlockMapper.delete(new LambdaQueryWrapper<ReportBlock>()
  1917. .eq(ReportBlock::getReportId, reportId));
  1918. // 清理食材适合性
  1919. reportFoodSuitabilityMapper.delete(new LambdaQueryWrapper<ReportFoodSuitability>()
  1920. .eq(ReportFoodSuitability::getReportId, reportId));
  1921. // 逻辑删除报告本身
  1922. if (report != null) {
  1923. report.setStatus("deleted");
  1924. report.setUpdatedAt(new Date());
  1925. healthReportMapper.updateById(report);
  1926. log.info("健康报告已删除: id={}, subjectId={}", reportId, subjectId);
  1927. }
  1928. // 删除后重算该成员的维度分(移除该报告的影响)
  1929. if (subjectId != null) {
  1930. try {
  1931. dimensionScoreService.refreshFromGutReport(subjectId, reportId);
  1932. log.info("报告删除后已重算成员{}的7维健康评分", subjectId);
  1933. } catch (Exception e) {
  1934. log.warn("报告删除后刷新成员{}健康评分失败: {}", subjectId, e.getMessage());
  1935. }
  1936. try {
  1937. foodRecommendService.recalculateForUser(subjectId);
  1938. } catch (Exception e) {
  1939. log.warn("报告删除后刷新成员{}食材推荐指数失败: {}", subjectId, e.getMessage());
  1940. }
  1941. }
  1942. }
  1943. /**
  1944. * 获取某成员某维度的最新指标值列表(用于首页卡片展示)
  1945. */
  1946. public List<Map<String, Object>> getLatestIndicatorsByDomain(Long memberId, String domain) {
  1947. // 1. 获取该域下所有指标定义
  1948. LambdaQueryWrapper<IndicatorDefinition> defW = new LambdaQueryWrapper<>();
  1949. defW.eq(IndicatorDefinition::getDomain, domain).eq(IndicatorDefinition::getStatus, 1);
  1950. defW.orderByAsc(IndicatorDefinition::getSortOrder);
  1951. List<IndicatorDefinition> defs = indicatorDefinitionMapper.selectList(defW);
  1952. if (defs.isEmpty()) return Collections.emptyList();
  1953. // 2. 对每个定义,获取该成员的最新值
  1954. List<Map<String, Object>> result = new java.util.ArrayList<>();
  1955. for (IndicatorDefinition def : defs) {
  1956. LambdaQueryWrapper<IndicatorValue> w = new LambdaQueryWrapper<>();
  1957. // 口径:写入端 source_id=报告ID(保留每报告快照),subject_id=成员ID;
  1958. // 读取端按 subject_id=memberId 查最新观测值
  1959. w.eq(IndicatorValue::getSubjectId, memberId)
  1960. .eq(IndicatorValue::getDefinitionId, def.getId())
  1961. .orderByDesc(IndicatorValue::getRecordedAt)
  1962. .last("LIMIT 1");
  1963. IndicatorValue latest = indicatorValueMapper.selectOne(w);
  1964. // B方案 diff:member_indicator_current(member_id+definition_id 唯一)
  1965. com.etotem.cfc.entity.MemberIndicatorCurrent mic = null;
  1966. try {
  1967. mic = memberIndicatorCurrentService.getByMemberAndDefinition(memberId, def.getId());
  1968. } catch (Exception ignore) { }
  1969. Map<String, Object> item = new HashMap<>();
  1970. item.put("code", def.getCode());
  1971. item.put("name", def.getName());
  1972. item.put("category", def.getCategory());
  1973. item.put("unit", def.getUnit());
  1974. item.put("refRange", def.getRefRange());
  1975. if (latest != null) {
  1976. item.put("value", latest.getValue());
  1977. item.put("numericValue", latest.getNumericValue() != null ? latest.getNumericValue().doubleValue() : null);
  1978. item.put("remark", latest.getRemark());
  1979. item.put("recordedAt", latest.getRecordedAt());
  1980. } else if (mic != null) {
  1981. // indicator_values 无观测行时回退到 member_indicator_current(B方案 diff 表)
  1982. item.put("value", mic.getValue());
  1983. item.put("numericValue", mic.getNumericValue() != null ? mic.getNumericValue().doubleValue() : null);
  1984. item.put("remark", null);
  1985. item.put("recordedAt", mic.getUpdatedAt());
  1986. } else {
  1987. item.put("value", null);
  1988. item.put("numericValue", null);
  1989. item.put("remark", null);
  1990. item.put("recordedAt", null);
  1991. }
  1992. if (mic != null) {
  1993. item.put("previousValue", mic.getPreviousValue());
  1994. java.math.BigDecimal delta = mic.getDelta();
  1995. item.put("delta", delta != null ? delta.doubleValue() : null);
  1996. item.put("trend", mic.getTrend());
  1997. // trendDesc: 前端展示"较上次 ±x"
  1998. if (delta != null && delta.compareTo(java.math.BigDecimal.ZERO) != 0) {
  1999. String arrow = delta.signum() > 0 ? "+" : "";
  2000. // stripTrailingZeros: 去掉 decimal(12,4) 带来的尾零 (如 -9.0000 -> -9)
  2001. String plain = delta.stripTrailingZeros().toPlainString();
  2002. item.put("trendDesc", "较上次 " + arrow + plain);
  2003. } else {
  2004. item.put("trendDesc", "");
  2005. }
  2006. }
  2007. result.add(item);
  2008. }
  2009. return result;
  2010. }
  2011. /**
  2012. * 获取成员的最新菌群报告核心指标(用于身页面"肠道菌群"卡片)
  2013. * 返回:核心菌属、益生菌、有害菌属、病原菌属、其它重要菌属(排除 taxonomy 门/纲/目/科级别分类构成)
  2014. */
  2015. public List<Map<String, Object>> getGutFloraIndicators(Long memberId) {
  2016. LambdaQueryWrapper<HealthReport> reportW = new LambdaQueryWrapper<>();
  2017. reportW.eq(HealthReport::getSubjectId, memberId)
  2018. .or().eq(HealthReport::getUserId, memberId);
  2019. reportW.eq(HealthReport::getReportType, "gut_flora")
  2020. .eq(HealthReport::getStatus, "active")
  2021. .orderByDesc(HealthReport::getCreatedAt)
  2022. .last("LIMIT 1");
  2023. HealthReport report = healthReportMapper.selectOne(reportW);
  2024. if (report == null) return Collections.emptyList();
  2025. LambdaQueryWrapper<HealthGutFlora> w = new LambdaQueryWrapper<>();
  2026. w.eq(HealthGutFlora::getReportId, report.getId())
  2027. .in(HealthGutFlora::getCategory, "核心菌属", "益生菌", "有害菌属", "其它重要菌属", "病原菌属")
  2028. .orderByAsc(HealthGutFlora::getSortOrder);
  2029. List<HealthGutFlora> flora = healthGutFloraMapper.selectList(w);
  2030. if (flora.isEmpty()) return Collections.emptyList();
  2031. List<Map<String, Object>> result = new ArrayList<>();
  2032. for (HealthGutFlora f : flora) {
  2033. Map<String, Object> item = new HashMap<>();
  2034. item.put("name", f.getBacteriaName());
  2035. item.put("value", f.getBacteriaValue());
  2036. item.put("status", f.getStatus());
  2037. item.put("category", f.getCategory());
  2038. item.put("populationLevel", f.getPopulationLevel());
  2039. item.put("detectionRate", f.getDetectionRate());
  2040. item.put("description", f.getDescription());
  2041. item.put("level", f.getLevel());
  2042. result.add(item);
  2043. }
  2044. return result;
  2045. }
  2046. /**
  2047. * 刷新成员指标当前值 diff(member_indicator_current)
  2048. * 逻辑:
  2049. * 1. 查该成员最近 2 份按 report_date desc 的确认报告(status=active)
  2050. * 2. 取第 1 份(最新)与第 2 份(次新)中同名指标
  2051. * 3. 通过 IndicatorDefinitionMapper 拿 domain/category
  2052. * 4. 用 numeric 做 delta/trend 计算(字符串 fallback)
  2053. * 5. upsert 写入 member_indicator_current
  2054. * 幂等:同一 member_id+definition_id 用 upsert 覆盖
  2055. */
  2056. /**
  2057. * 回填重载:按成员自动找最近 2 份已确认报告并刷新 member_indicator_current
  2058. * 供 admin 端点 / 启动任务使用;无 latestReportId 也可调用(自动解析)
  2059. */
  2060. public void rebuildForMember(Long subjectId) {
  2061. if (subjectId == null) return;
  2062. List<HealthReport> recent = healthReportMapper.selectList(
  2063. new LambdaQueryWrapper<HealthReport>()
  2064. .eq(HealthReport::getSubjectId, subjectId)
  2065. .eq(HealthReport::getStatus, "active")
  2066. .orderByDesc(HealthReport::getReportDate)
  2067. .orderByDesc(HealthReport::getCreatedAt)
  2068. .last("LIMIT 1"));
  2069. if (recent.isEmpty()) {
  2070. log.info("rebuildForMember: 成员{}无已确认报告,跳过", subjectId);
  2071. return;
  2072. }
  2073. refreshMemberIndicatorCurrent(subjectId, recent.get(0).getId());
  2074. }
  2075. public void refreshMemberIndicatorCurrent(Long subjectId, Long latestReportId) {
  2076. if (subjectId == null || latestReportId == null) return;
  2077. try {
  2078. // 取该成员最近 2 份报告 (按 report_date desc, 失败则 fallback 到 created_at)
  2079. List<HealthReport> recent = healthReportMapper.selectList(
  2080. new LambdaQueryWrapper<HealthReport>()
  2081. .eq(HealthReport::getSubjectId, subjectId)
  2082. .eq(HealthReport::getStatus, "active")
  2083. .orderByDesc(HealthReport::getReportDate)
  2084. .orderByDesc(HealthReport::getCreatedAt)
  2085. .last("LIMIT 2"));
  2086. if (recent.isEmpty()) return;
  2087. HealthReport latest = recent.get(0);
  2088. HealthReport previous = recent.size() > 1 ? recent.get(1) : null;
  2089. // 最新报告的指标
  2090. List<HealthIndicator> latestInds = getReportIndicators(latest.getId());
  2091. if (latestInds.isEmpty()) return;
  2092. // 次新指标的 name→HealthIndicator 映射
  2093. java.util.Map<String, HealthIndicator> prevMap = new java.util.HashMap<>();
  2094. if (previous != null) {
  2095. for (HealthIndicator pi : getReportIndicators(previous.getId())) {
  2096. if (pi.getIndicatorName() != null) {
  2097. prevMap.put(pi.getIndicatorName().trim(), pi);
  2098. }
  2099. }
  2100. }
  2101. int written = 0;
  2102. for (HealthIndicator li : latestInds) {
  2103. if (li.getIndicatorName() == null) continue;
  2104. String rawName = li.getIndicatorName().trim();
  2105. // 通过 mapper 找 definition(domain/category 来源)
  2106. com.etotem.cfc.entity.IndicatorDefinition def = indicatorDefinitionMapper.selectOne(
  2107. new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<
  2108. com.etotem.cfc.entity.IndicatorDefinition>()
  2109. .eq(com.etotem.cfc.entity.IndicatorDefinition::getName, rawName)
  2110. .eq(com.etotem.cfc.entity.IndicatorDefinition::getStatus, 1)
  2111. .last("LIMIT 1"));
  2112. if (def == null) continue;
  2113. MemberIndicatorCurrent item = new MemberIndicatorCurrent();
  2114. item.setMemberId(subjectId);
  2115. item.setDefinitionId(def.getId());
  2116. item.setDomain(def.getDomain());
  2117. item.setCategory(def.getCategory());
  2118. item.setName(rawName);
  2119. item.setValue(li.getIndicatorValue());
  2120. item.setLatestReportId(latest.getId());
  2121. item.setReportDate(latest.getReportDate() != null
  2122. ? new java.sql.Date(latest.getReportDate().getTime()) : null);
  2123. // previous 指标(同名)
  2124. HealthIndicator pi = prevMap.get(rawName);
  2125. if (pi != null) {
  2126. item.setPreviousValue(pi.getIndicatorValue());
  2127. item.setPreviousReportId(previous.getId());
  2128. }
  2129. // numeric 计算
  2130. BigDecimal currentNum = parseNumericString(li.getIndicatorValue());
  2131. BigDecimal previousNum = pi != null ? parseNumericString(pi.getIndicatorValue()) : null;
  2132. item.setNumericValue(currentNum);
  2133. item.setPreviousNumeric(previousNum);
  2134. if (currentNum != null && previousNum != null) {
  2135. item.setDelta(currentNum.subtract(previousNum));
  2136. int cmp = currentNum.compareTo(previousNum);
  2137. item.setTrend(cmp > 0 ? "up" : (cmp < 0 ? "down" : "flat"));
  2138. } else {
  2139. item.setTrend(pi != null ? "na" : "new");
  2140. }
  2141. item.setRecordedAt(new Date());
  2142. memberIndicatorCurrentService.upsert(item);
  2143. written++;
  2144. }
  2145. log.info("迁移378 补写完成: member={} latestReport={} prevReport={} written={}",
  2146. subjectId, latest.getId(),
  2147. previous != null ? previous.getId() : "none", written);
  2148. } catch (Exception e) {
  2149. log.warn("迁移378 补写失败 member={} report={}: {}", subjectId, latestReportId, e.getMessage());
  2150. }
  2151. }
  2152. /** 安全解析指标值为 BigDecimal(失败返回 null,避免整块报告因单条脏数据卡死)*/
  2153. private BigDecimal parseNumericString(String s) {
  2154. if (s == null || s.trim().isEmpty()) return null;
  2155. try {
  2156. return new BigDecimal(s.trim().replaceAll("[^0-9.eE+-]", ""));
  2157. } catch (Exception ex) {
  2158. return null;
  2159. }
  2160. }
  2161. }