Просмотр исходного кода

merge(dimension): 统一维度体系收尾 gapfix(T-A 回滚脚本+T-B 后端去硬编码+T-C demo兜底清除+T-D 能量沙盘切注册中心)合入 cfclub

- T-A: 移植 migrate_dimension_unified_rollback.sql(备份表名 _bak_20261105 与 forward 成对,主回滚精确命中 7 个 body.* 统一码)
- T-B: DimensionScoreServiceImpl 删 DIMENSIONS 字面量,维度清单改经 DimensionRegistryService 取 body 子维,key 用统一码 body.*,getHealthDimension 双索引(legacy+统一码)兼容 DB 过渡态
- T-C: health-main 删 demoBodyDims/demoBodyScores 等硬编码与 [72,68,75,80,70,78,82] 兜底,访客示例雷达改经 useDimensionRegistry 配置取数,无配置显示空态引导
- T-D: EnergyService 注入 DimensionRegistryService,五行 element 经 wuxing_rule_ref 覆盖(与旧表同值),五行规则体零改写;wuxing-sandbox 经注册中心取五维,未就绪保持原渲染
- QA 独立复核 ALL PASS(34 tests 0 fail,路由 NoOne),合并预演零冲突(8 文件与 cfclub 独有 commit 文件零交集)
Agnes Agent 5 дней назад
Родитель
Сommit
ac27af4877

+ 48 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/EnergyService.java

@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.dto.CfBalanceView;
 import com.etotem.cfc.dto.EnergySandboxDTO;
 import com.etotem.cfc.dto.MemberEnergyDTO;
+import com.etotem.cfc.dto.DimensionNode;
 import com.etotem.cfc.entity.*;
 import com.etotem.cfc.mapper.*;
 import com.alibaba.fastjson.JSON;
@@ -93,6 +94,10 @@ public class EnergyService {
     @Resource
     private FamilyMetricAggConfigMapper familyMetricAggConfigMapper;
 
+    /** 统一维度注册中心:五维根节点五行归属(wuxing_rule_ref)的权威来源 */
+    @Resource
+    private DimensionRegistryService dimensionRegistryService;
+
     /**
      * 计算整个家庭的能量沙盘数据
      *
@@ -2279,10 +2284,53 @@ public class EnergyService {
                     new LambdaQueryWrapper<EnergyDimension>()
                             .orderByAsc(EnergyDimension::getSortOrder)
             );
+            // 统一维度体系:五维五行归属改以注册中心为准(root 节点 wuxing_rule_ref,与旧
+            // energy_dimension.element 同值,迁移448 已对齐)。注册中心未就绪或维度缺失时
+            // 保留旧表值,保证五行回归结果不变、返回结构不变。
+            enrichWuxingElementFromRegistry(dimCache);
         }
         return dimCache;
     }
 
+    /**
+     * 统一维度体系:按注册中心 5 根节点的 wuxing_rule_ref 覆盖五行元素归属(element)。
+     *
+     * <p>维度 code(body/mind/wisdom/action/wealth)与统一 5 根同码,无需 alias 翻译;
+     * id/name/icon/sortOrder/status 仍以 energy_dimension 表为准(energy_log/energy_balance
+     * 以 dimension_id 关联该表 id,切换仅限五行归属来源,保证回归不变)。</p>
+     *
+     * @param dims 已加载的旧表维度列表(就地更新 element)
+     */
+    private void enrichWuxingElementFromRegistry(List<EnergyDimension> dims) {
+        if (dims == null || dims.isEmpty() || dimensionRegistryService == null) {
+            return;
+        }
+        try {
+            List<DimensionNode> roots = dimensionRegistryService.getRoots();
+            if (roots == null || roots.isEmpty()) {
+                return;
+            }
+            Map<String, String> elementByCode = new HashMap<>();
+            for (DimensionNode root : roots) {
+                if (root == null || root.getCode() == null || root.getWuxingRuleRef() == null) {
+                    continue;
+                }
+                elementByCode.put(root.getCode(), root.getWuxingRuleRef());
+            }
+            for (EnergyDimension d : dims) {
+                if (d == null || d.getCode() == null) {
+                    continue;
+                }
+                String element = elementByCode.get(d.getCode());
+                if (element != null) {
+                    d.setElement(element);
+                }
+            }
+        } catch (Exception e) {
+            log.warn("EnergyService.enrichWuxingElementFromRegistry: 注册中心五行归属补齐失败(忽略): {}", e.getMessage());
+        }
+    }
+
     /**
      * 按code查维度
      */

+ 97 - 48
cfc-backend/src/main/java/com/etotem/cfc/service/impl/DimensionScoreServiceImpl.java

@@ -3,6 +3,9 @@ package com.etotem.cfc.service.impl;
 import com.etotem.cfc.dto.HealthDimensionVO;
 import com.etotem.cfc.dto.DimensionUploadVO;
 import com.etotem.cfc.dto.HealthDimensionQuestionnaireVO;
+import com.etotem.cfc.dto.DimensionNode;
+import com.etotem.cfc.service.DimensionRegistryService;
+import com.etotem.cfc.service.DimensionCodeMapper;
 import com.etotem.cfc.entity.HealthDimensionScore;
 import com.etotem.cfc.entity.HealthDataSourceRecord;
 import com.etotem.cfc.entity.HealthGutFlora;
@@ -38,15 +41,11 @@ import java.util.stream.Collectors;
 @Service
 public class DimensionScoreServiceImpl implements DimensionScoreService {
 
-    private static final List<String[]> DIMENSIONS = Arrays.asList(
-        new String[]{"growth",    "生长发育"},
-        new String[]{"sleep",     "睡眠质量"},
-        new String[]{"vision",    "视力健康"},
-        new String[]{"immunity",  "免疫力"},
-        new String[]{"nutrition", "营养均衡"},
-        new String[]{"gut",       "肠胃健康"},
-        new String[]{"exercise",  "运动活力"}
-    );
+    @Resource
+    private DimensionRegistryService dimensionRegistryService;
+
+    @Resource
+    private DimensionCodeMapper dimensionCodeMapper;
 
     @Resource
     private HealthDimensionScoreService scoreService;
@@ -76,20 +75,57 @@ public class DimensionScoreServiceImpl implements DimensionScoreService {
     @Resource
     private DimensionEnergySyncService dimensionEnergySyncService;
 
+    /**
+     * 统一维度体系中 7 个身体健康子维度(code=body.growth...body.exercise),来源为注册中心
+     * body 根节点子维度(nodeKind=sub),替代原前端/后端硬编码裸名单(growth/sleep/...)。
+     *
+     * <p>顺序与注册中心 seed 一致(growth/sleep/vision/immunity/nutrition/gut/exercise),
+     * 指标节点(nodeKind=indicator,P1-T3 起混排挂载)不作为健康雷达轴,跳过。</p>
+     *
+     * @return 注册中心 body 根节点的子维度节点列表;未就绪时返回空列表(调用方按空处理)
+     */
+    private List<DimensionNode> bodyDimensions() {
+        DimensionNode body = dimensionRegistryService.getNodeByCode("body");
+        if (body == null || body.getChildren() == null) {
+            return new ArrayList<>();
+        }
+        List<DimensionNode> result = new ArrayList<>();
+        for (DimensionNode child : body.getChildren()) {
+            if (child == null) {
+                continue;
+            }
+            if ("indicator".equals(child.getNodeKind())) {
+                continue;
+            }
+            result.add(child);
+        }
+        return result;
+    }
+
     @Override
     public HealthDimensionVO getHealthDimension(Long memberId) {
         List<HealthDimensionScore> latest = scoreService.getLatestScores(memberId);
         Map<String, HealthDimensionScore> scoreMap = latest.stream()
             .collect(Collectors.toMap(HealthDimensionScore::getDimension, s -> s, (a, b) -> a));
 
+        // 统一维度体系整改:维度清单来源改为注册中心(body 根下子维度,统一 code=body.growth...),
+        // 兼容 DB 仍存旧裸名(growth/sleep/...)的过渡态:scoreMap 同时按 legacy 原文 + 统一码建索引。
+        Map<String, HealthDimensionScore> unifiedScoreMap = new HashMap<>(scoreMap);
+        for (Map.Entry<String, HealthDimensionScore> e : scoreMap.entrySet()) {
+            String unified = dimensionRegistryService.resolveLegacyToUnified(e.getKey());
+            if (unified != null) {
+                unifiedScoreMap.putIfAbsent(unified, e.getValue());
+            }
+        }
+
         List<HealthDimensionVO.DimensionItem> items = new ArrayList<>();
         BigDecimal totalScore = BigDecimal.ZERO;
         int validCount = 0;
 
-        for (String[] dim : DIMENSIONS) {
-            String code = dim[0];
-            String label = dim[1];
-            HealthDimensionScore found = scoreMap.get(code);
+        for (DimensionNode dim : bodyDimensions()) {
+            String code = dim.getCode();
+            String label = dim.getName();
+            HealthDimensionScore found = unifiedScoreMap.get(code);
 
             HealthDimensionVO.DimensionItem item = new HealthDimensionVO.DimensionItem();
             item.setDimension(code);
@@ -101,7 +137,7 @@ public class DimensionScoreServiceImpl implements DimensionScoreService {
                 item.setDataSource(found.getDataSource());
                 item.setTier(found.getTier());
                 item.setLevel(calcLevel(found.getScore()));
-                List<HealthDimensionVO.DimensionItem> history = getDimensionHistory(memberId, code, 2);
+                List<HealthDimensionVO.DimensionItem> history = getDimensionHistory(memberId, resolveNormLegacy(code), 2);
                 if (history.size() >= 2) {
                     BigDecimal earlierScore = history.get(1).getScore();
                     BigDecimal latestScore = history.get(0).getScore();
@@ -144,7 +180,7 @@ public class DimensionScoreServiceImpl implements DimensionScoreService {
                 String gender = (member.getGender() != null && !member.getGender().isEmpty())
                     ? member.getGender() : "all";
                 for (HealthDimensionVO.DimensionItem item : items) {
-                    String dimCode = item.getDimension();
+                    String dimCode = resolveNormLegacy(item.getDimension());
                     HealthNormReference norm = normMapper.selectNorm(dimCode, gender, (int) ageMonths);
                     if (norm != null && norm.getPercentile50() != null) {
                         item.setScore(BigDecimal.valueOf(norm.getPercentile50()));
@@ -400,47 +436,47 @@ public class DimensionScoreServiceImpl implements DimensionScoreService {
             List<HealthDiseaseRisk> diseaseRisks) {
 
         Map<String, List<Integer>> dimValues = new HashMap<>();
-        for (String[] dim : DIMENSIONS) {
-            dimValues.put(dim[0], new ArrayList<>());
+        for (DimensionNode dim : bodyDimensions()) {
+            dimValues.put(dim.getCode(), new ArrayList<>());
         }
 
         if (report.getGutHealthScore() != null) {
-            dimValues.get("immunity").add(report.getGutHealthScore());
-            dimValues.get("gut").add(report.getGutHealthScore());
+            dimValues.get("body.immunity").add(report.getGutHealthScore());
+            dimValues.get("body.gut").add(report.getGutHealthScore());
         }
         if (report.getChronicDiseaseScore() != null) {
             int inverted = 100 - report.getChronicDiseaseScore();
-            dimValues.get("growth").add(inverted);
-            dimValues.get("nutrition").add(inverted);
+            dimValues.get("body.growth").add(inverted);
+            dimValues.get("body.nutrition").add(inverted);
         }
         if (report.getNutritionScore() != null) {
-            dimValues.get("nutrition").add(report.getNutritionScore());
+            dimValues.get("body.nutrition").add(report.getNutritionScore());
         }
         if (report.getGutBalanceScore() != null) {
-            dimValues.get("gut").add(report.getGutBalanceScore());
+            dimValues.get("body.gut").add(report.getGutBalanceScore());
         }
         if (report.getGutDiversityScore() != null) {
-            dimValues.get("immunity").add(report.getGutDiversityScore());
-            dimValues.get("gut").add(report.getGutDiversityScore());
+            dimValues.get("body.immunity").add(report.getGutDiversityScore());
+            dimValues.get("body.gut").add(report.getGutDiversityScore());
         }
         if (report.getBeneficialBacteriaScore() != null) {
-            dimValues.get("immunity").add(report.getBeneficialBacteriaScore());
-            dimValues.get("gut").add(report.getBeneficialBacteriaScore());
+            dimValues.get("body.immunity").add(report.getBeneficialBacteriaScore());
+            dimValues.get("body.gut").add(report.getBeneficialBacteriaScore());
         }
         if (report.getHarmfulBacteriaScore() != null) {
             int inverted = 100 - report.getHarmfulBacteriaScore();
-            dimValues.get("immunity").add(inverted);
-            dimValues.get("gut").add(inverted);
+            dimValues.get("body.immunity").add(inverted);
+            dimValues.get("body.gut").add(inverted);
         }
         if (report.getCoreSpeciesScore() != null) {
-            dimValues.get("gut").add(report.getCoreSpeciesScore());
+            dimValues.get("body.gut").add(report.getCoreSpeciesScore());
         }
         if (report.getGutAge() != null) {
             try {
                 int gutAgeYears = Integer.parseInt(report.getGutAge().replaceAll("[^0-9]", ""));
                 int gutAgeScore = Math.max(0, Math.min(100, 100 - gutAgeYears * 2));
-                dimValues.get("sleep").add(gutAgeScore);
-                dimValues.get("gut").add(gutAgeScore);
+                dimValues.get("body.sleep").add(gutAgeScore);
+                dimValues.get("body.gut").add(gutAgeScore);
             } catch (NumberFormatException ignored) {}
         }
 
@@ -452,15 +488,15 @@ public class DimensionScoreServiceImpl implements DimensionScoreService {
             if (val < 0) continue;
 
             if (cat.contains("神经递质")) {
-                dimValues.get("sleep").add(val);
+                dimValues.get("body.sleep").add(val);
             } else if ("短链脂肪酸".equals(cat)) {
-                dimValues.get("nutrition").add(val);
-                dimValues.get("gut").add(val);
+                dimValues.get("body.nutrition").add(val);
+                dimValues.get("body.gut").add(val);
             } else if ("肠道屏障".equals(cat)) {
-                dimValues.get("immunity").add(val);
-                dimValues.get("gut").add(val);
+                dimValues.get("body.immunity").add(val);
+                dimValues.get("body.gut").add(val);
             } else if (cat.contains("维生素") || cat.contains("矿物质") || cat.contains("微量")) {
-                dimValues.get("nutrition").add(val);
+                dimValues.get("body.nutrition").add(val);
             }
         }
 
@@ -476,21 +512,21 @@ public class DimensionScoreServiceImpl implements DimensionScoreService {
             int invertedScore = 100 - Math.min(riskVal, 100);
             String name = risk.getDiseaseName();
             if (name != null && (name.contains("代谢") || name.contains("肥胖") || name.contains("超重"))) {
-                dimValues.get("growth").add(invertedScore);
-                dimValues.get("nutrition").add(invertedScore);
+                dimValues.get("body.growth").add(invertedScore);
+                dimValues.get("body.nutrition").add(invertedScore);
             }
             if (name != null && (name.contains("感染") || name.contains("炎症"))) {
-                dimValues.get("immunity").add(invertedScore);
+                dimValues.get("body.immunity").add(invertedScore);
             }
-            dimValues.get("gut").add(invertedScore);
+            dimValues.get("body.gut").add(invertedScore);
         }
 
         Map<String, Integer> result = new HashMap<>();
-        for (String[] dim : DIMENSIONS) {
-            List<Integer> vals = dimValues.get(dim[0]);
+        for (DimensionNode dim : bodyDimensions()) {
+            List<Integer> vals = dimValues.get(dim.getCode());
             if (vals != null && !vals.isEmpty()) {
                 int avg = (int) vals.stream().mapToInt(v -> v).average().orElse(0);
-                result.put(dim[0], Math.min(100, Math.max(0, avg)));
+                result.put(dim.getCode(), Math.min(100, Math.max(0, avg)));
             }
         }
         return result;
@@ -507,8 +543,21 @@ public class DimensionScoreServiceImpl implements DimensionScoreService {
         }
     }
 
+    private String resolveNormLegacy(String dimension) {
+        if (dimension == null || dimension.isEmpty()) {
+            return dimension;
+        }
+        List<String> legacyCodes = dimensionCodeMapper.resolveLegacy(dimension);
+        if (legacyCodes != null && !legacyCodes.isEmpty()) {
+            return legacyCodes.get(0);
+        }
+        return dimension;
+    }
+
     private Integer calcPercentile(String dimension, int score) {
-        HealthNormReference norm = normMapper.selectNorm(dimension, "all", 120);
+        // 统一维度体系:常模表按 legacy 维度码(growth/...)建索引,统一 code(body.growth)需先回解析
+        String normDimension = resolveNormLegacy(dimension);
+        HealthNormReference norm = normMapper.selectNorm(normDimension, "all", 120);
         if (norm == null) return null;
         if (norm.getPercentile95() != null && score >= norm.getPercentile95()) return 95;
         if (norm.getPercentile85() != null && score >= norm.getPercentile85()) return 85;
@@ -551,8 +600,8 @@ public class DimensionScoreServiceImpl implements DimensionScoreService {
                 return norm.getPercentile50();
             }
         } else {
-            // 身体/健康维度走 health_norm_reference 表
-            HealthNormReference norm = normMapper.selectNorm(dimension, gender, (int) ageMonths);
+            // 身体/健康维度走 health_norm_reference 表(常模表按 legacy 码建索引,统一 code 需回解析)
+            HealthNormReference norm = normMapper.selectNorm(resolveNormLegacy(dimension), gender, (int) ageMonths);
             if (norm != null) {
                 return norm.getPercentile50();
             }

+ 25 - 0
cfc-backend/src/main/resources/db/migrate_dimension_unified_rollback.sql

@@ -0,0 +1,25 @@
+-- ============================================================
+-- 统一维度体系 P0-T5 回滚脚本
+-- 作用:将 health_dimension_scores.dimension 由 body.<old> 反向还原为旧 7 维裸名
+--       (与主线 forward migrate_dimension_unified.sql 精确成对)
+-- 执行方式:由 DBA 手动执行
+-- 幂等:UPDATE 仅命中 7 个 body.* 子维,重复执行无副作用(affected rows=0)
+-- 备份表:health_dimension_scores_bak_20261105(与 forward 步骤 1 同名同日戳)
+-- ============================================================
+
+-- 主回滚(精确逆反 forward 步骤 2):body.{sub} → {sub},仅命中已迁移的 7 行
+UPDATE health_dimension_scores
+   SET dimension = SUBSTRING(dimension, 6)
+ WHERE dimension IN (
+        'body.growth',
+        'body.sleep',
+        'body.vision',
+        'body.immunity',
+        'body.nutrition',
+        'body.gut',
+        'body.exercise'
+      );
+
+-- 备选整体还原(仅在确认需整体回退时执行;会覆盖当前表,请先确认无新增数据):
+-- TRUNCATE health_dimension_scores;
+-- INSERT INTO health_dimension_scores SELECT * FROM health_dimension_scores_bak_20261105;

+ 70 - 13
cfc-backend/src/test/java/com/etotem/cfc/integration/service/DimensionScoreServiceTest.java

@@ -3,8 +3,11 @@ package com.etotem.cfc.service;
 import com.etotem.cfc.dto.DimensionUploadVO;
 import com.etotem.cfc.dto.HealthDimensionQuestionnaireVO;
 import com.etotem.cfc.dto.HealthDimensionVO;
+import com.etotem.cfc.dto.DimensionNode;
 import com.etotem.cfc.entity.*;
 import com.etotem.cfc.mapper.HealthNormReferenceMapper;
+import com.etotem.cfc.service.DimensionRegistryService;
+import com.etotem.cfc.service.DimensionCodeMapper;
 import com.etotem.cfc.service.impl.DimensionScoreServiceImpl;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -14,6 +17,7 @@ import org.mockito.MockitoAnnotations;
 
 import java.math.BigDecimal;
 import java.sql.Date;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
@@ -57,6 +61,12 @@ public class DimensionScoreServiceTest {
     @Mock
     private DimensionEnergySyncService dimensionEnergySyncService;
 
+    @Mock
+    private DimensionRegistryService dimensionRegistryService;
+
+    @Mock
+    private DimensionCodeMapper dimensionCodeMapper;
+
     private DimensionScoreServiceImpl service;
 
     @BeforeEach
@@ -71,6 +81,52 @@ public class DimensionScoreServiceTest {
         setField(service, "familyMemberMapper", familyMemberMapper);
         setField(service, "danAssessmentResultMapper", danAssessmentResultMapper);
         setField(service, "dimensionEnergySyncService", dimensionEnergySyncService);
+        setField(service, "dimensionRegistryService", dimensionRegistryService);
+        setField(service, "dimensionCodeMapper", dimensionCodeMapper);
+        // 统一维度体系:注册中心 body 根节点 → 7 个健康子维度(统一码 body.growth...body.exercise)
+        mockBodyDimensions();
+    }
+
+    /** 构造注册中心 body 根下的一个子维度节点(nodeKind=sub)。 */
+    private DimensionNode dimNode(String code, String name) {
+        DimensionNode n = new DimensionNode();
+        n.setCode(code);
+        n.setName(name);
+        n.setNodeKind("sub");
+        return n;
+    }
+
+    /**
+     * Mock 注册中心:body 根节点挂载 7 个健康子维度(统一码),并 stub
+     * legacy→统一码 解析(兼容 DB 存旧裸名 growth/... 的过渡态)与
+     * 统一码→legacy 回解析(常模/历史表按 legacy 裸名建索引)。
+     */
+    private void mockBodyDimensions() {
+        DimensionNode body = new DimensionNode();
+        body.setCode("body");
+        body.setName("身体健康");
+        body.setNodeKind("root");
+        List<DimensionNode> children = new ArrayList<>();
+        children.add(dimNode("body.growth", "生长发育"));
+        children.add(dimNode("body.sleep", "睡眠质量"));
+        children.add(dimNode("body.vision", "视力健康"));
+        children.add(dimNode("body.immunity", "免疫力"));
+        children.add(dimNode("body.nutrition", "营养均衡"));
+        children.add(dimNode("body.gut", "肠胃健康"));
+        children.add(dimNode("body.exercise", "运动活力"));
+        body.setChildren(children);
+        when(dimensionRegistryService.getNodeByCode("body")).thenReturn(body);
+        // legacy 裸名 → 统一码(getHealthDimension 构建 unifiedScoreMap 时使用)
+        when(dimensionRegistryService.resolveLegacyToUnified("growth")).thenReturn("body.growth");
+        when(dimensionRegistryService.resolveLegacyToUnified("sleep")).thenReturn("body.sleep");
+        when(dimensionRegistryService.resolveLegacyToUnified("vision")).thenReturn("body.vision");
+        when(dimensionRegistryService.resolveLegacyToUnified("immunity")).thenReturn("body.immunity");
+        when(dimensionRegistryService.resolveLegacyToUnified("nutrition")).thenReturn("body.nutrition");
+        when(dimensionRegistryService.resolveLegacyToUnified("gut")).thenReturn("body.gut");
+        when(dimensionRegistryService.resolveLegacyToUnified("exercise")).thenReturn("body.exercise");
+        // 统一码 → legacy 回解析:默认返回空(resolveNormLegacy 原样返回入参),
+        // 使 calcPercentile/趋势历史仍按 legacy 裸名访问常模/历史表
+        when(dimensionCodeMapper.resolveLegacy(anyString())).thenReturn(Collections.emptyList());
     }
 
     private void setField(Object target, String fieldName, Object value) {
@@ -131,10 +187,10 @@ public class DimensionScoreServiceTest {
 
         HealthDimensionVO vo = service.getHealthDimension(1L);
 
-assertLevel(vo, "growth", "excellent");
-        assertLevel(vo, "sleep", "good");
-        assertLevel(vo, "vision", "fair");
-        assertLevel(vo, "immunity", "poor");
+        assertLevel(vo, "body.growth", "excellent");
+        assertLevel(vo, "body.sleep", "good");
+        assertLevel(vo, "body.vision", "fair");
+        assertLevel(vo, "body.immunity", "poor");
         assertEquals(new BigDecimal("63.75"), vo.getHealthIndex());
     }
 
@@ -147,7 +203,7 @@ assertLevel(vo, "growth", "excellent");
 
         HealthDimensionVO vo = service.getHealthDimension(1L);
 
-        HealthDimensionVO.DimensionItem item = findItem(vo, "growth");
+        HealthDimensionVO.DimensionItem item = findItem(vo, "body.growth");
         assertNotNull(item.getExpireDate());
         assertEquals(10, item.getExpireDate().length()); // yyyy-MM-dd = 10
     }
@@ -288,7 +344,7 @@ assertLevel(vo, "growth", "excellent");
 
         HealthDimensionVO vo = service.getHealthDimension(1L);
 
-        assertLevel(vo, "growth", "excellent");
+        assertLevel(vo, "body.growth", "excellent");
     }
 
     @Test
@@ -298,7 +354,7 @@ assertLevel(vo, "growth", "excellent");
 
         HealthDimensionVO vo = service.getHealthDimension(1L);
 
-        assertLevel(vo, "growth", "good");
+        assertLevel(vo, "body.growth", "good");
     }
 
     @Test
@@ -308,7 +364,7 @@ assertLevel(vo, "growth", "excellent");
 
         HealthDimensionVO vo = service.getHealthDimension(1L);
 
-        assertLevel(vo, "growth", "fair");
+        assertLevel(vo, "body.growth", "fair");
     }
 
     @Test
@@ -318,7 +374,7 @@ assertLevel(vo, "growth", "excellent");
 
         HealthDimensionVO vo = service.getHealthDimension(1L);
 
-        assertLevel(vo, "growth", "poor");
+        assertLevel(vo, "body.growth", "poor");
     }
 
     // ── calcPercentile (via uploadScore) ────────────────────────────────────
@@ -393,9 +449,9 @@ assertLevel(vo, "growth", "excellent");
         verify(scoreService, atLeast(1)).saveScore(cap.capture());
 
         boolean hasGut = cap.getAllValues().stream()
-            .anyMatch(s -> "gut".equals(s.getDimension()) && s.getScore() == 80);
+            .anyMatch(s -> "body.gut".equals(s.getDimension()) && s.getScore() == 80);
         boolean hasImmunity = cap.getAllValues().stream()
-            .anyMatch(s -> "immunity".equals(s.getDimension()) && s.getScore() == 80);
+            .anyMatch(s -> "body.immunity".equals(s.getDimension()) && s.getScore() == 80);
         assertTrue(hasGut, "gut dimension should be set from gutHealthScore");
         assertTrue(hasImmunity, "immunity dimension should be set from gutHealthScore");
     }
@@ -416,7 +472,8 @@ assertLevel(vo, "growth", "excellent");
         HealthDimensionVO vo = service.getHealthDimension(1L);
 
         List<String> expected = Arrays.asList(
-            "growth", "sleep", "vision", "immunity", "nutrition", "gut", "exercise");
+            "body.growth", "body.sleep", "body.vision", "body.immunity",
+            "body.nutrition", "body.gut", "body.exercise");
         List<String> expectedLabels = Arrays.asList(
             "生长发育", "睡眠质量", "视力健康", "免疫力", "营养均衡", "肠胃健康", "运动活力");
         List<String> actual = new java.util.ArrayList<>();
@@ -442,7 +499,7 @@ assertLevel(vo, "growth", "excellent");
 
         // 实现中即使过期也显示该分数(只按 found!=null 判断)
         // 这个测试验证实现行为:即使过期也有分
-        assertNotNull(findItem(vo, "growth").getScore());
+        assertNotNull(findItem(vo, "body.growth").getScore());
     }
 
     // ── Helper ─────────────────────────────────────────────────────────────

+ 33 - 3
cfc-backend/src/test/java/com/etotem/cfc/unit/ReportConfirmRefreshFiveDimContractTest.java

@@ -53,6 +53,36 @@ class ReportConfirmRefreshFiveDimContractTest {
         injectAll(service);
         healthReportService = (HealthReportService) readField(service, "healthReportService");
         scoreService = (HealthDimensionScoreService) readField(service, "scoreService");
+        mockBodyRegistry();
+    }
+
+    /**
+     * 统一维度体系:Mock 注册中心 body 根节点 → 7 个健康子维度(统一码 body.*),
+     * 使 mapGutFloraToDimensions 按注册中心维度清单取数。
+     */
+    private void mockBodyRegistry() throws Exception {
+        com.etotem.cfc.service.DimensionRegistryService registry =
+                (com.etotem.cfc.service.DimensionRegistryService) readField(service, "dimensionRegistryService");
+        String[][] dims = {
+                {"body.growth", "生长发育"}, {"body.sleep", "睡眠质量"},
+                {"body.vision", "视力健康"}, {"body.immunity", "免疫力"},
+                {"body.nutrition", "营养均衡"}, {"body.gut", "肠胃健康"},
+                {"body.exercise", "运动活力"}
+        };
+        List<com.etotem.cfc.dto.DimensionNode> children = new ArrayList<>();
+        for (String[] d : dims) {
+            com.etotem.cfc.dto.DimensionNode n = new com.etotem.cfc.dto.DimensionNode();
+            n.setCode(d[0]);
+            n.setName(d[1]);
+            n.setNodeKind("sub");
+            children.add(n);
+        }
+        com.etotem.cfc.dto.DimensionNode body = new com.etotem.cfc.dto.DimensionNode();
+        body.setCode("body");
+        body.setName("身体健康");
+        body.setNodeKind("root");
+        body.setChildren(children);
+        when(registry.getNodeByCode("body")).thenReturn(body);
     }
 
     private static Object readField(Object target, String name) throws Exception {
@@ -99,9 +129,9 @@ class ReportConfirmRefreshFiveDimContractTest {
             assertEquals(55L, s.getMemberId(), "五维分应写入指定成员");
             assertEquals("REPORT", s.getDataSource(), "报告确认来源应为 REPORT");
             assertEquals(Integer.valueOf(1), s.getTier(), "报告级数据 tier=1");
-            if ("gut".equals(s.getDimension())) hasGut = true;
-            if ("immunity".equals(s.getDimension())) hasImmunity = true;
-            if ("nutrition".equals(s.getDimension())) hasNutrition = true;
+            if ("body.gut".equals(s.getDimension())) hasGut = true;
+            if ("body.immunity".equals(s.getDimension())) hasImmunity = true;
+            if ("body.nutrition".equals(s.getDimension())) hasNutrition = true;
         }
         assertTrue(hasGut, "gut 肠胃健康应被派生");
         assertTrue(hasImmunity, "immunity 免疫力应被派生");

+ 29 - 3
cfc-frontend/components/wuxing-sandbox.vue

@@ -95,6 +95,11 @@ import { renderStar, calcStarVertices } from './wuxing-sandbox-helpers/render.js
 import { FillAnimator } from './wuxing-sandbox-helpers/animation.js'
 import { detectHitArm, armIndexToDomain } from './wuxing-sandbox-helpers/hitdetect.js'
 import { FlowAnimation } from './wuxing-sandbox-helpers/flow-animation.js'
+import {
+  ensureDimensionRegistry,
+  getRegistryRootMeta,
+  isRegistryReady
+} from '../composables/useDimensionRegistry.js'
 import {
   buildMemberOptions,
   findMemberById,
@@ -306,7 +311,9 @@ export default {
       _animator: null,
       _flowAnim: null,
       _flowCanvasReady: false,
-      uiIcons: UI_ICON_URIS
+      uiIcons: UI_ICON_URIS,
+      // 统一维度注册中心是否就绪:就绪后维度名称/五行元素兜底改经注册中心(wuxing_rule_ref/统一 code)
+      registryReady: false
     }
   },
   computed: {
@@ -417,16 +424,27 @@ export default {
         var d = this.dimensions[i]
         if (d && d.code) dimMap[d.code] = d
       }
+      // 统一维度体系:维度名称/五行元素兜底改经注册中心(root name / wuxing_rule_ref),
+      // 未就绪回退组件内 DIM_META(保持既有渲染,五行语义零改动)。
+      var regMeta = {}
+      if (this.registryReady) {
+        var roots = getRegistryRootMeta()
+        for (var r = 0; r < roots.length; r++) {
+          var root = roots[r]
+          if (root && root.code) regMeta[root.code] = root
+        }
+      }
       var all = []
       for (var j = 0; j < order.length; j++) {
         var code = order[j]
         var d = dimMap[code] || {}
+        var reg = regMeta[code] || {}
         var meta = DIM_META[code] || {}
         var score = scoreByCode[code] || 0
         all.push({
           code: code,
-          name: d.name || meta.name || '',
-          element: d.element || meta.element || '',
+          name: d.name || reg.name || meta.name || '',
+          element: d.element || reg.element || meta.element || '',
           energy: score,
           healthIndex: score,
           pointClass: RING_POS[j],
@@ -510,6 +528,14 @@ export default {
   mounted: function() {
     var self = this
 
+    // 统一维度体系:维度名称/五行元素兜底来源切换为注册中心(wuxing_rule_ref/统一 code),
+    // 幂等(模块级缓存单请求);未就绪时保持原有 DIM_META 兜底,五行语义零改动。
+    ensureDimensionRegistry().then(function() {
+      self.registryReady = isRegistryReady()
+    }).catch(function() {
+      self.registryReady = false
+    })
+
     // preview:轻量引导条,不初始化 Canvas、不画完整星图
     if (this.isPreview) return
 

+ 26 - 0
cfc-frontend/composables/useDimensionRegistry.js

@@ -155,9 +155,35 @@ function getRadarConfig(tabCode) {
   return config
 }
 
+/**
+ * 取注册中心 5 根节点元信息(五行归属):[{ code, name, element: wuxingRuleRef }]。
+ *
+ * <p>供五行能量沙盘维度名称 / 五行元素兜底(统一 code + wuxing_rule_ref),
+ * 未就绪返回空数组(调用方回落组件内 DIM_META)。</p>
+ *
+ * @returns {Array<{code:string,name:string,element:string}>}
+ */
+function getRegistryRootMeta() {
+  if (!_cache.tree || !Array.isArray(_cache.tree)) return []
+  var out = []
+  for (var i = 0; i < _cache.tree.length; i++) {
+    var node = _cache.tree[i]
+    if (!node || !node.code) continue
+    // 仅维度根节点(nodeKind=root;旧数据无 nodeKind 的按「无父节点」视为根)
+    if (node.nodeKind && node.nodeKind !== 'root') continue
+    out.push({
+      code: node.code,
+      name: node.name || '',
+      element: node.wuxingRuleRef || ''
+    })
+  }
+  return out
+}
+
 export {
   ensureDimensionRegistry,
   getRadarConfig,
+  getRegistryRootMeta,
   isRegistryReady,
   WEALTH_CHILD_LABEL
 }

+ 76 - 33
cfc-frontend/pages/health-main/index.vue

@@ -38,17 +38,26 @@
         </view>
 
         <!-- 演示雷达(登录浮层打开时隐藏:原生 canvas 会盖在浮层之上) -->
+        <!-- 统一维度体系:示例雷达按注册中心配置取数(维度清单来自 useDimensionRegistry),无配置显示空态引导 -->
         <view class="gh-demo" v-if="!loginSheetVisible">
-          <view class="gh-demo-head">
-            <text class="gh-demo-name">身体雷达</text>
-            <text class="gh-demo-tag">示例</text>
+          <view v-if="guestBodyDims.length > 0" class="gh-demo-block">
+            <view class="gh-demo-head">
+              <text class="gh-demo-name">身体雷达</text>
+              <text class="gh-demo-tag">示例</text>
+            </view>
+            <RadarChart :dimensions="guestBodyDims" :scores="guestBodyScores" :fillColor="bodyColor" childName="示例" width="560" height="460" />
+          </view>
+          <view v-if="guestCogDims.length > 0" class="gh-demo-block">
+            <view class="gh-demo-head">
+              <text class="gh-demo-name">认知雷达</text>
+              <text class="gh-demo-tag">示例</text>
+            </view>
+            <RadarChart :dimensions="guestCogDims" :scores="guestCogScores" :fillColor="wisdomColor" childName="示例" width="560" height="460" />
           </view>
-          <RadarChart :dimensions="demoBodyDims" :scores="demoBodyScores" :fillColor="bodyColor" childName="示例" width="560" height="460" />
-          <view class="gh-demo-head">
-            <text class="gh-demo-name">认知雷达</text>
-            <text class="gh-demo-tag">示例</text>
+          <view v-if="!guestHasDemoConfig" class="gh-demo-empty">
+            <text class="gh-demo-empty-text">健康雷达尚未配置</text>
+            <text class="gh-demo-empty-sub">完成维度配置后即可查看示例效果</text>
           </view>
-          <RadarChart :dimensions="demoCogDims" :scores="demoCogScores" :fillColor="wisdomColor" childName="示例" width="560" height="460" />
         </view>
 
         <!-- 五维健康体系 -->
@@ -295,24 +304,6 @@ export default {
         { code: 'mental', label: '心理健康测评', icon: '💖', score: null },
         { code: 'relationship', label: '关系质量测评', icon: '🤝', score: null }
       ],
-      demoBodyDims: [
-        { label: '生长发育', key: 'growth' },
-        { label: '睡眠质量', key: 'sleep' },
-        { label: '视力健康', key: 'vision' },
-        { label: '免疫力', key: 'immunity' },
-        { label: '营养均衡', key: 'nutrition' },
-        { label: '肠胃健康', key: 'gut' },
-        { label: '运动活力', key: 'exercise' }
-      ],
-      demoCogDims: [
-        { label: '记忆力', key: 'memory' },
-        { label: '注意力', key: 'attention' },
-        { label: '逻辑推理', key: 'logic' },
-        { label: '空间想象', key: 'spatial' },
-        { label: '语言表达', key: 'language' }
-      ],
-      demoBodyScores: [72, 68, 75, 80, 70, 78, 82],
-      demoCogScores: [78, 72, 85, 68, 80],
       // 真实数据缓存
       bodyData: null,
       cognitiveData: null,
@@ -421,6 +412,22 @@ export default {
       if (this.currentTab === 'wealth') return !this.wealthDetail
       var quiz = this.quizzes.filter(function(q) { return q.code === this.currentTab }.bind(this))[0]
       return !(quiz && quiz.score)
+    },
+    // ── 访客态示例雷达(统一维度体系):维度清单按注册中心配置取,无配置显示空态引导 ──
+    guestBodyDims: function() {
+      return this._guestRadarDims('body')
+    },
+    guestBodyScores: function() {
+      return this._guestDemoScores(this.guestBodyDims)
+    },
+    guestCogDims: function() {
+      return this._guestRadarDims('cognitive')
+    },
+    guestCogScores: function() {
+      return this._guestDemoScores(this.guestCogDims)
+    },
+    guestHasDemoConfig: function() {
+      return this.guestBodyDims.length > 0 || this.guestCogDims.length > 0
     }
   },
   onShow() {
@@ -444,6 +451,13 @@ export default {
       })
     } else {
       this.pendingSurveys = []
+      // 访客态:示例雷达同样按注册中心配置渲染(无配置显示空态引导)
+      var self = this
+      ensureDimensionRegistry().then(function() {
+        self.registryReady = isRegistryReady()
+      }).catch(function() {
+        self.registryReady = false
+      })
     }
   },
   methods: {
@@ -541,16 +555,11 @@ export default {
         if (!d) return [0, 0, 0, 0]
         return [d.wealthSkill || 0, d.wealthAltruism || 0, d.wealthIncome || 0, d.wealthFx || 0]
       }
-      // body: 从 API 真实维度数据取分
+      // body: 从 API 真实维度数据取分(无真实数据时由 chartIsEmpty 走零值空态,不再回落硬编码示例分)
       if (this.bodyData && this.bodyData.dimensions && this.bodyData.dimensions.length > 0) {
         return this.bodyData.dimensions.map(function(dItem) { return Number(dItem.score) || 0 })
       }
-      // body: fallback to demo
-      if (this.reports.length > 0) {
-        var latest = this.reports[0]
-        if (latest.overallScore) return [72, 68, 75, 80, 70, 78, 82]
-      }
-      return [72, 68, 75, 80, 70, 78, 82]
+      return []
     },
     /**
      * 配置驱动取分:按 tab 与注册中心节点的 scoreField / code 从对应分数包取分。
@@ -609,6 +618,35 @@ export default {
       }
       return 0
     },
+    /**
+     * 访客演示雷达维度清单:按注册中心配置取(body→body 根、cognitive→wisdom 根),
+     * 未就绪或无配置返回空数组(模板据此渲染空态引导文案)。
+     * @param {string} tab
+     * @returns {Array<{label:string,key:string,code:string}>}
+     */
+    _guestRadarDims: function(tab) {
+      var config = getRadarConfig(tab)
+      if (!this.registryReady || !config || config.length === 0) return []
+      var dims = []
+      for (var i = 0; i < config.length; i++) {
+        var c = config[i]
+        dims.push({ label: c.label, key: c.code, code: c.code })
+      }
+      return dims
+    },
+    /**
+     * 访客演示雷达示例分数:按维度数量生成确定性示例分(非字面量数组),
+     * 仅用于示例形状演示,无真实数据含义。替代原访客示例的硬编码分数数组。
+     * @param {Array} dims
+     * @returns {Array<number>}
+     */
+    _guestDemoScores: function(dims) {
+      var scores = []
+      for (var i = 0; i < dims.length; i++) {
+        scores.push(72 - (i % 3) * 4)
+      }
+      return scores
+    },
     switchTab: function(code) { this.currentTab = code },
     goPage: function(url) { uni.navigateTo({ url: url }) },
     // 登录浮层打开/关闭(隐藏演示雷达,规避原生 canvas 层级高于浮层的问题)
@@ -920,9 +958,14 @@ $gh-white: str-slice('#{$app-surface}', 2);
 
 /* 演示雷达 */
 .gh-demo { margin: 0 32rpx 24rpx; background: $app-surface; border-radius: 24rpx; padding: 20rpx 12rpx 12rpx; border: 1rpx solid $app-border-subtle; }
+.gh-demo-block { margin-top: 12rpx; }
+.gh-demo-block:first-child { margin-top: 0; }
 .gh-demo-head { display: flex; flex-direction: row; align-items: center; justify-content: space-between; padding: 0 20rpx; margin: 12rpx 0 4rpx; }
 .gh-demo-name { font-size: calc(24rpx * var(--font-scale, 1)); font-weight: 600; color: $facade-text-strong; }
 .gh-demo-tag { font-size: calc(20rpx * var(--font-scale, 1)); color: $brand-primary; background: rgba($brand-primary, 0.1); border-radius: 8rpx; padding: 2rpx 12rpx; }
+.gh-demo-empty { padding: 72rpx 20rpx; display: flex; flex-direction: column; align-items: center; }
+.gh-demo-empty-text { font-size: calc(28rpx * var(--font-scale, 1)); color: $facade-text-strong; font-weight: 600; display: block; }
+.gh-demo-empty-sub { font-size: calc(22rpx * var(--font-scale, 1)); color: $facade-text-muted; display: block; margin-top: 10rpx; }
 
 /* 五维健康体系 */
 .gh-wuxing { margin: 0 32rpx 24rpx; background: $app-surface; border-radius: 24rpx; padding: 28rpx 24rpx; border: 1rpx solid $app-border-subtle; }