| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394 |
- package com.etotem.cfc.service;
- import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
- import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
- import com.etotem.cfc.dto.MemberMatchResult;
- import com.etotem.cfc.dto.ParsedReportPayload;
- import com.etotem.cfc.entity.*;
- import com.etotem.cfc.mapper.FamilyMemberMapper;
- import com.etotem.cfc.mapper.HealthDiseaseRiskMapper;
- import com.etotem.cfc.mapper.HealthGutFloraMapper;
- import com.etotem.cfc.mapper.HealthIndicatorMapper;
- import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
- import com.etotem.cfc.mapper.IndicatorValueMapper;
- import com.etotem.cfc.mapper.HealthReportDraftMapper;
- import com.etotem.cfc.mapper.HealthReportEditLogMapper;
- import com.etotem.cfc.mapper.HealthReportMapper;
- import com.etotem.cfc.mapper.HealthReportPermissionMapper;
- import com.etotem.cfc.mapper.UserMapper;
- import com.etotem.cfc.mapper.FoodMapper;
- import com.etotem.cfc.mapper.ReportBlockMapper;
- import com.etotem.cfc.mapper.ReportFoodSuitabilityMapper;
- import com.fasterxml.jackson.databind.ObjectMapper;
- import lombok.extern.slf4j.Slf4j;
- import org.springframework.dao.DuplicateKeyException;
- import org.springframework.jdbc.core.JdbcTemplate;
- import org.springframework.stereotype.Service;
- import org.springframework.transaction.annotation.Transactional;
- import javax.annotation.Resource;
- import java.util.ArrayList;
- import java.util.Arrays;
- import java.util.Collections;
- import java.util.Comparator;
- import java.util.Date;
- import java.util.HashMap;
- import java.util.HashSet;
- import java.util.List;
- import java.math.BigDecimal;
- import java.util.Map;
- import com.etotem.cfc.entity.MemberIndicatorCurrent;
- import java.util.Set;
- import com.etotem.cfc.util.SortUtil;
- /**
- * 健康检测报告服务 — 管理体检报告/菌群检测报告的录入与查询
- * 为五维能量中的"身(body)"维度提供数据源
- */
- @Slf4j
- @Service
- public class HealthReportService {
- @Resource
- private HealthReportMapper healthReportMapper;
-
- @Resource
- protected JdbcTemplate jdbcTemplate;
- @Resource
- private HealthIndicatorMapper healthIndicatorMapper;
- @Resource
- private HealthGutFloraMapper healthGutFloraMapper;
- @Resource
- private HealthDiseaseRiskMapper healthDiseaseRiskMapper;
- @Resource
- private FamilyMemberMapper familyMemberMapper;
- @Resource
- private UserMapper userMapper;
- @Resource
- private IndicatorDefinitionMapper indicatorDefinitionMapper;
- @Resource
- private IndicatorValueMapper indicatorValueMapper;
- @Resource
- private HealthReportPermissionMapper healthReportPermissionMapper;
- @Resource
- private HealthReportDraftMapper draftMapper;
- @Resource
- private HealthReportEditLogMapper healthReportEditLogMapper;
- @Resource
- private ReportSummaryService reportSummaryService;
- @Resource
- private ObjectMapper objectMapper;
- @Resource
- private DimensionScoreService dimensionScoreService;
- // @Lazy 打破循环依赖:FoodRecommendService 内部注入 HealthReportService
- @Resource
- @org.springframework.context.annotation.Lazy
- private FoodRecommendService foodRecommendService;
- @Resource
- private ReportBlockService reportBlockService;
- @Resource
- private ReportBlockAssembler reportBlockAssembler;
- @Resource
- private ReportFoodSuitabilityMapper reportFoodSuitabilityMapper;
- @Resource
- private ReportBlockMapper reportBlockMapper;
- @Resource
- private FoodMapper foodMapper;
- @Resource
- @org.springframework.context.annotation.Lazy
- private MemberIndicatorCurrentService memberIndicatorCurrentService;
- @Resource
- private com.etotem.cfc.service.NormalizeIndicatorPipeline normalizeIndicatorPipeline;
- /**
- * 创建健康报告(含指标列表)
- */
- @Transactional
- public HealthReport createReport(HealthReport report, List<HealthIndicator> indicators) {
- report.setCreatedAt(new Date());
- report.setUpdatedAt(new Date());
- if (report.getStatus() == null) {
- report.setStatus("active");
- }
- healthReportMapper.insert(report);
- try {
- reportSummaryService.syncFromHealthReport(report);
- } catch (Exception e) {
- log.warn("同步健康报告汇总失败 id={}", report.getId(), e);
- }
- if (indicators != null && !indicators.isEmpty()) {
- // 指标去重: 同一批中相同 indicatorName 的只保留最后一条
- java.util.Map<String, HealthIndicator> dedupMap = new java.util.LinkedHashMap<>();
- for (HealthIndicator ind : indicators) {
- String key = (ind.getCategory() != null ? ind.getCategory() : "") + "|" + (ind.getIndicatorName() != null ? ind.getIndicatorName() : "");
- dedupMap.put(key, ind);
- }
- for (HealthIndicator ind : dedupMap.values()) {
- ind.setReportId(report.getId());
- if (ind.getCollectionDate() == null) {
- ind.setCollectionDate(report.getReportDate());
- }
- ind.setCreatedAt(new Date());
- truncateIndicatorColumns(ind);
- healthIndicatorMapper.insert(ind);
- }
- }
- log.info("健康报告已创建: id={}, userId={}, type={}, score={}",
- report.getId(), report.getUserId(), report.getReportType(), report.getOverallScore());
- return report;
- }
- /**
- * 截断指标字符串字段到数据库列最大长度(null 安全)。
- * 自由文本来源(LangGraph/opencode 解析、手动录入)status/category 等字段无长度约束,
- * 超长写入会触发 MySQL Data too long 导致整单入库失败。
- */
- private void truncateIndicatorColumns(HealthIndicator ind) {
- ind.setCategory(truncate(ind.getCategory(), 50));
- ind.setIndicatorName(truncate(ind.getIndicatorName(), 100));
- ind.setIndicatorValue(truncate(ind.getIndicatorValue(), 100));
- ind.setUnit(truncate(ind.getUnit(), 50));
- ind.setRefRange(truncate(ind.getRefRange(), 200));
- ind.setStatus(truncate(ind.getStatus(), 100));
- }
- private String truncate(String s, int maxLen) {
- if (s == null || s.length() <= maxLen) return s;
- return s.substring(0, maxLen);
- }
- /**
- * 创建健康报告(含指标列表 + 菌群数据 + 疾病风险评估)
- */
- @Transactional
- public HealthReport createReport(HealthReport report, List<HealthIndicator> indicators,
- List<HealthGutFlora> gutFlora, List<HealthDiseaseRisk> diseaseRisks) {
- // 调用已有逻辑创建报告和指标
- createReport(report, indicators);
- // 保存菌群数据
- if (gutFlora != null && !gutFlora.isEmpty()) {
- saveGutFlora(report.getId(), gutFlora);
- }
- // 保存疾病风险评估
- if (diseaseRisks != null && !diseaseRisks.isEmpty()) {
- saveDiseaseRisks(report.getId(), diseaseRisks);
- }
- return report;
- }
- /**
- * 获取孩子最新的健康报告
- */
- public HealthReport getLatestReport(Long userId) {
- return healthReportMapper.selectOne(
- new LambdaQueryWrapper<HealthReport>()
- .and(w -> w.eq(HealthReport::getSubjectId, userId)
- .or(w2 -> w2.isNull(HealthReport::getSubjectId)
- .eq(HealthReport::getUserId, userId)))
- .eq(HealthReport::getStatus, "active")
- .orderByDesc(HealthReport::getReportDate)
- .last("LIMIT 1")
- );
- }
- /**
- * 按文件MD5查找已入库的报告ID(同一报告类型下),用于重复上传检测
- */
- public Long findReportIdByFileHash(String fileHash, String reportType) {
- if (fileHash == null || reportType == null) {
- return null;
- }
- HealthReport report = healthReportMapper.selectOne(
- new LambdaQueryWrapper<HealthReport>()
- .eq(HealthReport::getFileHash, fileHash)
- .eq(HealthReport::getReportType, reportType)
- .eq(HealthReport::getStatus, "active")
- .last("LIMIT 1")
- );
- return report != null ? report.getId() : null;
- }
- /**
- * 按文件MD5查找正在采集中的草稿ID,用于避免重复提交采集
- */
- public Long findCollectingDraftIdByFileHash(String fileHash) {
- if (fileHash == null) {
- return null;
- }
- HealthReportDraft draft = draftMapper.selectOne(
- new LambdaQueryWrapper<HealthReportDraft>()
- .eq(HealthReportDraft::getFileHash, fileHash)
- .eq(HealthReportDraft::getParseStatus, "collecting")
- .last("LIMIT 1")
- );
- return draft != null ? draft.getId() : null;
- }
- /**
- * 获取全家所有健康报告(用于方案页选择已有报告)
- * 优先通过家庭所有成员(subject_id/user_id)反查报告,兼容报告表 family_id 未填充的历史数据;
- * 同时补充按报告表 family_id 匹配的报告,合并去重。
- */
- public List<HealthReport> getFamilyReports(Long familyId) {
- if (familyId == null) {
- return Collections.emptyList();
- }
- // 1. 查家庭所有成员 id(family_members.id + user_id)
- Set<Long> memberIds = new HashSet<>();
- try {
- List<FamilyMember> members = familyMemberMapper.selectList(
- new LambdaQueryWrapper<FamilyMember>().eq(FamilyMember::getFamilyId, familyId));
- for (FamilyMember m : members) {
- memberIds.add(m.getId());
- if (m.getUserId() != null) {
- memberIds.add(m.getUserId());
- }
- }
- } catch (Exception e) {
- log.warn("查询家庭{}成员失败: {}", familyId, e.getMessage());
- }
- // 2. 按成员反查报告(subject_id/user_id 匹配)
- List<HealthReport> reports = new ArrayList<>();
- if (!memberIds.isEmpty()) {
- reports = healthReportMapper.selectList(
- new LambdaQueryWrapper<HealthReport>()
- .and(w -> w.in(HealthReport::getSubjectId, memberIds)
- .or(w2 -> w2.isNull(HealthReport::getSubjectId)
- .in(HealthReport::getUserId, memberIds)))
- .eq(HealthReport::getStatus, "active")
- .orderByDesc(HealthReport::getCreatedAt));
- }
- // 3. 补充按报告表 family_id 匹配的报告(去重)
- List<HealthReport> byFamily = healthReportMapper.selectList(
- new LambdaQueryWrapper<HealthReport>()
- .eq(HealthReport::getFamilyId, familyId)
- .eq(HealthReport::getStatus, "active")
- .orderByDesc(HealthReport::getCreatedAt));
- Set<Long> seen = new HashSet<>();
- for (HealthReport r : reports) {
- seen.add(r.getId());
- }
- for (HealthReport r : byFamily) {
- if (seen.add(r.getId())) {
- reports.add(r);
- }
- }
- // 按上传时间倒序(createdAt),默认按上传时间排序
- reports.sort((a, b) -> {
- java.util.Date da = a.getCreatedAt();
- java.util.Date db = b.getCreatedAt();
- if (da == null) return 1;
- if (db == null) return -1;
- return db.compareTo(da);
- });
- return reports;
- }
- /**
- * 获取某家庭成员的所有健康报告
- * 兼容 subject_id 的两种历史语义(users.id / family_members.id):
- * 先通过 family_members 表归一该成员的两种 id,再用 IN 匹配 subject_id/user_id
- */
- public List<HealthReport> getMemberReports(Long memberId) {
- return getMemberReports(memberId, null);
- }
- /**
- * 获取成员的报告列表(家庭隔离)。
- *
- * @param memberId 成员ID(兼容 users.id / family_members.id)
- * @param viewerFamilyId 查看者家庭ID(来自 JWT)。非空时以其为家庭边界,
- * 与 reassignReport 的权限校验保持一致,避免列表展示
- * 的报告在「重新分配归属」时被判为「报告不属于该家庭」。
- */
- public List<HealthReport> getMemberReports(Long memberId, Long viewerFamilyId) {
- if (memberId == null) {
- return Collections.emptyList();
- }
- Set<Long> ids = new HashSet<>();
- ids.add(memberId);
- Long memberFamilyId = null;
- try {
- FamilyMember fm = familyMemberMapper.selectById(memberId);
- if (fm != null) {
- ids.add(fm.getId());
- if (fm.getUserId() != null) {
- ids.add(fm.getUserId());
- }
- memberFamilyId = fm.getFamilyId();
- }
- // memberId 可能是 users.id,反查其关联的 family_members 行
- List<FamilyMember> linked = familyMemberMapper.selectList(
- new LambdaQueryWrapper<FamilyMember>().eq(FamilyMember::getUserId, memberId));
- for (FamilyMember item : linked) {
- ids.add(item.getId());
- if (memberFamilyId == null) {
- memberFamilyId = item.getFamilyId();
- }
- }
- } catch (Exception e) {
- log.warn("归一家庭成员ID失败 memberId={}: {}", memberId, e.getMessage());
- }
- // 家庭隔离:报告必须归属该家庭成员。列表侧必须与权限侧(reassignReport)遵守同一条家庭边界。
- // 查看者家庭(JWT)优先,与权限校验一致;为空时退回成员家庭。
- // 修复:此前以成员家庭为边界,当成员为跨家庭 users.id(多家庭)时与查看者家庭不一致,
- // 小程序列表展示后可点「取消归属」,却在 reassignReport 处抛「报告不属于该家庭」。
- Long boundaryFamilyId = (viewerFamilyId != null) ? viewerFamilyId : memberFamilyId;
- if (boundaryFamilyId == null) {
- log.warn("无法解析家庭边界,返回空报告列表 memberId={}", memberId);
- return Collections.emptyList();
- }
- // 严格绑定:经 reassignReport 取消归属(subject_id 被清空)的报告不在此列出,
- // 家庭「全部」视图 getFamilyReports 仍会展示它们。
- return healthReportMapper.selectList(
- new LambdaQueryWrapper<HealthReport>()
- .in(HealthReport::getSubjectId, ids)
- .eq(HealthReport::getFamilyId, boundaryFamilyId)
- .eq(HealthReport::getStatus, "active")
- .orderByDesc(HealthReport::getCreatedAt)
- );
- }
- /**
- * 获取某份报告的所有指标
- */
- public List<HealthIndicator> getReportIndicators(Long reportId) {
- return healthIndicatorMapper.selectList(
- new LambdaQueryWrapper<HealthIndicator>()
- .eq(HealthIndicator::getReportId, reportId)
- .orderByAsc(HealthIndicator::getSortOrder)
- );
- }
- /**
- * 计算孩子身体维度得分 (0-100)
- * <p>
- * 数据源优先级:
- * 1. 最新的健康检测报告 overallScore(如果1年内有效)
- * 2. 多份报告加权平均(如有体检报告+菌群报告)
- * 3. 无报告时返回null(由调用方决定降级策略)
- *
- * @param childId 孩子ID
- * @return 身体维度得分 (0-100), null表示无数据
- */
- public Integer calculateBodyScore(Long childId) {
- List<HealthReport> reports = healthReportMapper.selectList(
- new LambdaQueryWrapper<HealthReport>()
- .and(w -> w.eq(HealthReport::getSubjectId, childId)
- .or(w2 -> w2.isNull(HealthReport::getSubjectId)
- .eq(HealthReport::getUserId, childId)))
- .eq(HealthReport::getStatus, "active")
- .orderByDesc(HealthReport::getReportDate)
- );
- if (reports.isEmpty()) {
- return null;
- }
- // 取最新一份有overallScore的报告
- for (HealthReport r : reports) {
- if (r.getOverallScore() != null) {
- return clampScore(r.getOverallScore());
- }
- }
- // 所有报告都没有总分时,取子分值的加权平均
- int totalWeight = 0;
- int weightedSum = 0;
- int reportCount = reports.size();
- for (int i = 0; i < reportCount; i++) {
- HealthReport r = reports.get(i);
- int weight = reportCount - i;
- if (r.getOverallScore() != null) {
- weightedSum += r.getOverallScore() * weight;
- totalWeight += weight;
- }
- if (r.getGutHealthScore() != null) {
- weightedSum += r.getGutHealthScore() * weight;
- totalWeight += weight;
- }
- if (r.getChronicDiseaseScore() != null) {
- weightedSum += r.getChronicDiseaseScore() * weight;
- totalWeight += weight;
- }
- if (r.getNutritionScore() != null) {
- weightedSum += r.getNutritionScore() * weight;
- totalWeight += weight;
- }
- }
- if (totalWeight == 0) return null;
- return clampScore(weightedSum / totalWeight);
- }
- /**
- * 获取孩子身体维度的数据详情(健康指标解读)
- */
- public List<HealthIndicator> getBodyDetail(Long userId) {
- HealthReport latest = getLatestReport(userId);
- if (latest == null) return Collections.emptyList();
- return getReportIndicators(latest.getId());
- }
- /**
- * 将报告标记为已归档
- */
- @Transactional
- public void archiveReport(Long reportId) {
- HealthReport report = healthReportMapper.selectById(reportId);
- if (report != null) {
- report.setStatus("archived");
- report.setUpdatedAt(new Date());
- healthReportMapper.updateById(report);
- }
- }
- /**
- * 批量归档报告(软删除)— 逐个归档,返回实际归档成功的数量
- */
- @Transactional
- public int archiveReportsBatch(List<Long> reportIds) {
- if (reportIds == null || reportIds.isEmpty()) {
- return 0;
- }
- int archived = 0;
- for (Long reportId : reportIds) {
- if (reportId == null) continue;
- HealthReport report = healthReportMapper.selectById(reportId);
- if (report != null && !"archived".equals(report.getStatus())) {
- report.setStatus("archived");
- report.setUpdatedAt(new Date());
- healthReportMapper.updateById(report);
- archived++;
- }
- }
- return archived;
- }
- // ======================== 菌群+疾病风险专数据 ========================
- /**
- * 获取报告的菌群数据
- */
- public List<HealthGutFlora> getGutFloraByReportId(Long reportId) {
- return healthGutFloraMapper.selectList(
- new LambdaQueryWrapper<HealthGutFlora>()
- .eq(HealthGutFlora::getReportId, reportId)
- .orderByAsc(HealthGutFlora::getSortOrder)
- );
- }
- /**
- * 保存菌群数据
- */
- @Transactional
- public void saveGutFlora(Long reportId, List<HealthGutFlora> floraList) {
- if (floraList == null || floraList.isEmpty()) return;
- for (int i = 0; i < floraList.size(); i++) {
- HealthGutFlora item = floraList.get(i);
- item.setReportId(reportId);
- item.setSortOrder(i);
- item.setCreatedAt(new Date());
- healthGutFloraMapper.insert(item);
- }
- }
- /**
- * 获取报告的疾病风险评估
- */
- public List<HealthDiseaseRisk> getDiseaseRisksByReportId(Long reportId) {
- return healthDiseaseRiskMapper.selectList(
- new LambdaQueryWrapper<HealthDiseaseRisk>()
- .eq(HealthDiseaseRisk::getReportId, reportId)
- );
- }
- /**
- * 保存疾病风险评估
- */
- @Transactional
- public void saveDiseaseRisks(Long reportId, List<HealthDiseaseRisk> riskList) {
- if (riskList == null || riskList.isEmpty()) return;
- for (HealthDiseaseRisk item : riskList) {
- item.setReportId(reportId);
- item.setCreatedAt(new Date());
- healthDiseaseRiskMapper.insert(item);
- }
- }
- /**
- * 获取身体维度异常指标(身体页面"指标告警"专供)
- * 返回:疾病风险(非低风险) + 主要营养评估异常 + 氨基酸评估异常
- * 正常指标不显示,减少信息噪音
- */
- public Map<String, Object> getBodyAbnormalIndicators(Long memberId) {
- HealthReport latest = getLatestReport(memberId);
- Map<String, Object> result = new HashMap<>();
- result.put("diseaseRisks", Collections.emptyList());
- result.put("nutritionAbnormal", Collections.emptyList());
- result.put("aminoAcidAbnormal", Collections.emptyList());
- result.put("hasReport", latest != null);
- if (latest == null) return result;
- // 疾病风险:排除低风险
- List<HealthDiseaseRisk> allRisks = getDiseaseRisksByReportId(latest.getId());
- List<Map<String, Object>> abnormalRisks = new ArrayList<>();
- for (HealthDiseaseRisk r : allRisks) {
- if (r.getRiskLevel() != null && !"低风险".equals(r.getRiskLevel())) {
- Map<String, Object> item = new HashMap<>();
- item.put("name", r.getDiseaseName());
- item.put("value", r.getRiskValue());
- item.put("level", r.getRiskLevel());
- abnormalRisks.add(item);
- }
- }
- result.put("diseaseRisks", abnormalRisks);
- // 主要营养评估 + 氨基酸评估:只保留异常状态
- List<HealthIndicator> allIndicators = getReportIndicators(latest.getId());
- List<Map<String, Object>> nutritionAbnormal = new ArrayList<>();
- List<Map<String, Object>> aminoAcidAbnormal = new ArrayList<>();
- for (HealthIndicator ind : allIndicators) {
- String category = ind.getCategory();
- String status = ind.getStatus();
- if (status == null || "正常".equals(status) || "normal".equalsIgnoreCase(status)
- || "低风险".equals(status)) continue;
- Map<String, Object> item = new HashMap<>();
- item.put("name", ind.getIndicatorName());
- item.put("value", ind.getIndicatorValue());
- item.put("unit", ind.getUnit());
- item.put("status", status);
- item.put("refRange", ind.getRefRange());
- if ("主要营养评估".equals(category)) {
- nutritionAbnormal.add(item);
- } else if ("氨基酸评估".equals(category)) {
- aminoAcidAbnormal.add(item);
- }
- }
- result.put("nutritionAbnormal", nutritionAbnormal);
- result.put("aminoAcidAbnormal", aminoAcidAbnormal);
- return result;
- }
- /**
- * 获取报告完整详情(含指标明细 + 菌群数据 + 疾病风险评估)
- */
- public Map<String, Object> getReportDetail(Long reportId) {
- Map<String, Object> detail = new HashMap<>();
- HealthReport report = healthReportMapper.selectById(reportId);
- detail.put("report", report);
- if (report != null) {
- List<HealthIndicator> indicators = getReportIndicators(reportId);
- detail.put("indicators", indicators);
- List<HealthGutFlora> gutFlora = getGutFloraByReportId(reportId);
- // 读回归一化:兼容历史数据(归一化前入库的 category='菌X构成' + level='门/纲/...')
- normalizeGutFloraRead(gutFlora);
- detail.put("gutFlora", gutFlora);
- List<HealthDiseaseRisk> diseaseRisks = getDiseaseRisksByReportId(reportId);
- detail.put("diseaseRisks", diseaseRisks);
- // 通用展示块(blocks 为空时前端回退旧字段)
- detail.put("blocks", reportBlockService.getBlocks(report.getReportType(), reportId));
- // 报告解读(前端 gut-flora-detail 读取 reportInterpretation.gutAgeAssessment)
- Map<String, Object> interp = new HashMap<>();
- StringBuilder sb = new StringBuilder();
- if (report.getGutAge() != null && !report.getGutAge().isEmpty()) {
- sb.append("您的肠道年龄为 ").append(report.getGutAge()).append(" 岁");
- }
- if (report.getGutType() != null && !report.getGutType().isEmpty()) {
- if (sb.length() > 0) sb.append(",");
- sb.append("肠道类型为 ").append(report.getGutType());
- }
- if (sb.length() > 0) {
- interp.put("gutAgeAssessment", sb.toString());
- detail.put("reportInterpretation", interp);
- }
- // 编辑回填: payload_json 快照 → 嵌套 payload 结构
- if (report.getPayloadJson() != null && !report.getPayloadJson().isEmpty()) {
- try {
- detail.put("payload", objectMapper.readValue(report.getPayloadJson(), ParsedReportPayload.Payload.class));
- } catch (Exception e) {
- log.warn("解析payload_json失败 reportId={}: {}", reportId, e.getMessage());
- }
- }
- }
- return detail;
- }
- /**
- * 获取报告 payload(编辑回填用,按需加载)
- */
- public Map<String, Object> getReportPayload(Long reportId) {
- Map<String, Object> result = new HashMap<>();
- HealthReport report = healthReportMapper.selectById(reportId);
- if (report == null) {
- return result;
- }
- if (report.getPayloadJson() != null && !report.getPayloadJson().isEmpty()) {
- try {
- result.put("payload", objectMapper.readValue(report.getPayloadJson(), ParsedReportPayload.Payload.class));
- } catch (Exception e) {
- log.warn("解析payload_json失败 reportId={}: {}", reportId, e.getMessage());
- }
- }
- return result;
- }
- private int clampScore(Integer score) {
- if (score == null) return 0;
- return Math.min(Math.max(score, 0), 100);
- }
- /**
- * 根据姓名模糊匹配家庭成员(优先匹配 FamilyMember,其次匹配 User)
- *
- * @param name 从PDF提取的姓名
- * @param familyId 家庭ID
- * @return 匹配到的成员ID(FamilyMember.id 或 User.id),未匹配返回 null
- */
- public Long matchFamilyMemberByName(String name, Long familyId) {
- if (name == null || name.trim().isEmpty() || familyId == null) {
- return null;
- }
- String cleanName = name.trim().replaceAll("\\s+", "");
- if (cleanName.isEmpty()) {
- return null;
- }
- // 第1轮:匹配 FamilyMember 表(孩子昵称)
- Long childId = matchFamilyMemberByNameInternal(cleanName, familyId);
- if (childId != null) {
- return childId;
- }
- // 第2轮:匹配 User 表(父母的 realName / nickname)
- Long userId = matchUserByName(cleanName, familyId);
- if (userId != null) {
- return userId;
- }
- log.warn("无法匹配姓名[{}]到家庭{}的成员", name, familyId);
- return null;
- }
- /**
- * 在 FamilyMember 表中匹配姓名
- */
- private Long matchFamilyMemberByNameInternal(String cleanName, Long familyId) {
- List<FamilyMember> children = familyMemberMapper.selectList(
- new LambdaQueryWrapper<FamilyMember>()
- .eq(FamilyMember::getFamilyId, familyId)
- );
- if (children == null || children.isEmpty()) {
- return null;
- }
- // 精确匹配
- for (FamilyMember child : children) {
- if (child.getNickname() != null) {
- String nn = child.getNickname().trim().replaceAll("\\s+", "");
- if (nn.equals(cleanName)) {
- log.info("FamilyMember 姓名精确匹配: {} -> childId={}", cleanName, child.getId());
- return child.getId();
- }
- }
- }
- // 模糊包含匹配
- for (FamilyMember child : children) {
- if (child.getNickname() != null) {
- String nn = child.getNickname();
- if (cleanName.contains(nn) || nn.contains(cleanName)) {
- log.info("FamilyMember 姓名模糊匹配: {} -> childId={}", cleanName, child.getId());
- return child.getId();
- }
- }
- }
- // fuzzyMatch 匹配
- for (FamilyMember child : children) {
- if (child.getNickname() != null && fuzzyMatch(cleanName, child.getNickname())) {
- log.info("FamilyMember 姓名fuzzy匹配: {} -> childId={}", cleanName, child.getId());
- return child.getId();
- }
- }
- return null;
- }
- /**
- * 在 User 表中匹配姓名(realName / nickname)
- */
- private Long matchUserByName(String cleanName, Long familyId) {
- List<User> users = userMapper.selectList(
- new LambdaQueryWrapper<User>()
- .eq(User::getFamilyId, familyId)
- );
- if (users == null || users.isEmpty()) {
- return null;
- }
- for (User user : users) {
- // 优先匹配 realName,其次 nickname
- String nameToMatch = user.getRealName() != null ? user.getRealName() : user.getNickname();
- if (nameToMatch == null) continue;
- String nm = nameToMatch.trim().replaceAll("\\s+", "");
- if (nm.equals(cleanName)) {
- log.info("User 姓名精确匹配: {} -> userId={}", cleanName, user.getId());
- return resolveSubjectToMemberId(user.getId(), familyId);
- }
- }
- for (User user : users) {
- String nameToMatch = user.getRealName() != null ? user.getRealName() : user.getNickname();
- if (nameToMatch == null) continue;
- if (cleanName.contains(nameToMatch) || nameToMatch.contains(cleanName)) {
- log.info("User 姓名模糊匹配: {} -> userId={}", cleanName, user.getId());
- return resolveSubjectToMemberId(user.getId(), familyId);
- }
- }
- for (User user : users) {
- String nameToMatch = user.getRealName() != null ? user.getRealName() : user.getNickname();
- if (nameToMatch == null) continue;
- if (fuzzyMatch(cleanName, nameToMatch)) {
- log.info("User 姓名fuzzy匹配: {} -> userId={}", cleanName, user.getId());
- return resolveSubjectToMemberId(user.getId(), familyId);
- }
- }
- return null;
- }
- /**
- * 模糊匹配:检查 str1 是否大致包含 str2(用于处理生僻字等情况)
- */
- private boolean fuzzyMatch(String str1, String str2) {
- if (str1 == null || str2 == null) return false;
- if (str1.contains(str2) || str2.contains(str1)) return true;
- // 检查 str1 的前2个字符是否在 str2 中(处理"王小明"匹配"小明"的情况)
- if (str1.length() >= 2) {
- String prefix = str1.substring(0, 2);
- if (str2.contains(prefix)) return true;
- }
- // 检查 str2 的前2个字符是否在 str1 中
- if (str2.length() >= 2) {
- String prefix = str2.substring(0, 2);
- if (str1.contains(prefix)) return true;
- }
- return false;
- }
- public Long matchFamilyMemberIdByName(String name, Long familyId) {
- if (name == null || familyId == null) return null;
- List<FamilyMember> children = familyMemberMapper.selectList(
- new LambdaQueryWrapper<FamilyMember>()
- .eq(FamilyMember::getFamilyId, familyId)
- );
- for (FamilyMember c : children) {
- if (name.contains(c.getNickname()) || (c.getNickname() != null && c.getNickname().contains(name))) {
- return c.getId();
- }
- }
- return null;
- }
- /**
- * 多字段匹配家庭成员 — 基于姓名+性别+年龄综合评分
- * 返回置信度最高的匹配结果及候选列表
- */
- public MemberMatchResult matchMemberByProfile(String name, String gender, Integer age, Long familyId) {
- MemberMatchResult result = new MemberMatchResult();
- result.setCandidates(new ArrayList<>());
- if (familyId == null) {
- result.setMatchedMemberId(null);
- result.setConfidence(0.0);
- result.setMatchLabel("no_match");
- return result;
- }
- // 收集所有候选成员(FamilyMember + User)
- List<FamilyMember> familyMembers = familyMemberMapper.selectList(
- new LambdaQueryWrapper<FamilyMember>()
- .eq(FamilyMember::getFamilyId, familyId)
- );
- List<User> users = userMapper.selectList(
- new LambdaQueryWrapper<User>()
- .eq(User::getFamilyId, familyId)
- );
- List<MemberMatchResult.MemberCandidate> allCandidates = new ArrayList<>();
- // 评估 FamilyMember
- if (familyMembers != null) {
- for (FamilyMember fm : familyMembers) {
- MemberMatchResult.MemberCandidate candidate = new MemberMatchResult.MemberCandidate();
- candidate.setId(fm.getId());
- candidate.setNickname(fm.getNickname());
- candidate.setName(fm.getNickname());
- candidate.setGender(fm.getGender());
- candidate.setAge(fm.getAge());
- double score = 0.0;
- List<String> matchedFields = new ArrayList<>();
- if (name != null && !name.trim().isEmpty()) {
- double nameScore = computeNameSimilarity(name.trim(), fm.getNickname());
- score += nameScore * 0.6;
- if (nameScore > 0.5) matchedFields.add("name");
- }
- if (gender != null && !gender.trim().isEmpty()) {
- String dbGender = fm.getGender();
- if ("male".equalsIgnoreCase(gender) && "male".equals(dbGender)) {
- score += 0.2;
- matchedFields.add("gender");
- } else if ("female".equalsIgnoreCase(gender) && "female".equals(dbGender)) {
- score += 0.2;
- matchedFields.add("gender");
- }
- }
- if (age != null && age > 0) {
- int dbAge = fm.getAge();
- if (dbAge > 0) {
- int diff = Math.abs(age - dbAge);
- if (diff <= 1) {
- score += 0.2;
- matchedFields.add("age");
- } else if (diff <= 3) {
- score += 0.1;
- matchedFields.add("age");
- } else if (diff <= 5) {
- score += 0.05;
- matchedFields.add("age");
- }
- }
- }
- score = Math.min(score, 1.0);
- candidate.setSimilarityScore(Math.round(score * 1000.0) / 1000.0);
- candidate.setMatchFields(String.join(",", matchedFields));
- if (score > 0) {
- allCandidates.add(candidate);
- }
- }
- }
- // 评估 User(父母)
- if (users != null) {
- for (User u : users) {
- String displayName = u.getRealName() != null ? u.getRealName() : u.getNickname();
- if (displayName == null || displayName.trim().isEmpty()) continue;
- MemberMatchResult.MemberCandidate candidate = new MemberMatchResult.MemberCandidate();
- candidate.setId(u.getId());
- candidate.setName(displayName);
- candidate.setGender(u.getGender());
- candidate.setNickname(u.getNickname());
- // 从生日计算年龄
- Integer userAge = null;
- if (u.getBirthday() != null) {
- java.time.LocalDate birth = u.getBirthday().toInstant()
- .atZone(java.time.ZoneId.systemDefault()).toLocalDate();
- userAge = java.time.Period.between(birth, java.time.LocalDate.now()).getYears();
- }
- candidate.setAge(userAge);
- double score = 0.0;
- List<String> matchedFields = new ArrayList<>();
- if (name != null && !name.trim().isEmpty()) {
- double nameScore = computeNameSimilarity(name.trim(), displayName);
- score += nameScore * 0.6;
- if (nameScore > 0.5) matchedFields.add("name");
- }
- if (gender != null && !gender.trim().isEmpty()) {
- String dbGender = u.getGender();
- if ("male".equalsIgnoreCase(gender) && "male".equals(dbGender)) {
- score += 0.2;
- matchedFields.add("gender");
- } else if ("female".equalsIgnoreCase(gender) && "female".equals(dbGender)) {
- score += 0.2;
- matchedFields.add("gender");
- }
- }
- if (age != null && age > 0 && userAge != null && userAge > 0) {
- int diff = Math.abs(age - userAge);
- if (diff <= 2) {
- score += 0.2;
- matchedFields.add("age");
- } else if (diff <= 5) {
- score += 0.1;
- matchedFields.add("age");
- }
- }
- score = Math.min(score, 1.0);
- candidate.setSimilarityScore(Math.round(score * 1000.0) / 1000.0);
- candidate.setMatchFields(String.join(",", matchedFields));
- if (score > 0) {
- allCandidates.add(candidate);
- }
- }
- }
- // 按相似度降序排序
- allCandidates.sort(Comparator.comparing(MemberMatchResult.MemberCandidate::getSimilarityScore).reversed());
- // 取前5个候选
- int limit = Math.min(5, allCandidates.size());
- result.setCandidates(allCandidates.subList(0, limit));
- if (limit > 0 && allCandidates.get(0).getSimilarityScore() >= 0.6) {
- result.setMatchedMemberId(allCandidates.get(0).getId());
- result.setConfidence(allCandidates.get(0).getSimilarityScore());
- result.setMatchLabel(allCandidates.get(0).getSimilarityScore() >= 0.9 ? "exact" : "profile_match");
- } else {
- result.setMatchedMemberId(null);
- result.setConfidence(limit > 0 ? allCandidates.get(0).getSimilarityScore() : 0.0);
- result.setMatchLabel("no_match");
- }
- return result;
- }
- /**
- * 计算两个姓名的相似度(0.0-1.0)
- */
- private double computeNameSimilarity(String reportName, String dbName) {
- if (reportName == null || dbName == null) return 0.0;
- String a = reportName.trim().toLowerCase();
- String b = dbName.trim().toLowerCase();
- // 精确匹配
- if (a.equals(b)) return 1.0;
- // 互相包含
- if (a.contains(b) || b.contains(a)) return 0.85;
- // 前2字匹配
- if (a.length() >= 2 && b.length() >= 2) {
- String aPrefix = a.substring(0, 2);
- String bPrefix = b.substring(0, 2);
- if (aPrefix.equals(bPrefix)) return 0.7;
- }
- // 最后一个字相同
- if (a.length() > 0 && b.length() > 0) {
- if (String.valueOf(a.charAt(a.length() - 1)).equals(String.valueOf(b.charAt(b.length() - 1)))) {
- return 0.4;
- }
- }
- return 0.0;
- }
- /**
- * Get mind-related indicators from the latest gut microbiome report.
- * Filters indicators by categories related to mood/emotion:
- * - 神经递质与激素 (neurotransmitters & hormones)
- * - 短链脂肪酸 (short-chain fatty acids, gut-brain axis)
- *
- * @param childId the child's ID
- * @return list of mood-related HealthIndicator
- */
- public List<HealthIndicator> getMindRelatedIndicators(Long childId) {
- // Get latest gut flora report for this child
- LambdaQueryWrapper<HealthReport> reportWrapper = new LambdaQueryWrapper<>();
- reportWrapper.eq(HealthReport::getUserId, childId);
- reportWrapper.eq(HealthReport::getReportType, "gut_flora");
- reportWrapper.orderByDesc(HealthReport::getCreatedAt);
- reportWrapper.last("LIMIT 1");
- SortUtil.applySort(reportWrapper);
- HealthReport report = healthReportMapper.selectOne(reportWrapper);
- if (report == null) {
- return Collections.emptyList();
- }
- // Get indicators matching mood-related categories
- LambdaQueryWrapper<HealthIndicator> indicatorWrapper = new LambdaQueryWrapper<>();
- indicatorWrapper.eq(HealthIndicator::getReportId, report.getId());
- indicatorWrapper.in(HealthIndicator::getCategory, "神经递质与激素", "短链脂肪酸");
- indicatorWrapper.orderByAsc(HealthIndicator::getSortOrder);
- SortUtil.applySort(indicatorWrapper);
- return healthIndicatorMapper.selectList(indicatorWrapper);
- }
- /**
- * 情绪相关指标关键词(知识库"神经递质及激素"分类中与情绪状态直接相关的指标)
- * 体现"心生身"概念:情绪状态通过神经递质反映在身体上
- */
- private static final Set<String> EMOTION_INDICATOR_KEYWORDS = new HashSet<>(Arrays.asList(
- "血清素", "5-HT", "多巴胺", "GABA", "γ-氨基丁酸", "色氨酸", "褪黑素", "皮质醇"
- ));
- /**
- * 认知相关指标关键词(与学习记忆、智力、注意力相关的肠脑轴指标)
- * 含谷氨酸(学习记忆)、多巴胺(专注力,用户确认)、色氨酸(褪黑素前体→睡眠→记忆力)
- */
- private static final Set<String> COGNITION_INDICATOR_KEYWORDS = new HashSet<>(Arrays.asList(
- "谷氨酸", "Glutamate", "乙酰胆碱", "多巴胺", "色氨酸", "丁酸", "丙酸", "乙酸", "组胺", "短链脂肪酸"
- ));
- /**
- * 根据关键词过滤指标列表
- */
- private List<HealthIndicator> filterIndicatorsByKeywords(List<HealthIndicator> indicators, Set<String> keywords) {
- if (indicators == null || indicators.isEmpty()) return Collections.emptyList();
- List<HealthIndicator> result = new ArrayList<>();
- for (HealthIndicator ind : indicators) {
- if (ind.getIndicatorName() == null) continue;
- String name = ind.getIndicatorName();
- for (String kw : keywords) {
- if (name.contains(kw)) {
- result.add(ind);
- break;
- }
- }
- }
- return result;
- }
- /**
- * 获取与情绪状态相关的菌群报告指标(心页面"情绪与肠胃")
- * 体现"心生身":从菌群报告提取与情绪调节直接相关的神经递质指标
- *
- * @param childId the child's ID
- * @return list of emotion-related HealthIndicator
- */
- public List<HealthIndicator> getEmotionIndicators(Long childId) {
- List<HealthIndicator> all = getMindRelatedIndicators(childId);
- return filterIndicatorsByKeywords(all, EMOTION_INDICATOR_KEYWORDS);
- }
- /**
- * 获取与认知/智力相关的菌群报告指标(智页面"肠胃与认知")
- * 体现"肠脑轴":肠道菌群代谢产物影响认知功能
- *
- * @param childId the child's ID
- * @return list of cognition-related HealthIndicator
- */
- public List<HealthIndicator> getCognitionIndicators(Long childId) {
- // Get latest gut flora report for this child
- LambdaQueryWrapper<HealthReport> reportWrapper = new LambdaQueryWrapper<>();
- reportWrapper.eq(HealthReport::getUserId, childId);
- reportWrapper.eq(HealthReport::getReportType, "gut_flora");
- reportWrapper.orderByDesc(HealthReport::getCreatedAt);
- reportWrapper.last("LIMIT 1");
- SortUtil.applySort(reportWrapper);
- HealthReport report = healthReportMapper.selectOne(reportWrapper);
- if (report == null) {
- return Collections.emptyList();
- }
- LambdaQueryWrapper<HealthIndicator> indicatorWrapper = new LambdaQueryWrapper<>();
- indicatorWrapper.eq(HealthIndicator::getReportId, report.getId());
- indicatorWrapper.in(HealthIndicator::getCategory, "神经递质与激素", "短链脂肪酸");
- indicatorWrapper.orderByAsc(HealthIndicator::getSortOrder);
- SortUtil.applySort(indicatorWrapper);
- List<HealthIndicator> all = healthIndicatorMapper.selectList(indicatorWrapper);
- return filterIndicatorsByKeywords(all, COGNITION_INDICATOR_KEYWORDS);
- }
- /**
- * Get gut-emotion insight summary based on neurotransmitter and SCFA indicators.
- *
- * @param childId the child's ID
- * @return map with summary text and indicator highlights
- */
- public Map<String, Object> getGutEmotionInsight(Long childId) {
- List<HealthIndicator> indicators = getEmotionIndicators(childId);
- if (indicators.isEmpty()) {
- Map<String, Object> empty = new HashMap<>();
- empty.put("summary", "暂无菌群报告数据");
- empty.put("indicators", Collections.emptyList());
- return empty;
- }
- Map<String, Object> result = new HashMap<>();
- result.put("indicators", indicators);
- // Simple summary based on indicator statuses
- long abnormalCount = indicators.stream()
- .filter(i -> "abnormal".equals(i.getStatus()) || "偏高".equals(i.getStatus()) || "偏低".equals(i.getStatus()))
- .count();
- if (abnormalCount == 0) {
- result.put("summary", "神经递质指标正常,肠道情绪状态良好");
- } else if (abnormalCount <= 2) {
- result.put("summary", "部分神经递质指标略有波动,可关注相关营养补充");
- } else {
- result.put("summary", "多项神经递质指标异常,建议咨询专业医生或营养师");
- }
- return result;
- }
- public com.baomidou.mybatisplus.extension.plugins.pagination.Page<Map<String, Object>> listAllReportsForAdmin(int page, int size) {
- com.baomidou.mybatisplus.extension.plugins.pagination.Page<HealthReport> p =
- new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page, size);
- LambdaQueryWrapper<HealthReport> wrapper = new LambdaQueryWrapper<HealthReport>()
- .eq(HealthReport::getStatus, "active")
- .orderByDesc(HealthReport::getReportDate);
- SortUtil.applySort(wrapper);
- p = healthReportMapper.selectPage(p, wrapper);
- List<Map<String, Object>> records = new ArrayList<>();
- for (HealthReport r : p.getRecords()) {
- Map<String, Object> m = new HashMap<>();
- m.put("id", r.getId());
- m.put("reportType", r.getReportType());
- m.put("overallScore", r.getOverallScore());
- m.put("gutHealthScore", r.getGutHealthScore());
- m.put("nutritionScore", r.getNutritionScore());
- m.put("reportDate", r.getReportDate());
- m.put("personName", r.getPersonName());
- m.put("fileUrl", r.getFileUrl());
- m.put("gutAge", r.getGutAge());
- m.put("gutType", r.getGutType());
- Long subjectId = r.getSubjectId() != null ? r.getSubjectId() : r.getUserId();
- m.put("memberId", subjectId);
- if (subjectId != null) {
- User user = userMapper.selectById(subjectId);
- if (user != null) {
- m.put("childName", user.getRealName() != null ? user.getRealName() : user.getNickname());
- }
- }
- if (m.get("childName") == null) {
- m.put("childName", r.getPersonName());
- }
- records.add(m);
- }
- com.baomidou.mybatisplus.extension.plugins.pagination.Page<Map<String, Object>> result =
- new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page, size);
- result.setRecords(records);
- result.setTotal(p.getTotal());
- result.setPages(p.getPages());
- return result;
- }
- public HealthReport getReportById(Long reportId) {
- return healthReportMapper.selectById(reportId);
- }
- /**
- * 仅更新报告的 payload_json 列(用于食材重新解析回填,只覆盖 foods 键,不影响其他字段)。
- * 使用 JdbcTemplate 直改单列,避免 MyBatis-Plus NOT_NULL 策略下其他 null 字段被误覆盖。
- */
- public void updatePayloadJson(Long reportId, String payloadJson) {
- jdbcTemplate.update("UPDATE health_reports SET payload_json = ?, updated_at = NOW() WHERE id = ?",
- payloadJson, reportId);
- }
- /**
- * 归一化 subject_id 到 family_members.id 口径(A方案:报告统一挂 member_id)。
- *
- * <p>历史 subject_id 混用 users.id / family_members.id:确认/采集/编辑各链路都可能
- * 把 users.id 直接写入。本方法把"users.id 或跨家庭成员 id"回查为指定家庭内的
- * family_members.id,保证报告、维度分、食材推荐全部按成员卡统一挂接,
- * 成员迁移(改 family_id 保留 member_id)后自动跟随。</p>
- *
- * @param subjectId 待归一化的被检测人 id(可能是 users.id 或 family_members.id)
- * @param familyId 报告所属家庭
- * @return 该家庭内的 family_members.id;无法解析时原样返回
- */
- public Long resolveSubjectToMemberId(Long subjectId, Long familyId) {
- if (subjectId == null || familyId == null) {
- return subjectId;
- }
- try {
- // 已是本家庭成员 id -> 原样返回
- FamilyMember direct = familyMemberMapper.selectById(subjectId);
- if (direct != null && familyId.equals(direct.getFamilyId())) {
- return subjectId;
- }
- // subjectId 是 users.id -> 回查该用户在本家庭的成员行
- User u = userMapper.selectById(subjectId);
- if (u != null) {
- FamilyMember byUser = familyMemberMapper.selectOne(
- new LambdaQueryWrapper<FamilyMember>()
- .eq(FamilyMember::getFamilyId, familyId)
- .eq(FamilyMember::getUserId, subjectId)
- .last("LIMIT 1"));
- if (byUser != null) {
- return byUser.getId();
- }
- }
- // subjectId 是跨家庭 member 行 -> 按该成员的 user_id 回查本家庭
- if (direct != null && direct.getUserId() != null) {
- FamilyMember same = familyMemberMapper.selectOne(
- new LambdaQueryWrapper<FamilyMember>()
- .eq(FamilyMember::getFamilyId, familyId)
- .eq(FamilyMember::getUserId, direct.getUserId())
- .last("LIMIT 1"));
- if (same != null) {
- return same.getId();
- }
- }
- } catch (Exception e) {
- log.warn("subjectId归一化失败 subjectId={}, familyId={}: {}", subjectId, familyId, e.getMessage());
- }
- return subjectId;
- }
- public void bindReportToMember(Long reportId, Long subjectId) {
- // 使用 JdbcTemplate 直接 UPDATE,避免 MyBatis-Plus NOT_NULL 策略导致 NULL 值不更新
- String sql = "UPDATE health_reports SET subject_id = ? WHERE id = ?";
- Long previousSubjectId = null;
- // A方案:写库前归一化为 family_members.id 口径
- if (subjectId != null && subjectId > 0) {
- HealthReport rep = healthReportMapper.selectById(reportId);
- if (rep != null) {
- previousSubjectId = rep.getSubjectId();
- Long resolved = resolveSubjectToMemberId(subjectId, rep.getFamilyId());
- if (resolved != null && !resolved.equals(subjectId)) {
- log.info("subjectId归一化 reportId={}, {} -> {}", reportId, subjectId, resolved);
- }
- subjectId = resolved;
- }
- }
- int updatedRows = jdbcTemplate.update(sql, subjectId, reportId);
- if (updatedRows == 0) {
- log.warn("reportId={}, subjectId={} 更新失败 - 报告不存在", reportId, subjectId);
- return;
- }
- log.info("已绑定报告 {} 到被检测人 {}", reportId, subjectId);
- // G3(DS-2026-10-25-01):上传时未选成员 → NormalizeIndicatorPipeline 因 subjectId==null 静默跳过,
- // 仅 UPDATE subject_id 不会补写时序,用户会看到「绑了成员却没有历史」。
- // 只在「此前无归属」时补跑(即 G3 缺口),失败不阻断绑定;processReport 底层 upsert 幂等。
- if (previousSubjectId == null && subjectId != null) {
- try {
- HealthReport rep = healthReportMapper.selectById(reportId);
- if (rep != null && rep.getSubjectId() != null && rep.getPayloadJson() != null) {
- ParsedReportPayload.Payload payload =
- objectMapper.readValue(rep.getPayloadJson(), ParsedReportPayload.Payload.class);
- normalizeIndicatorPipeline.processReport(rep, payload);
- log.info("绑定成员后补写时序 reportId={}, subjectId={}", reportId, subjectId);
- }
- } catch (Exception e) {
- log.warn("绑定成员后补写时序失败 reportId={}: {}", reportId, e.getMessage());
- }
- }
- }
- /**
- * 重新分配报告归属
- *
- * <p>将报告从旧归属切换到新归属,或解除归属(subjectId 设为 null)。
- * 归属变更后自动重新计算五维能量维度和食材推荐指数。</p>
- *
- * <p>流程:</p>
- * <ol>
- * <li>权限检查:确保报告属于当前家庭</li>
- * <li>记录旧归属 subjectId</li>
- * <li>更新 health_reports.subject_id</li>
- * <li>若旧归属不为空:refreshFromGutReport + recalculateForUser</li>
- * <li>若新归属不为空:refreshFromGutReport + recalculateForUser</li>
- * <li>记录操作日志</li>
- * </ol>
- *
- * @param reportId 报告ID
- * @param newSubjectId 新的归属成员ID(null 表示解除绑定)
- * @param familyId 家庭ID,用于权限验证
- * @throws RuntimeException 当报告不存在或不属于该家庭时抛出
- */
- @Transactional
- public void reassignReport(Long reportId, Long newSubjectId, Long familyId) {
- HealthReport report = healthReportMapper.selectById(reportId);
- if (report == null) {
- throw new RuntimeException("报告不存在");
- }
-
- // 权限检查:确保报告属于该家庭(familyId 为空视为未加入家庭,防 NPE)
- if (familyId == null) {
- throw new RuntimeException("请先加入家庭");
- }
- if (!familyId.equals(report.getFamilyId())) {
- throw new RuntimeException("报告不属于该家庭");
- }
-
- Long oldSubjectId = report.getSubjectId();
-
- // 无实际变化则直接返回
- if (oldSubjectId != null && newSubjectId != null && oldSubjectId.equals(newSubjectId)) {
- return;
- }
-
- // 1. 更新 subject_id
- String updateSql = "UPDATE health_reports SET subject_id = ? WHERE id = ?";
- jdbcTemplate.update(updateSql, newSubjectId, reportId);
- log.info("报告 {} 归属已更新:oldSubjectId={}, newSubjectId={}", reportId, oldSubjectId, newSubjectId);
-
- // 2. 重算旧成员维度(如果原来有绑定)
- if (oldSubjectId != null) {
- dimensionScoreService.rebuildFromMemberReports(oldSubjectId);
- foodRecommendService.recalculateForUser(oldSubjectId);
- log.info("已重算旧归属成员 {} 的五维能量与食材推荐", oldSubjectId);
- }
-
- // 3. 重算新成员维度(如果有新归属)
- if (newSubjectId != null) {
- dimensionScoreService.refreshFromGutReport(newSubjectId, reportId);
- foodRecommendService.recalculateForUser(newSubjectId);
- log.info("已重算新归属成员 {} 的五维能量与食材推荐", newSubjectId);
- }
-
- log.info("报告归属变更: reportId={}, oldSubjectId={}, newSubjectId={}",
- reportId, oldSubjectId, newSubjectId);
- }
- /**
- * 获取家庭内所有未绑定归属的报告
- *
- * @param familyId 家庭ID
- * @return 未绑定归属的报告列表
- */
- /**
- * 获取严格绑定到该成员的有效报告(不含 subject_id 为空的未绑定报告)。
- * 用于重建 REPORT 来源维度分——与 getMemberReports 语义不同,
- * 后者会带出 user_id 匹配的未绑定报告,不适合参与维度分派生。
- */
- public List<HealthReport> getMemberBoundReports(Long memberId) {
- if (memberId == null) {
- return Collections.emptyList();
- }
- return healthReportMapper.selectList(
- new LambdaQueryWrapper<HealthReport>()
- .eq(HealthReport::getSubjectId, memberId)
- .eq(HealthReport::getStatus, "active")
- .orderByAsc(HealthReport::getReportDate)
- .orderByAsc(HealthReport::getId)
- );
- }
- public List<HealthReport> getUnlinkedReports(Long familyId) {
- return healthReportMapper.selectList(
- new LambdaQueryWrapper<HealthReport>()
- .eq(HealthReport::getFamilyId, familyId)
- .isNull(HealthReport::getSubjectId)
- .eq(HealthReport::getStatus, "active")
- .orderByDesc(HealthReport::getReportDate)
- );
- }
- public void addReportPermission(Long reportId, Long userId) {
- HealthReportPermission permission = new HealthReportPermission();
- permission.setReportId(reportId);
- permission.setUserId(userId);
- try {
- healthReportPermissionMapper.insert(permission);
- } catch (DuplicateKeyException e) {
- // already exists, ignore
- }
- }
- public void removeReportPermission(Long reportId, Long userId) {
- healthReportPermissionMapper.delete(
- new LambdaQueryWrapper<HealthReportPermission>()
- .eq(HealthReportPermission::getReportId, reportId)
- .eq(HealthReportPermission::getUserId, userId)
- );
- }
- public boolean hasReportPermission(Long reportId, Long userId) {
- return healthReportPermissionMapper.selectCount(
- new LambdaQueryWrapper<HealthReportPermission>()
- .eq(HealthReportPermission::getReportId, reportId)
- .eq(HealthReportPermission::getUserId, userId)
- ) > 0;
- }
- // ======================== 自动创建成员 + 审核 ========================
- /**
- * 从PDF身份信息自动创建家庭成员
- * 如果姓名已存在则返回已有成员ID;否则创建新成员并返回新ID
- */
- @Transactional(rollbackFor = Exception.class)
- public Long autoCreateMemberFromReport(String name, String gender, Integer age,
- Long familyId, String reportType,
- String fileUrl, String originalFilename,
- String payloadJson) {
- if (name == null || name.trim().isEmpty()) {
- throw new IllegalArgumentException("姓名不能为空");
- }
- if (familyId == null) {
- throw new IllegalArgumentException("familyId不能为空");
- }
- String cleanName = name.trim().replaceAll("\\s+", "");
- // 第1步:尝试匹配已有成员
- Long existingMemberId = matchFamilyMemberByName(cleanName, familyId);
- if (existingMemberId != null) {
- log.info("姓名[{}]已匹配到现有成员 id={}", cleanName, existingMemberId);
- return existingMemberId;
- }
- // 第2步:创建新家庭成员
- FamilyMember newMember = new FamilyMember();
- newMember.setFamilyId(familyId);
- newMember.setNickname(cleanName);
- newMember.setGender(gender);
- newMember.setShowToFamily(1);
- newMember.setTotalPoints(0);
- Date now = new Date();
- newMember.setCreatedAt(now);
- newMember.setUpdatedAt(now);
- // 如果有年龄,反推出生日期
- if (age != null && age > 0) {
- java.time.LocalDate birthDate = java.time.LocalDate.now()
- .minusYears(age)
- .minusDays(java.time.LocalDate.now().getDayOfYear() - 1);
- newMember.setBirthday(java.sql.Date.valueOf(birthDate));
- }
- familyMemberMapper.insert(newMember);
- log.info("已从健康报告自动创建新成员: id={}, name={}, gender={}, age={}, familyId={}",
- newMember.getId(), cleanName, gender, age, familyId);
- return newMember.getId();
- }
- /**
- * 审核确认报告 — 将草稿转为正式报告并绑定到指定成员
- */
- @Transactional(rollbackFor = Exception.class)
- public Map<String, Object> auditConfirm(Long draftId, Long subjectId, Long auditorId) {
- HealthReportDraft draft = getActiveDraftOrThrow(draftId);
- if (draft == null) {
- throw new IllegalArgumentException("草稿不存在或已过期");
- }
- try {
- ParsedReportPayload.Payload payload = objectMapper.readValue(
- draft.getPayloadJson(), ParsedReportPayload.Payload.class);
- Long matchedMemberId = subjectId != null ? subjectId
- : matchFamilyMemberByName(
- payload.getSummary() != null ? payload.getSummary().getPersonName() : null,
- draft.getFamilyId());
- HealthReport report = buildReportFromPayload(draft, payload, matchedMemberId);
- List<HealthIndicator> indicators = buildIndicatorsFromPayload(payload);
- List<HealthGutFlora> gutFlora = buildGutFloraFromPayload(payload);
- List<HealthDiseaseRisk> diseaseRisks = buildDiseaseRisksFromPayload(payload);
- HealthReport created = createReport(report, indicators, gutFlora, diseaseRisks);
- if (matchedMemberId != null) {
- bindReportToMember(created.getId(), matchedMemberId);
- }
- // 保存食材推荐数据
- saveFoodSuitability(created.getId(), payload.getFoods());
- markDraftConfirmed(draftId, created.getId());
- Map<String, Object> result = new HashMap<>();
- result.put("reportId", created.getId());
- result.put("subjectId", matchedMemberId);
- result.put("status", "confirmed");
- return result;
- } catch (Exception e) {
- log.error("审核确认失败", e);
- throw new RuntimeException("审核确认失败: " + e.getMessage());
- }
- }
- /**
- * 审核丢弃报告 — 删除草稿,不入库
- */
- @Transactional(rollbackFor = Exception.class)
- public void auditDiscard(Long draftId) {
- HealthReportDraft draft = getActiveDraftOrThrow(draftId);
- if (draft == null) {
- throw new IllegalArgumentException("草稿不存在或已过期");
- }
- markDraftDiscarded(draftId);
- log.info("审核丢弃草稿: draftId={}", draftId);
- }
- /**
- * 获取待审核的草稿列表(分页)
- */
- public List<HealthReportDraft> listPendingAudits(int page, int size) {
- // MyBatis-Plus 分页查询
- com.baomidou.mybatisplus.extension.plugins.pagination.Page<HealthReportDraft> p =
- new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page, size);
- LambdaQueryWrapper<HealthReportDraft> wrapper = new LambdaQueryWrapper<>();
- wrapper.eq(HealthReportDraft::getStatus, "pending")
- .orderByDesc(HealthReportDraft::getCreatedAt);
- SortUtil.applySort(wrapper);
- p = draftMapper.selectPage(p, wrapper);
- return p.getRecords();
- }
- /**
- * 获取草稿(内部方法)
- */
- private HealthReportDraft getActiveDraftOrThrow(Long draftId) {
- HealthReportDraft draft = draftMapper.selectById(draftId);
- if (draft == null || !"pending".equals(draft.getStatus())) {
- return null;
- }
- if (draft.getExpireAt() != null && draft.getExpireAt().before(new Date())) {
- return null;
- }
- return draft;
- }
- private void markDraftConfirmed(Long draftId, Long reportId) {
- LambdaUpdateWrapper<HealthReportDraft> uw = new LambdaUpdateWrapper<>();
- uw.eq(HealthReportDraft::getId, draftId)
- .set(HealthReportDraft::getStatus, "confirmed")
- .set(HealthReportDraft::getConfirmedReportId, reportId)
- .set(HealthReportDraft::getUpdatedAt, new Date());
- draftMapper.update(null, uw);
- }
- private void markDraftDiscarded(Long draftId) {
- LambdaUpdateWrapper<HealthReportDraft> uw = new LambdaUpdateWrapper<>();
- uw.eq(HealthReportDraft::getId, draftId)
- .set(HealthReportDraft::getStatus, "discarded")
- .set(HealthReportDraft::getUpdatedAt, new Date());
- draftMapper.update(null, uw);
- }
- // ======================== Payload builders (shared with controller) ========================
- HealthReport buildReportFromPayload(HealthReportDraft draft, ParsedReportPayload.Payload payload, Long subjectId) {
- HealthReport report = new HealthReport();
- report.setUserId(draft.getUserId());
- report.setFamilyId(draft.getFamilyId());
- report.setSubjectId(subjectId);
- report.setReportType(draft.getReportType());
- report.setFileUrl(draft.getFileUrl());
- ParsedReportPayload.Summary s = payload.getSummary();
- if (s != null) {
- report.setOverallScore(s.getOverallScore());
- report.setGutHealthScore(s.getGutHealthScore());
- report.setChronicDiseaseScore(s.getChronicDiseaseScore());
- report.setNutritionScore(s.getNutritionScore());
- report.setBalanceScore(s.getBalanceScore());
- report.setDiversityScore(s.getDiversityScore());
- report.setBeneficialScore(s.getBeneficialScore());
- report.setHarmfulScore(s.getHarmfulScore());
- report.setCoreGenusScore(s.getCoreGenusScore());
- report.setGutAge(s.getGutAge());
- report.setGutType(s.getGutType());
- report.setReportNumber(s.getReportNumber());
- report.setPersonName(s.getPersonName());
- if (s.getReportDate() != null && !s.getReportDate().isEmpty()) {
- try {
- java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd");
- report.setReportDate(sdf.parse(s.getReportDate()));
- } catch (Exception e) {
- report.setReportDate(new Date());
- }
- } else {
- report.setReportDate(new Date());
- }
- } else {
- report.setReportDate(new Date());
- }
- report.setRemark("募极生物肠道菌群检测报告");
- return report;
- }
- List<HealthIndicator> buildIndicatorsFromPayload(ParsedReportPayload.Payload payload) {
- List<ParsedReportPayload.Indicator> list = payload.getIndicators();
- if (list == null || list.isEmpty()) return new ArrayList<>();
- List<HealthIndicator> indicators = new ArrayList<>();
- for (int i = 0; i < list.size(); i++) {
- ParsedReportPayload.Indicator pi = list.get(i);
- HealthIndicator ind = new HealthIndicator();
- ind.setCategory(pi.getCategory());
- ind.setIndicatorName(pi.getIndicatorName());
- ind.setIndicatorValue(pi.getIndicatorValue());
- ind.setUnit(pi.getUnit());
- ind.setRefRange(pi.getRefRange());
- ind.setStatus(pi.getStatus());
- ind.setSymptoms(pi.getSymptoms());
- ind.setSortOrder(i);
- if (pi.getCollectionDate() != null && !pi.getCollectionDate().isEmpty()) {
- try {
- java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd");
- ind.setCollectionDate(sdf.parse(pi.getCollectionDate()));
- } catch (Exception e) {
- // ignore parse error
- }
- }
- indicators.add(ind);
- }
- return indicators;
- }
- List<HealthGutFlora> buildGutFloraFromPayload(ParsedReportPayload.Payload payload) {
- List<HealthGutFlora> result = new ArrayList<>();
- // 核心菌属 + 有害菌属等
- addFloraList(result, payload.getGutFlora());
- // 益生菌种
- addFloraList(result, payload.getProbioticSpecies());
- // Taxonomy 层级
- addFloraList(result, payload.getTaxonomyClass());
- addFloraList(result, payload.getTaxonomyOrder());
- addFloraList(result, payload.getTaxonomyFamily());
- addFloraList(result, payload.getTaxonomyGenus());
- addFloraList(result, payload.getTaxonomySpecies());
- // 病原菌
- addFloraList(result, payload.getPathogenGenus());
- addFloraList(result, payload.getPathogenDetection());
- return result;
- }
- private void addFloraList(List<HealthGutFlora> target, List<ParsedReportPayload.Flora> source) {
- if (source != null) {
- for (ParsedReportPayload.Flora pf : source) {
- target.add(convertToGutFlora(pf));
- }
- }
- }
- private HealthGutFlora convertToGutFlora(ParsedReportPayload.Flora pf) {
- HealthGutFlora gf = new HealthGutFlora();
- gf.setBacteriaName(pf.getBacteriaName());
- gf.setBacteriaValue(pf.getBacteriaValue());
- gf.setNormalRange(pf.getNormalRange());
- gf.setPopulationLevel(pf.getPopulationLevel());
- gf.setDetectionRate(pf.getDetectionRate());
- gf.setDescription(pf.getDescription());
- gf.setCategory(normalizeFloraCategory(pf.getCategory()));
- gf.setLevel(normalizeFloraLevel(pf.getCategory(), pf.getLevel()));
- return gf;
- }
- private String normalizeFloraCategory(String category) {
- if (category == null) return null;
- if (category.endsWith("构成")) return HealthGutFlora.CATEGORY_TAXONOMY;
- return category;
- }
- private String normalizeFloraLevel(String category, String level) {
- if (level == null) return null;
- String normalized = level;
- if ("门".equals(level)) normalized = HealthGutFlora.LEVEL_PHYLUM;
- else if ("纲".equals(level)) normalized = HealthGutFlora.LEVEL_CLASS;
- else if ("目".equals(level)) normalized = HealthGutFlora.LEVEL_ORDER;
- else if ("科".equals(level)) normalized = HealthGutFlora.LEVEL_FAMILY;
- else if ("属".equals(level)) normalized = HealthGutFlora.LEVEL_GENUS;
- else if ("种".equals(level)) normalized = HealthGutFlora.LEVEL_SPECIES;
- return normalized;
- }
- /**
- * 读回归一化:兼容历史数据(归一化前入库的 category='菌X构成' + level='门/纲/...')。
- * 仅转换,不落库(避免读操作触发写)。
- */
- private void normalizeGutFloraRead(List<HealthGutFlora> gutFlora) {
- if (gutFlora == null) return;
- for (HealthGutFlora gf : gutFlora) {
- String rawCategory = gf.getCategory();
- gf.setCategory(normalizeFloraCategory(rawCategory));
- gf.setLevel(normalizeFloraLevel(rawCategory, gf.getLevel()));
- }
- }
- List<HealthDiseaseRisk> buildDiseaseRisksFromPayload(ParsedReportPayload.Payload payload) {
- List<ParsedReportPayload.DiseaseRisk> list = payload.getDiseaseRisks();
- if (list == null || list.isEmpty()) return new ArrayList<>();
- List<HealthDiseaseRisk> risks = new ArrayList<>();
- for (ParsedReportPayload.DiseaseRisk pr : list) {
- HealthDiseaseRisk dr = new HealthDiseaseRisk();
- dr.setDiseaseName(pr.getDiseaseName());
- dr.setRiskValue(pr.getRiskValue());
- dr.setRiskLevel(pr.getRiskLevel());
- risks.add(dr);
- }
- return risks;
- }
- /**
- * 从 Payload 构建食材推荐数据并入库
- * 查找 foodId(匹配 foods 表),未匹配时留空
- */
- @Transactional
- public void saveFoodSuitability(Long reportId, List<ParsedReportPayload.FoodItem> foods) {
- if (foods == null || foods.isEmpty()) {
- log.info("报告{}无食材推荐数据,跳过入库", reportId);
- return;
- }
- // 批量查询所有食材名称→ID映射
- Map<String, Long> foodNameIdMap = new HashMap<>();
- List<Food> allFoods = foodMapper.selectList(null);
- for (Food f : allFoods) {
- if (f.getName() != null) {
- foodNameIdMap.put(f.getName().trim(), f.getId());
- }
- }
- Date now = new Date();
- int saved = 0;
- for (ParsedReportPayload.FoodItem item : foods) {
- if (item.getName() == null || item.getName().trim().isEmpty()) continue;
- ReportFoodSuitability suit = new ReportFoodSuitability();
- suit.setReportId(reportId);
- suit.setFoodName(item.getName().trim());
- suit.setCategory(item.getCategory());
- suit.setScore(item.getScore());
- suit.setCreatedAt(now);
- // 匹配 foodId
- Long foodId = foodNameIdMap.get(item.getName().trim());
- if (foodId == null) {
- // 模糊匹配:尝试包含匹配
- for (Map.Entry<String, Long> entry : foodNameIdMap.entrySet()) {
- if (entry.getKey().contains(item.getName().trim()) ||
- item.getName().trim().contains(entry.getKey())) {
- foodId = entry.getValue();
- break;
- }
- }
- }
- suit.setFoodId(foodId);
- reportFoodSuitabilityMapper.insert(suit);
- saved++;
- }
- log.info("已保存{}条食材推荐到报告{}", saved, reportId);
- }
- /**
- * 更新已发布报告的编辑数据
- * 逻辑: 只更新已有字段的值,不增删条目(全量替换)
- * payload 支持嵌套结构 {summary:{...}, indicators:[], gutFlora:[], diseaseRisks:[]},
- * 也兼容旧版平铺结构 {overallScore:..., indicators:[], ...}
- */
- @Transactional(rollbackFor = Exception.class)
- public void updateReportFromPayload(Long reportId, Map<String, Object> payload, Long subjectId, Long userId) {
- // 0. 编辑前快照(用于编辑日志)
- HealthReport oldReport = healthReportMapper.selectById(reportId);
- if (oldReport == null) {
- throw new RuntimeException("报告不存在: " + reportId);
- }
- String beforePayload = oldReport.getPayloadJson();
- // 取评分子段: 嵌套 {summary:{...}} 或平铺
- @SuppressWarnings("unchecked")
- Map<String, Object> summary = (payload.get("summary") instanceof Map)
- ? (Map<String, Object>) payload.get("summary") : null;
- Map<String, Object> scoreSource = summary != null ? summary : payload;
- // 1. 更新 health_report 评分数值
- Integer overallScore = toInteger(scoreSource.get("overallScore"));
- if (overallScore != null) oldReport.setOverallScore(overallScore);
- Integer gutHealthScore = toInteger(scoreSource.get("gutHealthScore"));
- if (gutHealthScore != null) oldReport.setGutHealthScore(gutHealthScore);
- Integer chronicDiseaseScore = toInteger(scoreSource.get("chronicDiseaseScore"));
- if (chronicDiseaseScore != null) oldReport.setChronicDiseaseScore(chronicDiseaseScore);
- Integer nutritionScore = toInteger(scoreSource.get("nutritionScore"));
- if (nutritionScore != null) oldReport.setNutritionScore(nutritionScore);
- Integer gutBalanceScore = toInteger(scoreSource.get("gutBalanceScore"));
- if (gutBalanceScore != null) oldReport.setGutBalanceScore(gutBalanceScore);
- Integer gutDiversityScore = toInteger(scoreSource.get("gutDiversityScore"));
- if (gutDiversityScore != null) oldReport.setGutDiversityScore(gutDiversityScore);
- Integer beneficialBacteriaScore = toInteger(scoreSource.get("beneficialBacteriaScore"));
- if (beneficialBacteriaScore != null) oldReport.setBeneficialBacteriaScore(beneficialBacteriaScore);
- Integer harmfulBacteriaScore = toInteger(scoreSource.get("harmfulBacteriaScore"));
- if (harmfulBacteriaScore != null) oldReport.setHarmfulBacteriaScore(harmfulBacteriaScore);
- Integer coreSpeciesScore = toInteger(scoreSource.get("coreSpeciesScore"));
- if (coreSpeciesScore != null) oldReport.setCoreSpeciesScore(coreSpeciesScore);
- if (scoreSource.get("shannonIndex") != null) {
- oldReport.setShannonIndex(scoreSource.get("shannonIndex").toString());
- }
- // 用户可手动修正报告日期(AI 解析结果不准确时)
- Object reportDateObj = scoreSource.get("reportDate");
- if (reportDateObj != null && !String.valueOf(reportDateObj).trim().isEmpty()) {
- String reportDateStr = String.valueOf(reportDateObj).trim();
- try {
- java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd");
- java.util.Date parsedDate = sdf.parse(reportDateStr);
- if (parsedDate != null) {
- oldReport.setReportDate(new java.sql.Date(parsedDate.getTime()));
- log.info("报告日期已更新: reportId={}, reportDate={}", reportId, reportDateStr);
- }
- } catch (Exception e) {
- log.warn("reportDate 格式解析失败 reportId={}, value={}: {}", reportId, reportDateStr, e.getMessage());
- }
- }
- oldReport.setUpdatedAt(new Date());
- healthReportMapper.updateById(oldReport);
- // 2. 更新 indicators(删除旧→插入新)
- Object indicatorsRaw = payload.get("indicators");
- if (indicatorsRaw instanceof List) {
- @SuppressWarnings("unchecked")
- List<Map<String, Object>> indicators = (List<Map<String, Object>>) indicatorsRaw;
- healthIndicatorMapper.delete(new LambdaQueryWrapper<HealthIndicator>()
- .eq(HealthIndicator::getReportId, reportId));
- for (int i = 0; i < indicators.size(); i++) {
- Map<String, Object> item = indicators.get(i);
- HealthIndicator ind = new HealthIndicator();
- ind.setReportId(reportId);
- ind.setCategory((String) item.get("category"));
- ind.setIndicatorName((String) item.get("indicatorName"));
- ind.setIndicatorValue(item.get("indicatorValue") != null
- ? item.get("indicatorValue").toString() : null);
- ind.setUnit((String) item.get("unit"));
- ind.setRefRange((String) item.get("refRange"));
- ind.setStatus((String) item.get("status"));
- ind.setSymptoms((String) item.get("symptoms"));
- ind.setSortOrder(i);
- healthIndicatorMapper.insert(ind);
- }
- }
- // 3. 更新 gutFlora
- Object gutFloraRaw = payload.get("gutFlora");
- if (gutFloraRaw instanceof List) {
- @SuppressWarnings("unchecked")
- List<Map<String, Object>> gutFlora = (List<Map<String, Object>>) gutFloraRaw;
- healthGutFloraMapper.delete(new LambdaQueryWrapper<HealthGutFlora>()
- .eq(HealthGutFlora::getReportId, reportId));
- for (int i = 0; i < gutFlora.size(); i++) {
- Map<String, Object> item = gutFlora.get(i);
- HealthGutFlora flora = new HealthGutFlora();
- flora.setReportId(reportId);
- flora.setBacteriaName((String) item.get("bacteriaName"));
- flora.setBacteriaValue(item.get("bacteriaValue") != null
- ? item.get("bacteriaValue").toString() : null);
- flora.setNormalRange((String) item.get("normalRange"));
- flora.setStatus((String) item.get("status"));
- flora.setCategory(normalizeFloraCategory((String) item.get("category")));
- flora.setPopulationLevel((String) item.get("populationLevel"));
- flora.setDetectionRate((String) item.get("detectionRate"));
- flora.setLevel(normalizeFloraLevel((String) item.get("category"),
- (String) item.get("level")));
- flora.setSortOrder(i);
- healthGutFloraMapper.insert(flora);
- }
- }
- // 4. 更新 diseaseRisks
- Object risksRaw = payload.get("diseaseRisks");
- if (risksRaw instanceof List) {
- @SuppressWarnings("unchecked")
- List<Map<String, Object>> diseaseRisks = (List<Map<String, Object>>) risksRaw;
- healthDiseaseRiskMapper.delete(new LambdaQueryWrapper<HealthDiseaseRisk>()
- .eq(HealthDiseaseRisk::getReportId, reportId));
- for (Map<String, Object> item : diseaseRisks) {
- HealthDiseaseRisk risk = new HealthDiseaseRisk();
- risk.setReportId(reportId);
- risk.setDiseaseName((String) item.get("diseaseName"));
- risk.setRiskValue(item.get("riskValue") != null
- ? item.get("riskValue").toString() : null);
- risk.setRiskLevel((String) item.get("riskLevel"));
- healthDiseaseRiskMapper.insert(risk);
- }
- }
- // 5. 刷新 7 维健康评分(支持成员重选:旧成员和新成员都需重算)
- Long oldSubjectId = oldReport.getSubjectId();
- // subjectId 语义:
- // null → 主动解绑(移除该报告对原成员的影响)
- // <= 0 → 保持不变(子页面局部编辑,不触碰成员绑定)
- // > 0 → 绑定或切换成员(重算新成员 + 原成员)
- if (subjectId != null && subjectId > 0) {
- // 更新报告绑定的成员(可能为新成员或切换成员)
- if (oldSubjectId == null || !oldSubjectId.equals(subjectId)) {
- bindReportToMember(reportId, subjectId);
- }
- try {
- dimensionScoreService.refreshFromGutReport(subjectId, reportId);
- log.info("编辑后已刷新成员{}的7维健康评分", subjectId);
- } catch (Exception e) {
- log.warn("刷新7维健康评分失败: {}", e.getMessage());
- }
- try {
- foodRecommendService.recalculateForUser(subjectId);
- log.info("编辑后已刷新成员{}的食材推荐指数", subjectId);
- } catch (Exception e) {
- log.warn("刷新食材推荐指数失败: {}", e.getMessage());
- }
- // 若有旧成员(解绑或切换成员),也需重算旧成员的维度
- if (oldSubjectId != null && !oldSubjectId.equals(subjectId)) {
- try {
- dimensionScoreService.rebuildFromMemberReports(oldSubjectId);
- log.info("编辑后已重算旧成员{}的7维健康评分(成员已切换)", oldSubjectId);
- } catch (Exception e) {
- log.warn("刷新旧成员{}健康评分失败: {}", oldSubjectId, e.getMessage());
- }
- try {
- foodRecommendService.recalculateForUser(oldSubjectId);
- } catch (Exception e) {
- log.warn("刷新旧成员{}食材推荐指数失败: {}", oldSubjectId, e.getMessage());
- }
- }
- } else if (subjectId == null && oldSubjectId != null) {
- // 用户主动解绑(subjectId=null),重算旧成员(移除该报告的影响)
- try {
- dimensionScoreService.rebuildFromMemberReports(oldSubjectId);
- log.info("解绑成员后已重算旧成员{}的7维健康评分", oldSubjectId);
- } catch (Exception e) {
- log.warn("刷新旧成员{}健康评分失败: {}", oldSubjectId, e.getMessage());
- }
- try {
- foodRecommendService.recalculateForUser(oldSubjectId);
- } catch (Exception e) {
- log.warn("刷新旧成员{}食材推荐指数失败: {}", oldSubjectId, e.getMessage());
- }
- }
- // 6. 保存 payload 快照 + 编辑日志
- try {
- String afterPayload = objectMapper.writeValueAsString(payload);
- oldReport.setPayloadJson(afterPayload);
- oldReport.setUpdatedAt(new Date());
- healthReportMapper.updateById(oldReport);
- HealthReportEditLog editLog = new HealthReportEditLog();
- editLog.setReportId(reportId);
- editLog.setUserId(userId);
- editLog.setReportType("gut_flora");
- editLog.setAction("edit");
- editLog.setBeforePayload(beforePayload);
- editLog.setAfterPayload(afterPayload);
- editLog.setCreatedAt(new Date());
- healthReportEditLogMapper.insert(editLog);
- } catch (Exception e) {
- log.warn("保存payload快照/编辑日志失败 reportId={}: {}", reportId, e.getMessage());
- }
- // 7. 编辑后重新组装展示块(第一套键 Map → 标准 Payload → blocks)
- try {
- ParsedReportPayload.Payload editedPayload = reportBlockAssembler.fromMap(payload);
- String reportType = oldReport.getReportType();
- reportBlockService.save(reportType, reportId,
- reportBlockAssembler.assembleWithTemplate(reportType, editedPayload));
- } catch (Exception e) {
- log.warn("编辑后刷新展示块失败 reportId={}: {}", reportId, e.getMessage());
- }
- // 归一化管道:编辑后重写统一指标层(同事务;payload 为编辑后最新值)
- try {
- ParsedReportPayload.Payload editedPayload = reportBlockAssembler.fromMap(payload);
- oldReport.setSubjectId(subjectId != null && subjectId > 0 ? subjectId : oldSubjectId);
- normalizeIndicatorPipeline.processReport(oldReport, editedPayload);
- } catch (Exception e) {
- log.warn("归一化管道刷新失败 reportId={}: {}", reportId, e.getMessage());
- }
- }
- private Integer toInteger(Object value) {
- if (value == null) return null;
- try {
- return Integer.valueOf(value.toString());
- } catch (NumberFormatException e) {
- return null;
- }
- }
- /**
- * 保存 payload 快照并记录编辑日志(action: create/edit)
- */
- public void savePayloadSnapshot(Long reportId, Long userId, String action,
- String beforePayload, String afterPayload) {
- try {
- HealthReport report = healthReportMapper.selectById(reportId);
- if (report != null) {
- report.setPayloadJson(afterPayload);
- report.setUpdatedAt(new Date());
- healthReportMapper.updateById(report);
- }
- HealthReportEditLog editLog = new HealthReportEditLog();
- editLog.setReportId(reportId);
- editLog.setUserId(userId);
- editLog.setReportType("gut_flora");
- editLog.setAction(action);
- editLog.setBeforePayload(beforePayload);
- editLog.setAfterPayload(afterPayload);
- editLog.setCreatedAt(new Date());
- healthReportEditLogMapper.insert(editLog);
- } catch (Exception e) {
- log.warn("保存payload快照/编辑日志失败 reportId={}: {}", reportId, e.getMessage());
- }
- }
- /**
- * 设置代上传人信息
- */
- public void setProxyUploader(Long reportId, Long proxyUploaderId) {
- HealthReport report = healthReportMapper.selectById(reportId);
- if (report != null) {
- report.setProxyUploaderId(proxyUploaderId);
- report.setUpdatedAt(new Date());
- healthReportMapper.updateById(report);
- }
- }
- /**
- * 设置AI解读结果
- */
- public void setAiAnalysis(Long reportId, Map<String, Object> aiData) {
- try {
- com.alibaba.fastjson.JSON json = com.alibaba.fastjson.JSON.parseObject(
- com.alibaba.fastjson.JSON.toJSONString(aiData));
- HealthReport report = healthReportMapper.selectById(reportId);
- if (report != null) {
- report.setAiAnalysis(json.toJSONString());
- report.setUpdatedAt(new Date());
- healthReportMapper.updateById(report);
- }
- } catch (Exception e) {
- log.warn("保存AI解读失败: {}", e.getMessage());
- }
- }
- /**
- * 删除健康报告(逻辑删除: 设置 status=deleted,同时清理关联数据)
- */
- @Transactional
- public void deleteReport(Long reportId) {
- // 逻辑删除前记录 subjectId,用于删除后重算维度分
- Long subjectId = null;
- HealthReport report = healthReportMapper.selectById(reportId);
- if (report != null) {
- subjectId = report.getSubjectId();
- }
- // 清理关联指标
- healthIndicatorMapper.delete(new LambdaQueryWrapper<HealthIndicator>()
- .eq(HealthIndicator::getReportId, reportId));
- // 清理菌群数据
- healthGutFloraMapper.delete(new LambdaQueryWrapper<HealthGutFlora>()
- .eq(HealthGutFlora::getReportId, reportId));
- // 清理疾病风险评估
- healthDiseaseRiskMapper.delete(new LambdaQueryWrapper<HealthDiseaseRisk>()
- .eq(HealthDiseaseRisk::getReportId, reportId));
- // 清理查看权限
- healthReportPermissionMapper.delete(new LambdaQueryWrapper<HealthReportPermission>()
- .eq(HealthReportPermission::getReportId, reportId));
- // 清理编辑日志
- healthReportEditLogMapper.delete(new LambdaQueryWrapper<HealthReportEditLog>()
- .eq(HealthReportEditLog::getReportId, reportId));
- // 清理展示块
- reportBlockMapper.delete(new LambdaQueryWrapper<ReportBlock>()
- .eq(ReportBlock::getReportId, reportId));
- // 清理食材适合性
- reportFoodSuitabilityMapper.delete(new LambdaQueryWrapper<ReportFoodSuitability>()
- .eq(ReportFoodSuitability::getReportId, reportId));
- // 逻辑删除报告本身
- if (report != null) {
- report.setStatus("deleted");
- report.setUpdatedAt(new Date());
- healthReportMapper.updateById(report);
- log.info("健康报告已删除: id={}, subjectId={}", reportId, subjectId);
- }
- // 删除后重算该成员的维度分(移除该报告的影响)
- if (subjectId != null) {
- try {
- dimensionScoreService.refreshFromGutReport(subjectId, reportId);
- log.info("报告删除后已重算成员{}的7维健康评分", subjectId);
- } catch (Exception e) {
- log.warn("报告删除后刷新成员{}健康评分失败: {}", subjectId, e.getMessage());
- }
- try {
- foodRecommendService.recalculateForUser(subjectId);
- } catch (Exception e) {
- log.warn("报告删除后刷新成员{}食材推荐指数失败: {}", subjectId, e.getMessage());
- }
- }
- }
- /**
- * 获取某成员某维度的最新指标值列表(用于首页卡片展示)
- */
- public List<Map<String, Object>> getLatestIndicatorsByDomain(Long memberId, String domain) {
- // 1. 获取该域下所有指标定义
- LambdaQueryWrapper<IndicatorDefinition> defW = new LambdaQueryWrapper<>();
- defW.eq(IndicatorDefinition::getDomain, domain).eq(IndicatorDefinition::getStatus, 1);
- defW.orderByAsc(IndicatorDefinition::getSortOrder);
- List<IndicatorDefinition> defs = indicatorDefinitionMapper.selectList(defW);
- if (defs.isEmpty()) return Collections.emptyList();
- // 2. 对每个定义,获取该成员的最新值
- List<Map<String, Object>> result = new java.util.ArrayList<>();
- for (IndicatorDefinition def : defs) {
- LambdaQueryWrapper<IndicatorValue> w = new LambdaQueryWrapper<>();
- // 口径:写入端 source_id=报告ID(保留每报告快照),subject_id=成员ID;
- // 读取端按 subject_id=memberId 查最新观测值
- w.eq(IndicatorValue::getSubjectId, memberId)
- .eq(IndicatorValue::getDefinitionId, def.getId())
- .orderByDesc(IndicatorValue::getRecordedAt)
- .last("LIMIT 1");
- IndicatorValue latest = indicatorValueMapper.selectOne(w);
- // B方案 diff:member_indicator_current(member_id+definition_id 唯一)
- com.etotem.cfc.entity.MemberIndicatorCurrent mic = null;
- try {
- mic = memberIndicatorCurrentService.getByMemberAndDefinition(memberId, def.getId());
- } catch (Exception ignore) { }
- Map<String, Object> item = new HashMap<>();
- item.put("code", def.getCode());
- item.put("name", def.getName());
- item.put("category", def.getCategory());
- item.put("unit", def.getUnit());
- item.put("refRange", def.getRefRange());
- if (latest != null) {
- item.put("value", latest.getValue());
- item.put("numericValue", latest.getNumericValue() != null ? latest.getNumericValue().doubleValue() : null);
- item.put("remark", latest.getRemark());
- item.put("recordedAt", latest.getRecordedAt());
- } else if (mic != null) {
- // indicator_values 无观测行时回退到 member_indicator_current(B方案 diff 表)
- item.put("value", mic.getValue());
- item.put("numericValue", mic.getNumericValue() != null ? mic.getNumericValue().doubleValue() : null);
- item.put("remark", null);
- item.put("recordedAt", mic.getUpdatedAt());
- } else {
- item.put("value", null);
- item.put("numericValue", null);
- item.put("remark", null);
- item.put("recordedAt", null);
- }
- if (mic != null) {
- item.put("previousValue", mic.getPreviousValue());
- java.math.BigDecimal delta = mic.getDelta();
- item.put("delta", delta != null ? delta.doubleValue() : null);
- item.put("trend", mic.getTrend());
- // trendDesc: 前端展示"较上次 ±x"
- if (delta != null && delta.compareTo(java.math.BigDecimal.ZERO) != 0) {
- String arrow = delta.signum() > 0 ? "+" : "";
- // stripTrailingZeros: 去掉 decimal(12,4) 带来的尾零 (如 -9.0000 -> -9)
- String plain = delta.stripTrailingZeros().toPlainString();
- item.put("trendDesc", "较上次 " + arrow + plain);
- } else {
- item.put("trendDesc", "");
- }
- }
- result.add(item);
- }
- return result;
- }
- /**
- * 获取成员的最新菌群报告核心指标(用于身页面"肠道菌群"卡片)
- * 返回:核心菌属、益生菌、有害菌属、病原菌属、其它重要菌属(排除 taxonomy 门/纲/目/科级别分类构成)
- */
- public List<Map<String, Object>> getGutFloraIndicators(Long memberId) {
- LambdaQueryWrapper<HealthReport> reportW = new LambdaQueryWrapper<>();
- reportW.eq(HealthReport::getSubjectId, memberId)
- .or().eq(HealthReport::getUserId, memberId);
- reportW.eq(HealthReport::getReportType, "gut_flora")
- .eq(HealthReport::getStatus, "active")
- .orderByDesc(HealthReport::getCreatedAt)
- .last("LIMIT 1");
- HealthReport report = healthReportMapper.selectOne(reportW);
- if (report == null) return Collections.emptyList();
- LambdaQueryWrapper<HealthGutFlora> w = new LambdaQueryWrapper<>();
- w.eq(HealthGutFlora::getReportId, report.getId())
- .in(HealthGutFlora::getCategory, "核心菌属", "益生菌", "有害菌属", "其它重要菌属", "病原菌属")
- .orderByAsc(HealthGutFlora::getSortOrder);
- List<HealthGutFlora> flora = healthGutFloraMapper.selectList(w);
- if (flora.isEmpty()) return Collections.emptyList();
- List<Map<String, Object>> result = new ArrayList<>();
- for (HealthGutFlora f : flora) {
- Map<String, Object> item = new HashMap<>();
- item.put("name", f.getBacteriaName());
- item.put("value", f.getBacteriaValue());
- item.put("status", f.getStatus());
- item.put("category", f.getCategory());
- item.put("populationLevel", f.getPopulationLevel());
- item.put("detectionRate", f.getDetectionRate());
- item.put("description", f.getDescription());
- item.put("level", f.getLevel());
- result.add(item);
- }
- return result;
- }
- /**
- * 刷新成员指标当前值 diff(member_indicator_current)
- * 逻辑:
- * 1. 查该成员最近 2 份按 report_date desc 的确认报告(status=active)
- * 2. 取第 1 份(最新)与第 2 份(次新)中同名指标
- * 3. 通过 IndicatorDefinitionMapper 拿 domain/category
- * 4. 用 numeric 做 delta/trend 计算(字符串 fallback)
- * 5. upsert 写入 member_indicator_current
- * 幂等:同一 member_id+definition_id 用 upsert 覆盖
- */
- /**
- * 回填重载:按成员自动找最近 2 份已确认报告并刷新 member_indicator_current
- * 供 admin 端点 / 启动任务使用;无 latestReportId 也可调用(自动解析)
- */
- public void rebuildForMember(Long subjectId) {
- if (subjectId == null) return;
- List<HealthReport> recent = healthReportMapper.selectList(
- new LambdaQueryWrapper<HealthReport>()
- .eq(HealthReport::getSubjectId, subjectId)
- .eq(HealthReport::getStatus, "active")
- .orderByDesc(HealthReport::getReportDate)
- .orderByDesc(HealthReport::getCreatedAt)
- .last("LIMIT 1"));
- if (recent.isEmpty()) {
- log.info("rebuildForMember: 成员{}无已确认报告,跳过", subjectId);
- return;
- }
- refreshMemberIndicatorCurrent(subjectId, recent.get(0).getId());
- }
- public void refreshMemberIndicatorCurrent(Long subjectId, Long latestReportId) {
- if (subjectId == null || latestReportId == null) return;
- try {
- // 取该成员最近 2 份报告 (按 report_date desc, 失败则 fallback 到 created_at)
- List<HealthReport> recent = healthReportMapper.selectList(
- new LambdaQueryWrapper<HealthReport>()
- .eq(HealthReport::getSubjectId, subjectId)
- .eq(HealthReport::getStatus, "active")
- .orderByDesc(HealthReport::getReportDate)
- .orderByDesc(HealthReport::getCreatedAt)
- .last("LIMIT 2"));
- if (recent.isEmpty()) return;
- HealthReport latest = recent.get(0);
- HealthReport previous = recent.size() > 1 ? recent.get(1) : null;
- // 最新报告的指标
- List<HealthIndicator> latestInds = getReportIndicators(latest.getId());
- if (latestInds.isEmpty()) return;
- // 次新指标的 name→HealthIndicator 映射
- java.util.Map<String, HealthIndicator> prevMap = new java.util.HashMap<>();
- if (previous != null) {
- for (HealthIndicator pi : getReportIndicators(previous.getId())) {
- if (pi.getIndicatorName() != null) {
- prevMap.put(pi.getIndicatorName().trim(), pi);
- }
- }
- }
- int written = 0;
- for (HealthIndicator li : latestInds) {
- if (li.getIndicatorName() == null) continue;
- String rawName = li.getIndicatorName().trim();
- // 通过 mapper 找 definition(domain/category 来源)
- com.etotem.cfc.entity.IndicatorDefinition def = indicatorDefinitionMapper.selectOne(
- new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<
- com.etotem.cfc.entity.IndicatorDefinition>()
- .eq(com.etotem.cfc.entity.IndicatorDefinition::getName, rawName)
- .eq(com.etotem.cfc.entity.IndicatorDefinition::getStatus, 1)
- .last("LIMIT 1"));
- if (def == null) continue;
- MemberIndicatorCurrent item = new MemberIndicatorCurrent();
- item.setMemberId(subjectId);
- item.setDefinitionId(def.getId());
- item.setDomain(def.getDomain());
- item.setCategory(def.getCategory());
- item.setName(rawName);
- item.setValue(li.getIndicatorValue());
- item.setLatestReportId(latest.getId());
- item.setReportDate(latest.getReportDate() != null
- ? new java.sql.Date(latest.getReportDate().getTime()) : null);
- // previous 指标(同名)
- HealthIndicator pi = prevMap.get(rawName);
- if (pi != null) {
- item.setPreviousValue(pi.getIndicatorValue());
- item.setPreviousReportId(previous.getId());
- }
- // numeric 计算
- BigDecimal currentNum = parseNumericString(li.getIndicatorValue());
- BigDecimal previousNum = pi != null ? parseNumericString(pi.getIndicatorValue()) : null;
- item.setNumericValue(currentNum);
- item.setPreviousNumeric(previousNum);
- if (currentNum != null && previousNum != null) {
- item.setDelta(currentNum.subtract(previousNum));
- int cmp = currentNum.compareTo(previousNum);
- item.setTrend(cmp > 0 ? "up" : (cmp < 0 ? "down" : "flat"));
- } else {
- item.setTrend(pi != null ? "na" : "new");
- }
- item.setRecordedAt(new Date());
- memberIndicatorCurrentService.upsert(item);
- written++;
- }
- log.info("迁移378 补写完成: member={} latestReport={} prevReport={} written={}",
- subjectId, latest.getId(),
- previous != null ? previous.getId() : "none", written);
- } catch (Exception e) {
- log.warn("迁移378 补写失败 member={} report={}: {}", subjectId, latestReportId, e.getMessage());
- }
- }
- /** 安全解析指标值为 BigDecimal(失败返回 null,避免整块报告因单条脏数据卡死)*/
- private BigDecimal parseNumericString(String s) {
- if (s == null || s.trim().isEmpty()) return null;
- try {
- return new BigDecimal(s.trim().replaceAll("[^0-9.eE+-]", ""));
- } catch (Exception ex) {
- return null;
- }
- }
- }
|