Kaynağa Gözat

feat(indicators): B方案-成员指标当前值+相对上次变化(delta/trend)

后端:
- 迁移378: member_indicator_current 表(唯一键 member_id+definition_id)
- HealthReportService.refreshMemberIndicatorCurrent(报告确认后按最新vs次新同名指标算 diff)
- rebuildForMember + /api/health/indicators/rebuild(admin 回填存量)
- /api/health/indicators/current 读取端点
- getLatestIndicatorsByDomain 读取口径修复: source_id(报告ID)→subject_id(成员ID)
  (写入 source_id=reportId 保留每报告快照, 读端按 subject_id=memberId 查最新)
- confirmDraft + collect 接入 refreshMemberIndicatorCurrent

LangGraph:
- JavaClient.get_member_indicator_current
- collect_data 拉取指标变化(delta>=10 显著变化并入 abnormal_list)
- build_prompt 增加指标较上次报告的变化段落, 方案生成考虑趋势

数据: member21 回填 50 条 diff(报告42→58)
Xiaogang Liao 1 hafta önce
ebeveyn
işleme
4ad4731635

+ 42 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -3079,6 +3079,9 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
 
         // 迁移377: diet_preferences 添加 diet_structure JSON 列(蔬果量/乳制品/含糖饮料频率)
         ensureColumn("diet_preferences", "diet_structure", "JSON DEFAULT NULL COMMENT '饮食结构现状 {vegetableFruit,dairy,sugaryDrinks}'");
+
+        // 迁移378: 创建 member_indicator_current 表(成员指标当前值 + 相对上次变化 delta)
+        addMemberIndicatorCurrentTable();
     }
 
     private void migrateHealthDimensionsTable() {
@@ -12338,6 +12341,45 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
     }
 
     // 迁移374: 创建 user_content_weights 表(用户内容偏好权重表)
+    /**
+     * 迁移378: 创建 member_indicator_current 表
+     * 成员维度「当前指标 + 相对上次变化」:
+     *   - 报告确认入库后,取该成员最新一份报告 vs 次新报告的同名指标,计算 delta
+     *   - 方案生成(LangGraph)与前端指标卡片可直接读取,无需每次跨表 diff
+     * 幂等:CREATE TABLE IF NOT EXISTS
+     */
+    private void addMemberIndicatorCurrentTable() {
+        try {
+            jdbcTemplate.execute(
+                "CREATE TABLE IF NOT EXISTS `member_indicator_current` (" +
+                "`id` bigint NOT NULL AUTO_INCREMENT," +
+                "`member_id` bigint NOT NULL COMMENT '家庭成员ID(family_members.id)'," +
+                "`definition_id` bigint NOT NULL COMMENT '指标定义ID(indicator_definitions.id)'," +
+                "`domain` varchar(50) NOT NULL DEFAULT 'physical' COMMENT '指标域(body/mind/physical/wisdom)'," +
+                "`category` varchar(50) DEFAULT NULL COMMENT '指标分类'," +
+                "`name` varchar(100) NOT NULL COMMENT '指标名称'," +
+                "`value` varchar(200) DEFAULT NULL COMMENT '最新值'," +
+                "`numeric_value` decimal(12,4) DEFAULT NULL COMMENT '最新数值(可计算)'," +
+                "`previous_value` varchar(200) DEFAULT NULL COMMENT '上一份报告的同类指标值'," +
+                "`previous_numeric` decimal(12,4) DEFAULT NULL COMMENT '上一份报告数值'," +
+                "`delta` decimal(12,4) DEFAULT NULL COMMENT 'numeric_value - previous_numeric'," +
+                "`trend` varchar(16) DEFAULT NULL COMMENT '变化方向: up/down/flat/new/na'," +
+                "`latest_report_id` bigint DEFAULT NULL COMMENT '最新来源报告ID'," +
+                "`previous_report_id` bigint DEFAULT NULL COMMENT '上次来源报告ID'," +
+                "`report_date` date DEFAULT NULL COMMENT '最新报告日期'," +
+                "`recorded_at` datetime DEFAULT CURRENT_TIMESTAMP," +
+                "`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
+                "PRIMARY KEY (`id`)," +
+                "UNIQUE KEY `uk_member_definition` (`member_id`, `definition_id`)," +
+                "KEY `idx_member_domain` (`member_id`, `domain`)," +
+                "KEY `idx_report` (`latest_report_id`)" +
+                ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成员指标当前值+相对上次变化'");
+            log.info("迁移378: member_indicator_current 表就绪");
+        } catch (Exception e) {
+            log.warn("迁移378: 创建 member_indicator_current 表失败或已存在: {}", e.getMessage());
+        }
+    }
+
     private void addUserContentWeightsTable() {
         try {
             jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS user_content_weights (" +

+ 65 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -34,6 +34,7 @@ import com.etotem.cfc.service.FoodService;
 import com.etotem.cfc.service.HealthAnalysisService;
 import com.etotem.cfc.service.HealthReportDraftService;
 import com.etotem.cfc.service.HealthReportService;
+import com.etotem.cfc.service.MemberIndicatorCurrentService;
 import com.etotem.cfc.service.BasicQuotaService;
 import com.etotem.cfc.service.NutritionDeficiencyService;
 import com.etotem.cfc.service.PdfParseService;
@@ -171,6 +172,9 @@ public class HealthReportController {
     @Resource
     private com.etotem.cfc.service.PurchaseService purchaseService;
 
+    @Resource
+    private MemberIndicatorCurrentService memberIndicatorCurrentService;
+
     @Resource
     private com.etotem.cfc.service.HealthPlanService healthPlanService;
 
@@ -414,6 +418,55 @@ public class HealthReportController {
             return Result.success(java.util.Collections.emptyList());
         }
     }
+    /**
+     * [admin] 回填成员指标当前值 diff(对存量未走新确认流程的报告手动触发)
+     * 入参: memberId(必填);调用 healthReportService.rebuildForMember
+     */
+    @Operation(summary = "回填成员指标当前值(admin)")
+    @PostMapping("/indicators/rebuild")
+    public Result<Map<String, Object>> rebuildIndicatorCurrent(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute(value = "role", required = false) String role) {
+        Long memberId = ParamUtils.getLong(params.get("memberId"));
+        if (memberId == null) return Result.error("memberId不能为空");
+        if (!"admin".equals(role)) return Result.error("权限不足");
+        try {
+            healthReportService.rebuildForMember(memberId);
+            Map<String, Object> out = new java.util.HashMap<>();
+            out.put("memberId", memberId);
+            out.put("rebuilt", true);
+            return Result.success(out);
+        } catch (Exception e) {
+            log.warn("回填指标当前值失败 memberId={}: {}", memberId, e.getMessage());
+            return Result.error("回填失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 获取成员指标当前值 + 相对上一份报告的变化 delta(用于指标卡片与方案生成)
+     * 数据来源:member_indicator_current(报告确认/编辑时由 refreshMemberIndicatorCurrent 写入)
+     */
+    @Operation(summary = "获取成员指标当前值与变化")
+    @PostMapping("/indicators/current")
+    public Result<List<Map<String, Object>>> getMemberIndicatorCurrent(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Long memberId = ParamUtils.getLong(params.get("memberId"), currentMemberId);
+        String domain = (String) params.get("domain");
+        if (memberId == null) {
+            return Result.error("memberId不能为空");
+        }
+        try {
+            List<Map<String, Object>> list = memberIndicatorCurrentService.getCurrentIndicators(memberId, domain);
+            return Result.success(list);
+        } catch (Exception e) {
+            log.warn("获取成员指标当前值失败 memberId={}: {}", memberId, e.getMessage());
+            return Result.success(java.util.Collections.emptyList());
+        }
+    }
+
 
     /**
      * 获取孩子身体维度数据详情(健康指标解读)
@@ -1266,6 +1319,12 @@ public class HealthReportController {
                 } catch (Exception e) {
                     log.warn("刷新7维健康评分失败: {}", e.getMessage());
                 }
+                try {
+                    healthReportService.refreshMemberIndicatorCurrent(subjectId, created.getId());
+                    log.info("已刷新成员{}的指标当前值diff", subjectId);
+                } catch (Exception e) {
+                    log.warn("刷新指标当前值diff失败: {}", e.getMessage());
+                }
                 try {
                     foodRecommendService.recalculateForUser(subjectId);
                     log.info("已刷新成员{}的食材推荐指数", subjectId);
@@ -1283,6 +1342,12 @@ public class HealthReportController {
                     } catch (Exception e) {
                         log.warn("刷新7维健康评分失败: {}", e.getMessage());
                     }
+                    try {
+                        healthReportService.refreshMemberIndicatorCurrent(matchedId, created.getId());
+                        log.info("已刷新成员{}的指标当前值diff", matchedId);
+                    } catch (Exception e) {
+                        log.warn("刷新指标当前值diff失败: {}", e.getMessage());
+                    }
                     try {
                         foodRecommendService.recalculateForUser(matchedId);
                         log.info("已刷新成员{}的食材推荐指数", matchedId);

+ 64 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/MemberIndicatorCurrent.java

@@ -0,0 +1,64 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import java.math.BigDecimal;
+import java.util.Date;
+
+@TableName("member_indicator_current")
+public class MemberIndicatorCurrent {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long memberId;
+    private Long definitionId;
+    private String domain;
+    private String category;
+    private String name;
+    private String value;
+    private BigDecimal numericValue;
+    private String previousValue;
+    private BigDecimal previousNumeric;
+    private BigDecimal delta;
+    private String trend;
+    private Long latestReportId;
+    private Long previousReportId;
+    private Date reportDate;
+    private Date recordedAt;
+    private Date updatedAt;
+    // getters/setters omitted for brevity - use Lombok or explicit
+    public Long getId() { return id; }
+    public void setId(Long id) { this.id = id; }
+    public Long getMemberId() { return memberId; }
+    public void setMemberId(Long memberId) { this.memberId = memberId; }
+    public Long getDefinitionId() { return definitionId; }
+    public void setDefinitionId(Long definitionId) { this.definitionId = definitionId; }
+    public String getDomain() { return domain; }
+    public void setDomain(String domain) { this.domain = domain; }
+    public String getCategory() { return category; }
+    public void setCategory(String category) { this.category = category; }
+    public String getName() { return name; }
+    public void setName(String name) { this.name = name; }
+    public String getValue() { return value; }
+    public void setValue(String value) { this.value = value; }
+    public BigDecimal getNumericValue() { return numericValue; }
+    public void setNumericValue(BigDecimal numericValue) { this.numericValue = numericValue; }
+    public String getPreviousValue() { return previousValue; }
+    public void setPreviousValue(String previousValue) { this.previousValue = previousValue; }
+    public BigDecimal getPreviousNumeric() { return previousNumeric; }
+    public void setPreviousNumeric(BigDecimal previousNumeric) { this.previousNumeric = previousNumeric; }
+    public BigDecimal getDelta() { return delta; }
+    public void setDelta(BigDecimal delta) { this.delta = delta; }
+    public String getTrend() { return trend; }
+    public void setTrend(String trend) { this.trend = trend; }
+    public Long getLatestReportId() { return latestReportId; }
+    public void setLatestReportId(Long latestReportId) { this.latestReportId = latestReportId; }
+    public Long getPreviousReportId() { return previousReportId; }
+    public void setPreviousReportId(Long previousReportId) { this.previousReportId = previousReportId; }
+    public Date getReportDate() { return reportDate; }
+    public void setReportDate(Date reportDate) { this.reportDate = reportDate; }
+    public Date getRecordedAt() { return recordedAt; }
+    public void setRecordedAt(Date recordedAt) { this.recordedAt = recordedAt; }
+    public Date getUpdatedAt() { return updatedAt; }
+    public void setUpdatedAt(Date updatedAt) { this.updatedAt = updatedAt; }
+}

+ 26 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/MemberIndicatorCurrentMapper.java

@@ -0,0 +1,26 @@
+package com.etotem.cfc.mapper;
+
+import com.etotem.cfc.entity.MemberIndicatorCurrent;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Update;
+import java.util.List;
+
+@Mapper
+public interface MemberIndicatorCurrentMapper {
+    @Select("SELECT * FROM member_indicator_current WHERE member_id = #{memberId} AND domain = #{domain} ORDER BY updated_at DESC")
+    List<MemberIndicatorCurrent> selectByMemberAndDomain(@Param("memberId") Long memberId, @Param("domain") String domain);
+
+    @Select("SELECT * FROM member_indicator_current WHERE member_id = #{memberId} AND definition_id = #{definitionId}")
+    MemberIndicatorCurrent selectByMemberAndDefinition(@Param("memberId") Long memberId, @Param("definitionId") Long definitionId);
+
+    @Select("SELECT * FROM member_indicator_current WHERE member_id = #{memberId} ORDER BY domain ASC, name ASC")
+    List<MemberIndicatorCurrent> selectByMemberAll(@Param("memberId") Long memberId);
+
+    @Update("UPDATE member_indicator_current SET value=#{value}, numeric_value=#{numericValue}, previous_value=#{previousValue}, previous_numeric=#{previousNumeric}, delta=#{delta}, trend=#{trend}, latest_report_id=#{latestReportId}, previous_report_id=#{previousReportId}, report_date=#{reportDate}, updated_at=NOW() WHERE id=#{id}")
+    int updateById(MemberIndicatorCurrent item);
+
+    @Update("INSERT INTO member_indicator_current (member_id, definition_id, domain, category, name, value, numeric_value, previous_value, previous_numeric, delta, trend, latest_report_id, previous_report_id, report_date) VALUES (#{memberId}, #{definitionId}, #{domain}, #{category}, #{name}, #{value}, #{numericValue}, #{previousValue}, #{previousNumeric}, #{delta}, #{trend}, #{latestReportId}, #{previousReportId}, #{reportDate}) ON DUPLICATE KEY UPDATE value=#{value}, numeric_value=#{numericValue}, previous_value=#{previousValue}, previous_numeric=#{previousNumeric}, delta=#{delta}, trend=#{trend}, latest_report_id=#{latestReportId}, previous_report_id=#{previousReportId}, report_date=#{reportDate}, updated_at=NOW()")
+    int upsert(MemberIndicatorCurrent item);
+}

+ 135 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java

@@ -35,7 +35,9 @@ 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;
 
@@ -112,6 +114,10 @@ public class HealthReportService {
     @Resource
     private FoodMapper foodMapper;
 
+    @Resource
+    @org.springframework.context.annotation.Lazy
+    private MemberIndicatorCurrentService memberIndicatorCurrentService;
+
     @Resource
     private com.etotem.cfc.service.NormalizeIndicatorPipeline normalizeIndicatorPipeline;
 
@@ -2149,7 +2155,9 @@ public class HealthReportService {
         List<Map<String, Object>> result = new java.util.ArrayList<>();
         for (IndicatorDefinition def : defs) {
             LambdaQueryWrapper<IndicatorValue> w = new LambdaQueryWrapper<>();
-            w.eq(IndicatorValue::getSourceId, memberId)
+            // 口径:写入端 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");
@@ -2213,4 +2221,129 @@ public class HealthReportService {
         }
         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;
+        }
+    }
+
+}

+ 63 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/MemberIndicatorCurrentService.java

@@ -0,0 +1,63 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.MemberIndicatorCurrent;
+import com.etotem.cfc.mapper.MemberIndicatorCurrentMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+@Slf4j
+@Service
+public class MemberIndicatorCurrentService {
+    @Resource
+    private MemberIndicatorCurrentMapper mapper;
+
+    public List<MemberIndicatorCurrent> getByMemberAndDomain(Long memberId, String domain) {
+        return mapper.selectByMemberAndDomain(memberId, domain);
+    }
+
+    public List<Map<String, Object>> getCurrentIndicators(Long memberId, String domain) {
+        List<MemberIndicatorCurrent> list;
+        if (domain != null && !domain.isEmpty()) {
+            list = mapper.selectByMemberAndDomain(memberId, domain);
+        } else {
+            list = mapper.selectByMemberAll(memberId);
+        }
+        List<Map<String, Object>> result = new java.util.ArrayList<>();
+        for (MemberIndicatorCurrent item : list) {
+            Map<String, Object> m = new java.util.HashMap<>();
+            m.put("name", item.getName());
+            m.put("code", item.getDomain());
+            m.put("category", item.getCategory());
+            m.put("value", item.getValue());
+            m.put("numericValue", item.getNumericValue());
+            m.put("previousValue", item.getPreviousValue());
+            m.put("previousNumeric", item.getPreviousNumeric());
+            m.put("delta", item.getDelta());
+            m.put("trend", item.getTrend());
+            m.put("latestReportId", item.getLatestReportId());
+            m.put("previousReportId", item.getPreviousReportId());
+            m.put("reportDate", item.getReportDate());
+            result.add(m);
+        }
+        return result;
+    }
+
+    public void upsert(MemberIndicatorCurrent item) {
+        mapper.upsert(item);
+    }
+
+    public void bulkUpsert(List<MemberIndicatorCurrent> items) {
+        for (MemberIndicatorCurrent item : items) {
+            try {
+                mapper.upsert(item);
+            } catch (Exception e) {
+                log.warn("指标当前值写入失败 member={}, defId={}: {}", item.getMemberId(), item.getDefinitionId(), e.getMessage());
+            }
+        }
+    }
+}

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ReportCollectService.java

@@ -703,6 +703,12 @@ public class ReportCollectService {
             } catch (Exception e) {
                 log.warn("刷新7维健康评分失败 memberId={}", subjectId, e);
             }
+            try {
+                healthReportService.refreshMemberIndicatorCurrent(subjectId, created.getId());
+                log.info("已刷新成员{}的指标当前值diff", subjectId);
+            } catch (Exception e) {
+                log.warn("刷新指标当前值diff失败 memberId={}", subjectId, e);
+            }
             try {
                 foodRecommendService.recalculateForUser(subjectId);
             } catch (Exception e) {

+ 53 - 0
cfc-langgraph/app/graphs/health_plan_graph.py

@@ -87,6 +87,7 @@ class PlanState(TypedDict):
     members_info: dict
     indicators: list
     abnormal_indicators: list
+    indicator_changes: list
     kb_results: list
     overview: str
     nutrition_section: str
@@ -127,6 +128,7 @@ async def collect_data(state: PlanState) -> dict:
     # 1b. 获取健康指标
     all_indicators = []
     abnormal_list = []
+    indicator_changes = []  # 成员指标"当前值 vs 上次"变化 (来自 member_indicator_current)
     for member in members_info:
         reports = await java.get_member_reports(int(member["id"]))
         if not reports:
@@ -139,6 +141,22 @@ async def collect_data(state: PlanState) -> dict:
             ind["_member_name"] = member["name"]
             all_indicators.append(ind)
 
+        # 拉取指标变化 (delta/trend), 即使无报告也可能有历史指标
+        cur_list = await java.get_member_indicator_current(int(member["id"]))
+        for cur in cur_list:
+            delta = cur.get("delta")
+            trend = cur.get("trend", "")
+            if delta is not None and trend in ("up", "down"):
+                indicator_changes.append({
+                    "member": member["name"],
+                    "member_id": member["id"],
+                    "indicator": cur.get("name", ""),
+                    "current": cur.get("value", ""),
+                    "previous": cur.get("previousValue", ""),
+                    "delta": delta,
+                    "trend": trend,
+                })
+
     # 1c. 识别异常指标
     known_indicators = {}
     for ind in all_indicators:
@@ -167,6 +185,31 @@ async def collect_data(state: PlanState) -> dict:
         if status in ("abnormal", "high", "low", "偏高", "偏低"):
             abnormal_list.append(entry)
 
+    # 1c-2. 把"指标变化(delta 较大)"也视为值得关注的变化点, 并入 abnormal_list
+    _change_seen = set()
+    for ch in indicator_changes:
+        key = (ch["member"], ch["indicator"])
+        if key in _change_seen:
+            continue
+        _change_seen.add(key)
+        try:
+            d = float(ch["delta"])
+        except (TypeError, ValueError):
+            continue
+        if abs(d) >= 10:  # 显著变化阈值 (按指标量级可调)
+            arrow = "上升" if d > 0 else "下降"
+            abnormal_list.append({
+                "member": ch["member"],
+                "indicator": ch["indicator"],
+                "value": str(ch.get("current", "")),
+                "unit": "",
+                "ref_range": "上次: %s" % ch.get("previous", ""),
+                "description": "指标较上次%s %.1f (当前 %.1f, 上次 %s)" % (
+                    arrow, abs(d), float(ch["current"]) if ch.get("current") is not None else -1, ch.get("previous", "")),
+                "suggestion": "该指标近期%s明显, 方案中应体现针对性调整" % arrow,
+                "_is_change": True,
+            })
+
     # 1d. 知识库检索
     kb_results = []
     queries = set()
@@ -183,6 +226,7 @@ async def collect_data(state: PlanState) -> dict:
         "members_info": members_info,
         "indicators": all_indicators,
         "abnormal_indicators": abnormal_list,
+        "indicator_changes": indicator_changes,
         "kb_results": kb_results,
     }
 
@@ -210,6 +254,15 @@ async def build_prompt(state: PlanState) -> str:
             if ind.get("description"):
                 parts.append(f"  说明: {ind['description']}")
 
+    if state.get("indicator_changes"):
+        parts.append("\n## 指标较上次报告的变化(方案必须考虑这些趋势)")
+        for ch in state["indicator_changes"][:12]:
+            arrow = "上升" if ch.get("trend") == "up" else "下降"
+            parts.append(
+                f"- {ch[member]} - {ch[indicator]}: {ch.get(current,)} "
+                f"(上次: {ch.get(previous,)}, {arrow} {abs(ch.get(delta, 0))})"
+            )
+
     if state["kb_results"]:
         parts.append("\n## 知识库参考")
         for r in state["kb_results"][:6]:

+ 9 - 0
cfc-langgraph/app/tools/java_client.py

@@ -217,3 +217,12 @@ class JavaClient:
             return data["data"]
         return {}
 
+    async def get_member_indicator_current(self, member_id: int) -> list[dict]:
+        """获取成员指标当前值+相对上次变化(member_indicator_current,含 delta/trend)"""
+        client = await self._get_client()
+        resp = await client.post("/api/health/indicators/current", json={"memberId": member_id})
+        data = resp.json()
+        if data.get("code") == 200:
+            return data.get("data", [])
+        return []
+