Browse Source

merge(dimension): 统一维度体系 P0增量+P1(雷达配置驱动/指标收敛/去直读)+P2(多级混排/评分口径) 合入 cfclub

Agnes Agent 1 week ago
parent
commit
aa6ccb2dd

+ 86 - 0
cfc-backend/src/main/java/com/etotem/cfc/common/constant/DimensionCodeConstants.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.common.constant;
 package com.etotem.cfc.common.constant;
 
 
 public class DimensionCodeConstants {
 public class DimensionCodeConstants {
+    // 统一维度体系常量:5 根(body/mind/wisdom/action/wealth)+ 子维构成 body7/wisdom7/mind10/action5/wealth4(共33子维)
     // 5 根维度
     // 5 根维度
     public static final String BODY = "body";
     public static final String BODY = "body";
     public static final String MIND = "mind";
     public static final String MIND = "mind";
@@ -32,4 +33,89 @@ public class DimensionCodeConstants {
     public static final String WUXING_METAL = "金";
     public static final String WUXING_METAL = "金";
     public static final String WUXING_WATER = "水";
     public static final String WUXING_WATER = "水";
     public static final String WUXING_EARTH = "土";
     public static final String WUXING_EARTH = "土";
+
+    // ===== 增量子维常量(P0 增量,迁移448 seed;统一 code={root}.{key}) =====
+    // 维度构成:body7 / wisdom7 / mind10 / action5 / wealth4(共 33 子维 + 5 根 = 38 节点)
+
+    // wisdom 智/认知(7,source_system='cognitive')—— DAN 认知报告字段
+    public static final String WISDOM_MEMORY = "wisdom.memory";
+    public static final String WISDOM_ATTENTION = "wisdom.attention";
+    public static final String WISDOM_LOGIC = "wisdom.logic";
+    public static final String WISDOM_SPATIAL = "wisdom.spatial";
+    public static final String WISDOM_PERCEPTION = "wisdom.perception";
+    public static final String WISDOM_FOCUS = "wisdom.focus";
+    public static final String WISDOM_PROCESSING_SPEED = "wisdom.processingSpeed";
+
+    // mind 心/心理(10,source_system='emi')
+    public static final String MIND_OPENNESS = "mind.openness";
+    public static final String MIND_CONSCIENTIOUSNESS = "mind.conscientiousness";
+    public static final String MIND_EXTRAVERSION = "mind.extraversion";
+    public static final String MIND_AGREEABLENESS = "mind.agreeableness";
+    public static final String MIND_NEUROTICISM = "mind.neuroticism";
+    public static final String MIND_DRIVE_AUTONOMY = "mind.driveAutonomy";
+    public static final String MIND_DRIVE_COMPETENCE = "mind.driveCompetence";
+    public static final String MIND_DRIVE_RELATEDNESS = "mind.driveRelatedness";
+    public static final String MIND_EMOTION_STABILITY = "mind.emotionStability";
+    public static final String MIND_SELF_CONCEPT_OVERALL = "mind.selfConceptOverall";
+
+    // action 社会性(5,source_system='relationship')—— RelationshipScoreVO 字段
+    public static final String ACTION_PARENT_TRUST = "action.parentTrust";
+    public static final String ACTION_PARENT_INTIMACY = "action.parentIntimacy";
+    public static final String ACTION_COMMUNICATION = "action.communication";
+    public static final String ACTION_HEALTH_STATUS = "action.healthStatus";
+    public static final String ACTION_LAST_INTERACTION = "action.lastInteraction";
+
+    // wealth 富(4,source_system='energy')—— MemberEnergyDTO 字段
+    public static final String WEALTH_SKILL = "wealth.skill";
+    public static final String WEALTH_ALTRUISM = "wealth.altruism";
+    public static final String WEALTH_INCOME = "wealth.income";
+    public static final String WEALTH_FX = "wealth.fx";
+
+    // 增量 33 子维的 legacy_alias 映射(JSON数组字符串,单元素=后端 API 取数字段名)
+    public static final String LEGACY_MEMORY_SCORE = "memoryScore";
+    public static final String LEGACY_ATTENTION_SCORE = "attentionScore";
+    public static final String LEGACY_LOGIC_SCORE = "logicScore";
+    public static final String LEGACY_SPATIAL_SCORE = "spatialScore";
+    public static final String LEGACY_PERCEPTION_SCORE = "perceptionScore";
+    public static final String LEGACY_FOCUS_SCORE = "focusScore";
+    public static final String LEGACY_PROCESSING_SPEED_SCORE = "processingSpeedScore";
+    public static final String LEGACY_OPENNESS_SCORE = "opennessScore";
+    public static final String LEGACY_CONSCIENTIOUSNESS_SCORE = "conscientiousnessScore";
+    public static final String LEGACY_EXTRAVERSION_SCORE = "extraversionScore";
+    public static final String LEGACY_AGREEABLENESS_SCORE = "agreeablenessScore";
+    public static final String LEGACY_NEUROTICISM_SCORE = "neuroticismScore";
+    public static final String LEGACY_DRIVE_AUTONOMY = "driveAutonomy";
+    public static final String LEGACY_DRIVE_COMPETENCE = "driveCompetence";
+    public static final String LEGACY_DRIVE_RELATEDNESS = "driveRelatedness";
+    public static final String LEGACY_EMOTION_STABILITY = "emotionStability";
+    public static final String LEGACY_SELF_CONCEPT_OVERALL = "selfConceptOverall";
+    public static final String LEGACY_TRUST_SCORE = "trustScore";
+    public static final String LEGACY_INTIMACY_SCORE = "intimacyScore";
+    public static final String LEGACY_COMMUNICATION_SCORE = "communicationScore";
+    public static final String LEGACY_TOTAL_SCORE = "totalScore";
+    public static final String LEGACY_LAST_INTERACTION_AT = "lastInteractionAt";
+    public static final String LEGACY_WEALTH_SKILL = "wealthSkill";
+    public static final String LEGACY_WEALTH_ALTRUISM = "wealthAltruism";
+    public static final String LEGACY_WEALTH_INCOME = "wealthIncome";
+    public static final String LEGACY_WEALTH_FX = "wealthFx";
+
+    // ===== P2-T2 评分口径共享常量 =====
+    // 来源: docs/superpowers/specs/2026-11-05-dimension-scoring-convention.md
+    // 统一量纲 0-100(雷达 / 沙盘能量 / 指标归一化 三处共享同一量纲):
+    //   - 雷达(0-100): RadarChart.vue r = outerR × clamp(score/100,0,1),分值文本 Math.round(score)+'%'
+    //   - 沙盘能量(0-100%): EnergyService 各子维公式 clamp(...,0,100)
+    //   - 指标归一化: NormCompareEngine 按百分位带 P5/P25/P75/P95 判 偏低/正常/偏高
+    public static final int SCORE_MIN = 0;
+    public static final int SCORE_MAX = 100;
+
+    // 百分位带断点(NormCompareEngine.applyBandFromNorm / applyBandFromPopulation):
+    //   v < P5      → band=veryLow, status=偏低
+    //   P5 ≤ v < P25→ band=low,     status=偏低
+    //   P25 ≤ v ≤ P75 → band=mid,   status=正常
+    //   P75 < v ≤ P95 → band=high,  status=偏高
+    //   v > P95     → band=veryHigh,status=偏高
+    public static final int BAND_P5 = 5;
+    public static final int BAND_P25 = 25;
+    public static final int BAND_P75 = 75;
+    public static final int BAND_P95 = 95;
 }
 }

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

@@ -3443,6 +3443,12 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         migrateUnifiedIndicatorTreeColumns();
         migrateUnifiedIndicatorTreeColumns();
         // 迁移447: 统一维度体系(P0-T3)—— 固化 5 根 + 7 body 子维度节点(幂等 seed)
         // 迁移447: 统一维度体系(P0-T3)—— 固化 5 根 + 7 body 子维度节点(幂等 seed)
         seedUnifiedDimensionRegistry();
         seedUnifiedDimensionRegistry();
+        // 迁移448: 统一维度体系(增量)—— 另外 4 根 33 子维 + 5 根五行归属(幂等 seed,仅追加,不改动 445-447)
+        seedUnifiedDimensionRegistryExpansion();
+        // 迁移449: 统一维度体系(P1-T3)—— indicator_definitions.dimension_code 扩宽至 VARCHAR(64),
+        // 以容纳注册中心子维度 code(如 mind.selfConceptOverall=23 字符 > 原 VARCHAR(16)),
+        // 指标可归属/挂接到任意维度节点(红线1:不动 id 主键与 5 张外键表)
+        migrateIndicatorDimensionCodeWidth();
     }
     }
 
 
     /**
     /**
@@ -15169,6 +15175,100 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         log.info("迁移447: 统一维度注册中心 5 根 + 7 body 子维度 seed 完成");
         log.info("迁移447: 统一维度注册中心 5 根 + 7 body 子维度 seed 完成");
     }
     }
 
 
+    /**
+     * 迁移448: 统一维度体系(增量)—— 固化另外 4 根(mind/wisdom/action/wealth)的 33 个子维度节点,
+     * 并补 5 根节点的五行归属(wuxing_rule_ref)。幂等 seed,仅追加,不改动 445-447。
+     * 子维 code = {root}.{key},legacy_alias = 后端 API 取数字段名(供 P1 健康页雷达按配置取数)。
+     */
+    void seedUnifiedDimensionRegistryExpansion() {
+        // 4 根 id(body 的 7 子维已在迁移447 固化;此处覆盖其余 4 根)
+        Long mindId = queryDimensionIdByCode("mind");
+        Long wisdomId = queryDimensionIdByCode("wisdom");
+        Long actionId = queryDimensionIdByCode("action");
+        Long wealthId = queryDimensionIdByCode("wealth");
+
+        // —— mind 心/心理(10,source_system='emi')——
+        if (mindId != null) {
+            upsertDimensionSeed("mind.openness", "开放性", "emi", mindId, 1, "emi", "sub", "[\"opennessScore\"]", null);
+            upsertDimensionSeed("mind.conscientiousness", "尽责性", "emi", mindId, 2, "emi", "sub", "[\"conscientiousnessScore\"]", null);
+            upsertDimensionSeed("mind.extraversion", "外向性", "emi", mindId, 3, "emi", "sub", "[\"extraversionScore\"]", null);
+            upsertDimensionSeed("mind.agreeableness", "宜人性", "emi", mindId, 4, "emi", "sub", "[\"agreeablenessScore\"]", null);
+            upsertDimensionSeed("mind.neuroticism", "神经质", "emi", mindId, 5, "emi", "sub", "[\"neuroticismScore\"]", null);
+            upsertDimensionSeed("mind.driveAutonomy", "自主性", "emi", mindId, 6, "emi", "sub", "[\"driveAutonomy\"]", null);
+            upsertDimensionSeed("mind.driveCompetence", "胜任感", "emi", mindId, 7, "emi", "sub", "[\"driveCompetence\"]", null);
+            upsertDimensionSeed("mind.driveRelatedness", "归属感", "emi", mindId, 8, "emi", "sub", "[\"driveRelatedness\"]", null);
+            upsertDimensionSeed("mind.emotionStability", "情绪稳定性", "emi", mindId, 9, "emi", "sub", "[\"emotionStability\"]", null);
+            upsertDimensionSeed("mind.selfConceptOverall", "自我概念", "emi", mindId, 10, "emi", "sub", "[\"selfConceptOverall\"]", null);
+        } else {
+            log.warn("迁移448: 未找到 mind 根节点 id,跳过 mind 子维度 seed");
+        }
+
+        // —— wisdom 智/认知(7,source_system='cognitive')——
+        if (wisdomId != null) {
+            upsertDimensionSeed("wisdom.memory", "记忆力", "cognitive", wisdomId, 1, "cognitive", "sub", "[\"memoryScore\"]", null);
+            upsertDimensionSeed("wisdom.attention", "注意力", "cognitive", wisdomId, 2, "cognitive", "sub", "[\"attentionScore\"]", null);
+            upsertDimensionSeed("wisdom.logic", "逻辑推理", "cognitive", wisdomId, 3, "cognitive", "sub", "[\"logicScore\"]", null);
+            upsertDimensionSeed("wisdom.spatial", "空间想象", "cognitive", wisdomId, 4, "cognitive", "sub", "[\"spatialScore\"]", null);
+            upsertDimensionSeed("wisdom.perception", "感知能力", "cognitive", wisdomId, 5, "cognitive", "sub", "[\"perceptionScore\"]", null);
+            upsertDimensionSeed("wisdom.focus", "专注力", "cognitive", wisdomId, 6, "cognitive", "sub", "[\"focusScore\"]", null);
+            upsertDimensionSeed("wisdom.processingSpeed", "加工速度", "cognitive", wisdomId, 7, "cognitive", "sub", "[\"processingSpeedScore\"]", null);
+        } else {
+            log.warn("迁移448: 未找到 wisdom 根节点 id,跳过 wisdom 子维度 seed");
+        }
+
+        // —— action 社会性(5,source_system='relationship')——
+        if (actionId != null) {
+            upsertDimensionSeed("action.parentTrust", "亲子信任", "relationship", actionId, 1, "relationship", "sub", "[\"trustScore\"]", null);
+            upsertDimensionSeed("action.parentIntimacy", "亲子亲密度", "relationship", actionId, 2, "relationship", "sub", "[\"intimacyScore\"]", null);
+            upsertDimensionSeed("action.communication", "沟通质量", "relationship", actionId, 3, "relationship", "sub", "[\"communicationScore\"]", null);
+            upsertDimensionSeed("action.healthStatus", "关系健康度", "relationship", actionId, 4, "relationship", "sub", "[\"totalScore\"]", null);
+            upsertDimensionSeed("action.lastInteraction", "最近互动", "relationship", actionId, 5, "relationship", "sub", "[\"lastInteractionAt\"]", null);
+        } else {
+            log.warn("迁移448: 未找到 action 根节点 id,跳过 action 子维度 seed");
+        }
+
+        // —— wealth 富(4,source_system='energy')——
+        if (wealthId != null) {
+            upsertDimensionSeed("wealth.skill", "个人能力", "energy", wealthId, 1, "energy", "sub", "[\"wealthSkill\"]", null);
+            upsertDimensionSeed("wealth.altruism", "利他贡献", "energy", wealthId, 2, "energy", "sub", "[\"wealthAltruism\"]", null);
+            upsertDimensionSeed("wealth.income", "CF值积累", "energy", wealthId, 3, "energy", "sub", "[\"wealthIncome\"]", null);
+            upsertDimensionSeed("wealth.fx", "财务素养", "energy", wealthId, 4, "energy", "sub", "[\"wealthFx\"]", null);
+        } else {
+            log.warn("迁移448: 未找到 wealth 根节点 id,跳过 wealth 子维度 seed");
+        }
+
+        // 补 5 根五行归属:按 energy_dimension.element 填 wuxing_rule_ref(idempotent,仅当为空时填)
+        try {
+            jdbcTemplate.update(
+                    "UPDATE product_dimension_config pdc "
+                            + "JOIN energy_dimension ed ON ed.code = pdc.dimension_code "
+                            + "SET pdc.wuxing_rule_ref = COALESCE(pdc.wuxing_rule_ref, ed.element) "
+                            + "WHERE pdc.dimension_code IN ('body','mind','wisdom','action','wealth') "
+                            + "AND ed.element IS NOT NULL");
+            log.info("迁移448: 5 根五行归属 wuxing_rule_ref 已按 energy_dimension.element 补齐");
+        } catch (Exception e) {
+            log.warn("迁移448: 5 根五行归属补齐失败(忽略): {}", e.getMessage());
+        }
+        log.info("迁移448: 统一维度注册中心增量 seed 完成(mind10/wisdom7/action5/wealth4 共33子维)");
+    }
+
+    /**
+     * 迁移449: indicator_definitions.dimension_code 扩宽至 VARCHAR(64)。
+     *
+     * <p>统一维度体系下指标可归属/挂接到注册中心任意维度节点(含子维度),
+     * 子维度 code 最长 23 字符(如 mind.selfConceptOverall),原 VARCHAR(16) 不足。
+     * 幂等:MODIFY COLUMN 重复执行安全;不改 id 主键与 5 张外键表(红线 1)。</p>
+     */
+    private void migrateIndicatorDimensionCodeWidth() {
+        try {
+            jdbcTemplate.execute("ALTER TABLE indicator_definitions MODIFY COLUMN dimension_code "
+                    + "VARCHAR(64) COMMENT '关联统一维度节点code(注册中心root/sub,可空)'");
+            log.info("迁移449: indicator_definitions.dimension_code 已扩宽至 VARCHAR(64)");
+        } catch (Exception e) {
+            log.warn("迁移449: indicator_definitions.dimension_code 扩宽失败(忽略): {}", e.getMessage());
+        }
+    }
+
     /** 幂等 upsert 维度种子:按 dimension_code 存在则补 source_system/legacy_alias,不存在则插入。 */
     /** 幂等 upsert 维度种子:按 dimension_code 存在则补 source_system/legacy_alias,不存在则插入。 */
     private void upsertDimensionSeed(String code, String name, String dimensionType, Long parentId,
     private void upsertDimensionSeed(String code, String name, String dimensionType, Long parentId,
                                      int sort, String sourceSystem, String nodeKind,
                                      int sort, String sourceSystem, String nodeKind,

+ 14 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthDimensionController.java

@@ -28,6 +28,13 @@ public class HealthDimensionController {
     @Resource
     @Resource
     private FamilyMemberMapper familyMemberMapper;
     private FamilyMemberMapper familyMemberMapper;
 
 
+    /**
+     * 获取健康维度概览。
+     *
+     * @deprecated 统一维度体系下前端改走 /api/dimension-registry/*(注册中心只读接口),
+     *             本接口保留至零调用确认后下线(设计文档 §5.3),不删除。
+     */
+    @Deprecated
     @Operation(summary = "获取健康维度概览")
     @Operation(summary = "获取健康维度概览")
     @PostMapping("/overview")
     @PostMapping("/overview")
     public Result<HealthDimensionVO> overview(
     public Result<HealthDimensionVO> overview(
@@ -42,6 +49,13 @@ public class HealthDimensionController {
         return Result.success(vo);
         return Result.success(vo);
     }
     }
 
 
+    /**
+     * 获取雷达图三层数据:实际值/人群位置/家庭占比。
+     *
+     * @deprecated 统一维度体系下前端改走 /api/dimension-registry/*(注册中心只读接口),
+     *             本接口保留至零调用确认后下线(设计文档 §5.3),不删除。
+     */
+    @Deprecated
     @Operation(summary = "获取雷达图三层数据:实际值/人群位置/家庭占比")
     @Operation(summary = "获取雷达图三层数据:实际值/人群位置/家庭占比")
     @PostMapping("/radar-data")
     @PostMapping("/radar-data")
     public Result<Map<String, Object>> radarData(
     public Result<Map<String, Object>> radarData(

+ 8 - 1
cfc-backend/src/main/java/com/etotem/cfc/dto/DimensionNode.java

@@ -6,7 +6,8 @@ import java.util.List;
  * 统一维度树节点(维度节点 ∪ 指标节点混排)。
  * 统一维度树节点(维度节点 ∪ 指标节点混排)。
  *
  *
  * <p>结构见设计文档 §5.1:{id, code, name, parentId, nodeKind, sourceSystem,
  * <p>结构见设计文档 §5.1:{id, code, name, parentId, nodeKind, sourceSystem,
- * legacyAlias, wuxingRuleRef, children?}。</p>
+ * legacyAlias, wuxingRuleRef, children?};P2-T1 起指标节点额外带 dimensionCode
+ * (指标归属维度 code,供指标挂指标时继承归属,null=通用)。</p>
  */
  */
 public class DimensionNode {
 public class DimensionNode {
 
 
@@ -18,6 +19,10 @@ public class DimensionNode {
     private String sourceSystem;
     private String sourceSystem;
     private String legacyAlias;
     private String legacyAlias;
     private String wuxingRuleRef;
     private String wuxingRuleRef;
+
+    /** 指标节点的归属维度 code(仅 nodeKind=indicator 有值;维度节点为 null)。P2-T1。 */
+    private String dimensionCode;
+
     private List<DimensionNode> children;
     private List<DimensionNode> children;
 
 
     public DimensionNode() {
     public DimensionNode() {
@@ -39,6 +44,8 @@ public class DimensionNode {
     public void setLegacyAlias(String legacyAlias) { this.legacyAlias = legacyAlias; }
     public void setLegacyAlias(String legacyAlias) { this.legacyAlias = legacyAlias; }
     public String getWuxingRuleRef() { return wuxingRuleRef; }
     public String getWuxingRuleRef() { return wuxingRuleRef; }
     public void setWuxingRuleRef(String wuxingRuleRef) { this.wuxingRuleRef = wuxingRuleRef; }
     public void setWuxingRuleRef(String wuxingRuleRef) { this.wuxingRuleRef = wuxingRuleRef; }
+    public String getDimensionCode() { return dimensionCode; }
+    public void setDimensionCode(String dimensionCode) { this.dimensionCode = dimensionCode; }
     public List<DimensionNode> getChildren() { return children; }
     public List<DimensionNode> getChildren() { return children; }
     public void setChildren(List<DimensionNode> children) { this.children = children; }
     public void setChildren(List<DimensionNode> children) { this.children = children; }
 }
 }

+ 11 - 40
cfc-backend/src/main/java/com/etotem/cfc/service/DanKnowledgeBaseService.java

@@ -7,12 +7,10 @@ import com.etotem.cfc.entity.DanKnowledgeBase;
 import com.etotem.cfc.entity.DanKnowledgeBaseDimension;
 import com.etotem.cfc.entity.DanKnowledgeBaseDimension;
 import com.etotem.cfc.entity.DanKnowledgeBaseTag;
 import com.etotem.cfc.entity.DanKnowledgeBaseTag;
 import com.etotem.cfc.entity.DanKnowledgeTag;
 import com.etotem.cfc.entity.DanKnowledgeTag;
-import com.etotem.cfc.entity.ProductDimensionConfig;
 import com.etotem.cfc.mapper.DanKnowledgeBaseDimensionMapper;
 import com.etotem.cfc.mapper.DanKnowledgeBaseDimensionMapper;
 import com.etotem.cfc.mapper.DanKnowledgeBaseMapper;
 import com.etotem.cfc.mapper.DanKnowledgeBaseMapper;
 import com.etotem.cfc.mapper.DanKnowledgeBaseTagMapper;
 import com.etotem.cfc.mapper.DanKnowledgeBaseTagMapper;
 import com.etotem.cfc.mapper.DanKnowledgeTagMapper;
 import com.etotem.cfc.mapper.DanKnowledgeTagMapper;
-import com.etotem.cfc.mapper.ProductDimensionConfigMapper;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.transaction.annotation.Transactional;
@@ -46,7 +44,7 @@ public class DanKnowledgeBaseService extends ServiceImpl<DanKnowledgeBaseMapper,
     private DanKnowledgeTagMapper danKnowledgeTagMapper;
     private DanKnowledgeTagMapper danKnowledgeTagMapper;
 
 
     @Resource
     @Resource
-    private ProductDimensionConfigMapper productDimensionConfigMapper;
+    private DimensionRegistryService dimensionRegistryService;
 
 
     @Resource
     @Resource
     private LanggraphSyncService langgraphSyncService;
     private LanggraphSyncService langgraphSyncService;
@@ -276,17 +274,7 @@ public class DanKnowledgeBaseService extends ServiceImpl<DanKnowledgeBaseMapper,
                 }
                 }
             }
             }
         }
         }
-        Map<Long, String> dimCodesById = new HashMap<>();
-        if (!allDimIds.isEmpty()) {
-            List<ProductDimensionConfig> configs = productDimensionConfigMapper.selectBatchIds(allDimIds);
-            if (configs != null) {
-                for (ProductDimensionConfig config : configs) {
-                    if (config.getId() != null && config.getDimensionCode() != null) {
-                        dimCodesById.put(config.getId(), config.getDimensionCode());
-                    }
-                }
-            }
-        }
+        Map<Long, String> dimCodesById = dimensionRegistryService.getDimensionCodesByIds(allDimIds);
 
 
         for (DanKnowledgeBase record : records) {
         for (DanKnowledgeBase record : records) {
             List<Long> tids = tagIdsByKnowledge.getOrDefault(record.getId(), Collections.emptyList());
             List<Long> tids = tagIdsByKnowledge.getOrDefault(record.getId(), Collections.emptyList());
@@ -322,15 +310,7 @@ public class DanKnowledgeBaseService extends ServiceImpl<DanKnowledgeBaseMapper,
         }
         }
         List<Long> dimIds = knowledge.getDimensionIds();
         List<Long> dimIds = knowledge.getDimensionIds();
         if (dimIds != null && !dimIds.isEmpty()) {
         if (dimIds != null && !dimIds.isEmpty()) {
-            List<ProductDimensionConfig> configs = productDimensionConfigMapper.selectBatchIds(dimIds);
-            Map<Long, String> codes = new HashMap<>();
-            if (configs != null) {
-                for (ProductDimensionConfig config : configs) {
-                    if (config.getId() != null && config.getDimensionCode() != null) {
-                        codes.put(config.getId(), config.getDimensionCode());
-                    }
-                }
-            }
+            Map<Long, String> codes = dimensionRegistryService.getDimensionCodesByIds(dimIds);
             knowledge.setDimensionCodes(dimIds.stream().map(codes::get)
             knowledge.setDimensionCodes(dimIds.stream().map(codes::get)
                 .filter(Objects::nonNull).collect(Collectors.toList()));
                 .filter(Objects::nonNull).collect(Collectors.toList()));
         } else {
         } else {
@@ -390,21 +370,15 @@ public class DanKnowledgeBaseService extends ServiceImpl<DanKnowledgeBaseMapper,
                 .eq(DanKnowledgeBaseDimension::getKnowledgeId, knowledgeId));
                 .eq(DanKnowledgeBaseDimension::getKnowledgeId, knowledgeId));
         if (dims == null || dims.isEmpty()) return new java.util.ArrayList<>();
         if (dims == null || dims.isEmpty()) return new java.util.ArrayList<>();
         List<Long> dimIds = dims.stream().map(DanKnowledgeBaseDimension::getDimensionId).collect(Collectors.toList());
         List<Long> dimIds = dims.stream().map(DanKnowledgeBaseDimension::getDimensionId).collect(Collectors.toList());
-        ProductDimensionConfig cfg = new ProductDimensionConfig();
-        cfg.setId(dimIds.get(0));
-        List<ProductDimensionConfig> configs = new java.util.ArrayList<>();
+        Map<Long, String> codeById = dimensionRegistryService.getDimensionCodesByIds(dimIds);
+        List<String> codes = new java.util.ArrayList<>();
         for (Long dimId : dimIds) {
         for (Long dimId : dimIds) {
-            ProductDimensionConfig c = new ProductDimensionConfig();
-            c.setId(dimId);
-            configs.add(c);
+            String code = codeById.get(dimId);
+            if (code != null) {
+                codes.add(code);
+            }
         }
         }
-        return configs.stream()
-            .map(c -> {
-                ProductDimensionConfig full = productDimensionConfigMapper.selectById(c.getId());
-                return full != null ? full.getDimensionCode() : null;
-            })
-            .filter(code -> code != null)
-            .collect(Collectors.toList());
+        return codes;
     }
     }
 
 
     // ==================== 标签/维度自动回填 ====================
     // ==================== 标签/维度自动回填 ====================
@@ -525,9 +499,6 @@ public class DanKnowledgeBaseService extends ServiceImpl<DanKnowledgeBaseMapper,
     }
     }
 
 
     private Long dimensionIdByCode(String code) {
     private Long dimensionIdByCode(String code) {
-        ProductDimensionConfig cfg = productDimensionConfigMapper.selectOne(
-            new LambdaQueryWrapper<ProductDimensionConfig>()
-                .eq(ProductDimensionConfig::getDimensionCode, code));
-        return cfg != null ? cfg.getId() : null;
+        return dimensionRegistryService.getDimensionIdByCode(code);
     }
     }
 }
 }

+ 110 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/DimensionRegistryService.java

@@ -10,6 +10,7 @@ import org.springframework.util.StringUtils;
 
 
 import javax.annotation.Resource;
 import javax.annotation.Resource;
 import java.util.ArrayList;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.HashMap;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.HashSet;
 import java.util.List;
 import java.util.List;
@@ -80,6 +81,9 @@ public class DimensionRegistryService {
             n.setNodeKind("indicator");
             n.setNodeKind("indicator");
             n.setSourceSystem("indicator");
             n.setSourceSystem("indicator");
             n.setLegacyAlias(ind.getLegacyAlias());
             n.setLegacyAlias(ind.getLegacyAlias());
+            // P2-T1:指标节点暴露归属维度 code(指标挂指标时子指标继承该归属,
+            // 保证子指标 dimensionCode 仍 ∈ 注册中心维度节点 code 集合)
+            n.setDimensionCode(ind.getDimensionCode());
             idToNode.putIfAbsent(n.getId(), n);
             idToNode.putIfAbsent(n.getId(), n);
         }
         }
 
 
@@ -146,6 +150,67 @@ public class DimensionRegistryService {
         return dimensionCodeMapper.resolveToUnified(legacy);
         return dimensionCodeMapper.resolveToUnified(legacy);
     }
     }
 
 
+    /**
+     * 按统一 code 取维度配置的 Dify DatasetId。
+     *
+     * <p>P1-T4:知识库同步(LanggraphSyncService)等消费方不再直读 product_dimension_config,
+     * 统一经注册中心层取数,避免绕过注册中心。</p>
+     *
+     * @param code 统一维度 code(如 body / body.growth / mind.openness)
+     * @return 该维度的 Dify DatasetId;code 不存在或未配置返回 null
+     */
+    public String getDifyDatasetIdByCode(String code) {
+        if (code == null || code.isEmpty()) {
+            return null;
+        }
+        ProductDimensionConfig cfg = productDimensionConfigService.getByCode(code);
+        return cfg != null ? cfg.getDifyDatasetId() : null;
+    }
+
+    /**
+     * 批量取维度节点 id → dimensionCode 映射。
+     *
+     * <p>P1-T4:知识库关联回填(DanKnowledgeBaseService)不再直读 product_dimension_config,
+     * 统一经注册中心层取数。仅返回维度节点 id(与注册中心维度节点 id 同源,均为
+     * product_dimension_config.id),指标节点 id 不在映射内。</p>
+     *
+     * @param ids 维度节点 id 集合(可空)
+     * @return id → dimensionCode 映射(无匹配或空入参返回空 map)
+     */
+    public Map<Long, String> getDimensionCodesByIds(Collection<Long> ids) {
+        Map<Long, String> map = new HashMap<>();
+        if (ids == null || ids.isEmpty()) {
+            return map;
+        }
+        List<ProductDimensionConfig> configs = productDimensionConfigService.listByIds(ids);
+        if (configs == null) {
+            return map;
+        }
+        for (ProductDimensionConfig cfg : configs) {
+            if (cfg.getId() != null && cfg.getDimensionCode() != null) {
+                map.put(cfg.getId(), cfg.getDimensionCode());
+            }
+        }
+        return map;
+    }
+
+    /**
+     * 按统一 code 取维度节点 id。
+     *
+     * <p>P1-T4:知识库自动绑定(DanKnowledgeBaseService.dimensionIdByCode)不再直读
+     * product_dimension_config,统一经注册中心层取数。</p>
+     *
+     * @param code 统一维度 code(如 body / body.growth)
+     * @return 该维度节点 id;code 不存在返回 null
+     */
+    public Long getDimensionIdByCode(String code) {
+        if (code == null || code.isEmpty()) {
+            return null;
+        }
+        ProductDimensionConfig cfg = productDimensionConfigService.getByCode(code);
+        return cfg != null ? cfg.getId() : null;
+    }
+
     /** 注册中心写入入口:admin 配置写产品维度配置表 + 校验 code 唯一。 */
     /** 注册中心写入入口:admin 配置写产品维度配置表 + 校验 code 唯一。 */
     public ProductDimensionConfig saveConfig(ProductDimensionConfig node) {
     public ProductDimensionConfig saveConfig(ProductDimensionConfig node) {
         if (node == null) {
         if (node == null) {
@@ -174,6 +239,51 @@ public class DimensionRegistryService {
         return dualWrite;
         return dualWrite;
     }
     }
 
 
+    /**
+     * 收集注册中心全部维度节点 code(nodeKind=root/sub,不含指标节点)。
+     *
+     * <p>供指标 dimensionCode 越界校验(P1-T3):指标可归属的维度 code 必须 ∈ 该集合,
+     * 如 body / body.growth / mind.openness / ...(共 5 根 + 33 子维 = 38 个维度节点)。</p>
+     *
+     * @return 维度节点 code 集合(非空)
+     */
+    public Set<String> collectDimensionCodes() {
+        Set<String> codes = new HashSet<>();
+        for (DimensionNode root : getTree(null)) {
+            collectNodeCodes(root, codes);
+        }
+        return codes;
+    }
+
+    private void collectNodeCodes(DimensionNode node, Set<String> codes) {
+        if (node == null) {
+            return;
+        }
+        // 仅收维度节点(root/sub),指标节点(nodeKind=indicator)不占用维度 code 命名空间
+        if (!"indicator".equals(node.getNodeKind())
+                && node.getCode() != null && !node.getCode().isEmpty()) {
+            codes.add(node.getCode());
+        }
+        if (node.getChildren() != null) {
+            for (DimensionNode child : node.getChildren()) {
+                collectNodeCodes(child, codes);
+            }
+        }
+    }
+
+    /**
+     * 校验 code 是否为注册中心维度节点 code(root/sub,不含指标节点)。
+     *
+     * @param code 待校验 code
+     * @return true=是注册中心维度节点 code;null/空/越界=false
+     */
+    public boolean isDimensionCode(String code) {
+        if (code == null || code.isEmpty()) {
+            return false;
+        }
+        return collectDimensionCodes().contains(code);
+    }
+
     private void reloadMapper() {
     private void reloadMapper() {
         try {
         try {
             dimensionCodeMapper.reload();
             dimensionCodeMapper.reload();

+ 36 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorService.java

@@ -5,6 +5,7 @@ import com.etotem.cfc.entity.IndicatorDefinition;
 import com.etotem.cfc.entity.IndicatorValue;
 import com.etotem.cfc.entity.IndicatorValue;
 import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
 import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
 import com.etotem.cfc.mapper.IndicatorValueMapper;
 import com.etotem.cfc.mapper.IndicatorValueMapper;
+import org.springframework.context.annotation.Lazy;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 import javax.annotation.Resource;
 import javax.annotation.Resource;
 import java.util.List;
 import java.util.List;
@@ -19,6 +20,18 @@ public class IndicatorService {
     @Resource
     @Resource
     private IndicatorValueMapper indicatorValueMapper;
     private IndicatorValueMapper indicatorValueMapper;
 
 
+    /**
+     * 统一维度注册中心服务(P1-T3 指标归属校验)。
+     * <p>用 @Lazy 打破循环依赖:DimensionRegistryService.getTree() 依赖本服务(listAll),
+     * 本服务又需查询注册中心 code 集合,Spring 需懒加载其中一侧。</p>
+     */
+    @Resource
+    @Lazy
+    private DimensionRegistryService dimensionRegistryService;
+
+    /** 越界提示(GlobalExceptionHandler 将 IllegalArgumentException 转 400)。 */
+    private static final String DIMENSION_CODE_OUT_OF_REGISTRY = "dimensionCode 不在统一维度注册中心";
+
     public List<IndicatorDefinition> listAll() {
     public List<IndicatorDefinition> listAll() {
         return indicatorDefinitionMapper.selectList(null);
         return indicatorDefinitionMapper.selectList(null);
     }
     }
@@ -42,15 +55,38 @@ public class IndicatorService {
     }
     }
 
 
     public IndicatorDefinition createDefinition(IndicatorDefinition def) {
     public IndicatorDefinition createDefinition(IndicatorDefinition def) {
+        validateDimensionCode(def);
         indicatorDefinitionMapper.insert(def);
         indicatorDefinitionMapper.insert(def);
         return def;
         return def;
     }
     }
 
 
     public IndicatorDefinition updateDefinition(IndicatorDefinition def) {
     public IndicatorDefinition updateDefinition(IndicatorDefinition def) {
+        validateDimensionCode(def);
         indicatorDefinitionMapper.updateById(def);
         indicatorDefinitionMapper.updateById(def);
         return def;
         return def;
     }
     }
 
 
+    /**
+     * 校验指标 dimensionCode ∈ 注册中心维度节点 code 集合(root/sub,不含指标节点)。
+     *
+     * <p>null/空 放行(表示"通用/不关联具体维度",与既有语义一致);
+     * 越界抛 IllegalArgumentException → GlobalExceptionHandler 转 Result.error(400, msg)。</p>
+     *
+     * @param def 指标定义(可为 null)
+     */
+    private void validateDimensionCode(IndicatorDefinition def) {
+        if (def == null) {
+            return;
+        }
+        String dimensionCode = def.getDimensionCode();
+        if (dimensionCode == null || dimensionCode.isEmpty()) {
+            return;
+        }
+        if (!dimensionRegistryService.isDimensionCode(dimensionCode)) {
+            throw new IllegalArgumentException(DIMENSION_CODE_OUT_OF_REGISTRY);
+        }
+    }
+
     public void deleteDefinition(Long id) {
     public void deleteDefinition(Long id) {
         indicatorDefinitionMapper.deleteById(id);
         indicatorDefinitionMapper.deleteById(id);
     }
     }

+ 4 - 8
cfc-backend/src/main/java/com/etotem/cfc/service/LanggraphSyncService.java

@@ -1,9 +1,6 @@
 package com.etotem.cfc.service;
 package com.etotem.cfc.service;
 
 
 import com.etotem.cfc.entity.DanKnowledgeBase;
 import com.etotem.cfc.entity.DanKnowledgeBase;
-import com.etotem.cfc.entity.ProductDimensionConfig;
-import com.etotem.cfc.mapper.ProductDimensionConfigMapper;
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import org.slf4j.Logger;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.beans.factory.annotation.Value;
@@ -38,7 +35,7 @@ public class LanggraphSyncService {
     private String difyApiKey;
     private String difyApiKey;
 
 
     @Resource
     @Resource
-    private ProductDimensionConfigMapper productDimensionConfigMapper;
+    private DimensionRegistryService dimensionRegistryService;
 
 
     private final RestTemplate restTemplate = new RestTemplate();
     private final RestTemplate restTemplate = new RestTemplate();
 
 
@@ -47,13 +44,12 @@ public class LanggraphSyncService {
         if (dimensionCodes == null || dimensionCodes.isEmpty()) return;
         if (dimensionCodes == null || dimensionCodes.isEmpty()) return;
         for (String code : dimensionCodes) {
         for (String code : dimensionCodes) {
             try {
             try {
-                ProductDimensionConfig config = productDimensionConfigMapper.selectOne(
-                    new LambdaQueryWrapper<ProductDimensionConfig>().eq(ProductDimensionConfig::getDimensionCode, code));
-                if (config == null || config.getDifyDatasetId() == null || config.getDifyDatasetId().isEmpty()) {
+                String datasetId = dimensionRegistryService.getDifyDatasetIdByCode(code);
+                if (datasetId == null || datasetId.isEmpty()) {
                     log.warn("维度[{}]未配置 Dify Dataset,跳过同步", code);
                     log.warn("维度[{}]未配置 Dify Dataset,跳过同步", code);
                     continue;
                     continue;
                 }
                 }
-                String url = difyBaseUrl + "/v1/datasets/" + config.getDifyDatasetId() + "/documents";
+                String url = difyBaseUrl + "/v1/datasets/" + datasetId + "/documents";
                 HttpHeaders headers = new HttpHeaders();
                 HttpHeaders headers = new HttpHeaders();
                 headers.setContentType(MediaType.APPLICATION_JSON);
                 headers.setContentType(MediaType.APPLICATION_JSON);
                 headers.set("Authorization", "Bearer " + difyApiKey);
                 headers.set("Authorization", "Bearer " + difyApiKey);

+ 55 - 0
cfc-backend/src/main/resources/db/migrate_dimension_unified.sql

@@ -0,0 +1,55 @@
+-- =====================================================================
+-- T5 历史迁移脚本:health_dimension_scores 维度编码统一(统一维度体系)
+-- ---------------------------------------------------------------------
+-- 性质:DBA 手工执行脚本,非应用自启(不进入 DatabaseInitializer 调用链)。
+-- 目的:将旧健康评分表里的裸维度名(growth/sleep/...)改写为统一点分编码
+--       (body.growth/...),与 product_dimension_config 注册中心对齐。
+-- 幂等性:所有语句均幂等,可重复执行;备份表 IF NOT EXISTS + INSERT 去重。
+-- 执行前请先在测试库演练并核对 affected rows。
+-- 日期戳:20261105(按执行日替换 YYYYMMDD 亦可,保持全脚本一致)
+-- =====================================================================
+
+-- ---------------------------------------------------------------------
+-- 步骤1:备份 health_dimension_scores(幂等:表已存在则跳过)
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS health_dimension_scores_bak_20261105
+    LIKE health_dimension_scores;
+
+-- 仅当备份表为空时首次全量同步;重复执行不会重复插入(按主键去重)
+INSERT INTO health_dimension_scores_bak_20261105
+SELECT * FROM health_dimension_scores
+WHERE NOT EXISTS (
+    SELECT 1 FROM health_dimension_scores_bak_20261105
+);
+
+-- ---------------------------------------------------------------------
+-- 步骤2:改写 7 个 body 子维编码(幂等:已带 'body.' 前缀的行不会被二次改写)
+--   旧维度名 -> 统一点分编码 body.{sub}
+-- ---------------------------------------------------------------------
+UPDATE health_dimension_scores
+SET dimension = CONCAT('body.', dimension)
+WHERE dimension IN ('growth','sleep','vision','immunity','nutrition','gut','exercise');
+
+-- ---------------------------------------------------------------------
+-- 步骤3:越界扫描(仅报告,不修改)——供 P1-T3 收敛
+--   列出 indicator_definitions 中 dimension_code 不属于
+--   [5 根 + 33 子维] 白名单的越界记录,人工确认后应收敛。
+-- ---------------------------------------------------------------------
+SELECT id, code, name, dimension_code
+FROM indicator_definitions
+WHERE dimension_code IS NOT NULL
+  AND dimension_code NOT IN (
+    -- 5 根
+    'body','mind','wisdom','action','wealth',
+    -- 7 body 子维(P0-T3 已 seed)
+    'body.growth','body.sleep','body.vision','body.immunity','body.nutrition','body.gut','body.exercise',
+    -- 7 wisdom 子维(P0 增量 迁移448)
+    'wisdom.memory','wisdom.attention','wisdom.logic','wisdom.spatial','wisdom.perception','wisdom.focus','wisdom.processingSpeed',
+    -- 10 mind 子维(P0 增量 迁移448)
+    'mind.openness','mind.conscientiousness','mind.extraversion','mind.agreeableness','mind.neuroticism',
+    'mind.driveAutonomy','mind.driveCompetence','mind.driveRelatedness','mind.emotionStability','mind.selfConceptOverall',
+    -- 5 action 子维(P0 增量 迁移448)
+    'action.parentTrust','action.parentIntimacy','action.communication','action.healthStatus','action.lastInteraction',
+    -- 4 wealth 子维(P0 增量 迁移448)
+    'wealth.skill','wealth.altruism','wealth.income','wealth.fx'
+  );

+ 1 - 1
cfc-backend/src/main/resources/schema.sql

@@ -2622,7 +2622,7 @@ CREATE TABLE IF NOT EXISTS indicator_definitions (
     report_type_scope VARCHAR(32) COMMENT '适用报告类型(type_id,空=通用)',
     report_type_scope VARCHAR(32) COMMENT '适用报告类型(type_id,空=通用)',
     value_type VARCHAR(16) COMMENT '语义类型: numeric/categorical/level',
     value_type VARCHAR(16) COMMENT '语义类型: numeric/categorical/level',
     good_direction VARCHAR(16) DEFAULT 'neutral' COMMENT '变化优劣方向: higher_is_better/lower_is_better/in_range_is_better/neutral',
     good_direction VARCHAR(16) DEFAULT 'neutral' COMMENT '变化优劣方向: higher_is_better/lower_is_better/in_range_is_better/neutral',
-    dimension_code VARCHAR(16) COMMENT '关联五维(body/mind/wisdom/action/wealth,可空)',
+    dimension_code VARCHAR(64) COMMENT '关联统一维度节点code(注册中心root/sub,可空)',
     parent_id BIGINT COMMENT '父节点ID(可指向product_dimension_config.id或本表id, 混排进维度树)',
     parent_id BIGINT COMMENT '父节点ID(可指向product_dimension_config.id或本表id, 混排进维度树)',
     legacy_alias VARCHAR(512) COMMENT '历史别名(JSON数组)',
     legacy_alias VARCHAR(512) COMMENT '历史别名(JSON数组)',
     sort_order INT DEFAULT 0 COMMENT '排序',
     sort_order INT DEFAULT 0 COMMENT '排序',

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

@@ -0,0 +1,163 @@
+import config from '@/config.js'
+
+/**
+ * useDimensionRegistry - 统一维度注册中心(前端只读封装)
+ *
+ * 健康页 5 张雷达图(身体 / 认知 / 心理 / 社会性 / 财富)原前端硬编码维度清单与取分字段,
+ * 现改为从统一维度注册中心 `/api/dimension-registry/tree` 配置驱动取数画图
+ * (落实产品要求「健康页面的雷达图也是按这个配置取数据画图」)。
+ *
+ * 设计要点:
+ * 1. 模块级缓存:首次请求成功后整棵树缓存,tab 切换不再重复请求。
+ * 2. ready 标志:请求成功或失败后均置位,供页面安全兜底(后端未上线 / 接口异常 → 回落硬编码)。
+ * 3. 失败不抛错:ready=false、返回空,页面降级,不阻断渲染。
+ * 4. 兼容 uni-app 硬约束:不使用可选链 `?.`、new Date(string)、CSS Grid。
+ *
+ * 契约(已与后端 DimensionNode / seed 对齐):
+ *   GET /api/dimension-registry/tree → Result<List<DimensionNode>>
+ *   DimensionNode: { id, code, name, parentId, nodeKind, sourceSystem, legacyAlias, wuxingRuleRef, children }
+ *   legacyAlias 是 JSON 数组字符串,如 '["opennessScore"]',取首个元素作为分数字段名 scoreField。
+ */
+
+// 模块级缓存:跨组件 / 跨 tab 复用,避免重复请求
+var _cache = {
+  tree: null, // DimensionNode[](5 个根,各带 children)
+  ready: false, // 首次请求是否已结束(成功或失败均置位)
+  pending: null // 进行中的 Promise(防并发重复请求)
+}
+
+// tab(code) → 注册中心根 code 映射
+// body→body,cognitive→wisdom,mental→mind,relationship→action,wealth→wealth
+var TAB_TO_ROOT = {
+  body: 'body',
+  cognitive: 'wisdom',
+  mental: 'mind',
+  relationship: 'action',
+  wealth: 'wealth'
+}
+
+// 财富子维度在「成员(儿童)」语境下使用更贴近儿童的文案(与改造前字面量一致)
+var WEALTH_CHILD_LABEL = {
+  'wealth.skill': '学业能力',
+  'wealth.income': '积分积累'
+}
+
+/**
+ * 解析 legacyAlias:JSON 数组字符串,取首个元素作为分数字段名 scoreField。
+ * 例:'["opennessScore"]' → 'opennessScore';'["driveAutonomy"]' → 'driveAutonomy'
+ * @param {string} alias
+ * @returns {string}
+ */
+function parseLegacyAlias(alias) {
+  if (!alias || typeof alias !== 'string') return ''
+  var arr = null
+  try {
+    arr = JSON.parse(alias)
+  } catch (e) {
+    return ''
+  }
+  if (!Array.isArray(arr) || arr.length === 0) return ''
+  return typeof arr[0] === 'string' ? arr[0] : ''
+}
+
+/**
+ * 触发(或复用)注册中心树拉取。幂等:已尝试过则直接 resolve,不重复请求。
+ * @returns {Promise<void>}
+ */
+function ensureDimensionRegistry() {
+  if (_cache.ready) {
+    return Promise.resolve()
+  }
+  if (_cache.pending) {
+    return _cache.pending
+  }
+  _cache.pending = new Promise(function(resolve) {
+    var token = uni.getStorageSync('token')
+    var base = (config && config.API_BASE_URL) ? config.API_BASE_URL : ''
+    var url = base + '/api/dimension-registry/tree'
+    uni.request({
+      url: url,
+      method: 'GET',
+      header: {
+        'Content-Type': 'application/json',
+        'Authorization': token ? 'Bearer ' + token : ''
+      },
+      success: function(res) {
+        try {
+          var data = res && res.data
+          // Result<List<DimensionNode>> → data.data
+          var list = (data && data.code === 200 && data.data) ? data.data : []
+          if (Array.isArray(list) && list.length > 0) {
+            _cache.tree = list
+          }
+        } catch (e) {
+          // 解析异常:保持 tree 为空,回落兜底
+          _cache.tree = null
+        }
+        // 无论成功 / 空数据 / 解析异常,均标记尝试结束(不抛错)
+        _cache.ready = true
+        resolve()
+      },
+      fail: function() {
+        // 网络 / 接口异常:不抛错,回落硬编码兜底
+        _cache.ready = true
+        resolve()
+      }
+    })
+  })
+  // 请求结束后清空进行中引用(tree / ready 仍保留)
+  _cache.pending.then(function() { _cache.pending = null })
+  return _cache.pending
+}
+
+/**
+ * 注册中心树是否就绪(成功取到数据)。
+ * @returns {boolean}
+ */
+function isRegistryReady() {
+  return !!_cache.tree && _cache.tree.length > 0
+}
+
+/**
+ * 取某 tab 的雷达配置:[{ label, code, scoreField }]
+ *  - label:注册中心节点 name(维度中文名)
+ *  - code:节点 code(body 类为 body.growth,用于 bodyData 按 dimension 字段匹配)
+ *  - scoreField:legacyAlias 首元素(取分读取器字段名)
+ * @param {string} tabCode
+ * @returns {Array}
+ */
+function getRadarConfig(tabCode) {
+  var rootCode = TAB_TO_ROOT[tabCode]
+  if (!rootCode || !_cache.tree || !Array.isArray(_cache.tree)) return []
+  var root = null
+  for (var i = 0; i < _cache.tree.length; i++) {
+    var node = _cache.tree[i]
+    if (node && node.code === rootCode) {
+      root = node
+      break
+    }
+  }
+  if (!root || !root.children || !root.children.length) return []
+  var config = []
+  for (var j = 0; j < root.children.length; j++) {
+    var child = root.children[j]
+    if (!child) continue
+    // P1-T3 防御:指标节点(nodeKind=indicator)是维度树的混排子节点,
+    // 不应作为健康页雷达图的轴(雷达轴应为 sub-dim 维度节点)。
+    // 操作者将指标挂到某维度节点下后,该节点 children 会含指标,过滤掉保持雷达 5×7/7/10/5/4 不变。
+    if (child.nodeKind === 'indicator') continue
+    config.push({
+      label: child.name || '',
+      code: child.code || '',
+      scoreField: parseLegacyAlias(child.legacyAlias)
+    })
+  }
+  return config
+}
+
+export {
+  ensureDimensionRegistry,
+  getRadarConfig,
+  isRegistryReady,
+  WEALTH_CHILD_LABEL
+}

+ 174 - 64
cfc-frontend/pages/health-main/index.vue

@@ -262,6 +262,7 @@ import RadarChart from '../../components/RadarChart.vue'
 import ArticleFeed from '../../components/ArticleFeed.vue'
 import ArticleFeed from '../../components/ArticleFeed.vue'
 import ReportUploadCard from '../../components/report-upload-card.vue'
 import ReportUploadCard from '../../components/report-upload-card.vue'
 import { parseDate } from '../../utils/format.js'
 import { parseDate } from '../../utils/format.js'
+import { ensureDimensionRegistry, getRadarConfig, isRegistryReady, WEALTH_CHILD_LABEL } from '../../composables/useDimensionRegistry.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 import shareMixin from '../../components/share-mixin.js'
 import shareMixin from '../../components/share-mixin.js'
 
 
@@ -324,7 +325,10 @@ export default {
       childrenList: [],
       childrenList: [],
       relationshipEmpty: false,
       relationshipEmpty: false,
       // 登录浮层打开标志(打开期间隐藏演示雷达,避免原生 canvas 盖在浮层之上)
       // 登录浮层打开标志(打开期间隐藏演示雷达,避免原生 canvas 盖在浮层之上)
-      loginSheetVisible: false
+      loginSheetVisible: false,
+      // 统一维度注册中心是否就绪(成功取到 tree 后由 ensureDimensionRegistry 置位)
+      // 未就绪时主页雷达回落硬编码兜底,保证后端未上线 / 接口异常时页面不崩
+      registryReady: false
     }
     }
   },
   },
   computed: {
   computed: {
@@ -337,23 +341,23 @@ export default {
       return tab ? tab.color : BrandColors.body
       return tab ? tab.color : BrandColors.body
     },
     },
     currentDimensions: function() {
     currentDimensions: function() {
-      if (this.currentTab === 'cognitive') {
-        return [{ label:'记忆力',key:'memory'},{ label:'注意力',key:'attention'},{ label:'逻辑推理',key:'logic'},{ label:'空间想象',key:'spatial'},{ label:'感知能力',key:'perception'},{ label:'专注力',key:'focus'},{ label:'加工速度',key:'processingSpeed' }]
-      }
-      if (this.currentTab === 'mental') {
-        return [{ label:'开放性',key:'openness'},{ label:'尽责性',key:'conscientiousness'},{ label:'外向性',key:'extraversion'},{ label:'宜人性',key:'agreeableness'},{ label:'神经质',key:'neuroticism'},{ label:'自主性',key:'driveAutonomy'},{ label:'胜任感',key:'driveCompetence'},{ label:'归属感',key:'driveRelatedness'},{ label:'情绪稳定性',key:'emotionStability'},{ label:'自我概念',key:'selfConceptOverall' }]
-      }
-      if (this.currentTab === 'relationship') {
-        return [{ label:'亲子信任',key:'parentTrust'},{ label:'亲子亲密度',key:'parentIntimacy'},{ label:'沟通质量',key:'communication'},{ label:'关系健康度',key:'healthStatus'},{ label:'最近互动',key:'lastInteraction' }]
-      }
-      if (this.currentTab === 'wealth') {
+      // 配置驱动:从统一维度注册中心 tree 取该 tab 根节点的 children 作为维度清单
+      var config = getRadarConfig(this.currentTab)
+      if (this.registryReady && config && config.length > 0) {
         var isChild = !!uni.getStorageSync('currentChildId')
         var isChild = !!uni.getStorageSync('currentChildId')
-        if (isChild) {
-          return [{ label:'学业能力',key:'skill'},{ label:'利他贡献',key:'altruism'},{ label:'积分积累',key:'income'},{ label:'财务素养',key:'fx' }]
-        }
-        return [{ label:'个人能力',key:'skill'},{ label:'利他贡献',key:'altruism'},{ label:'CF值积累',key:'income'},{ label:'财务素养',key:'fx' }]
+        var self = this
+        return config.map(function(c) {
+          var label = c.label
+          // 财富子维度在「成员(儿童)」语境下使用更贴近儿童的文案(与改造前字面量一致)
+          if (self.currentTab === 'wealth' && isChild && WEALTH_CHILD_LABEL[c.code]) {
+            label = WEALTH_CHILD_LABEL[c.code]
+          }
+          // label 供 RadarChart 画维度名;key 供(若有)点击透传;code 保留注册中心维度码
+          return { label: label, key: c.code, code: c.code }
+        })
       }
       }
-      return [{ label:'生长发育',key:'growth'},{ label:'睡眠质量',key:'sleep'},{ label:'视力健康',key:'vision'},{ label:'免疫力',key:'immunity'},{ label:'营养均衡',key:'nutrition'},{ label:'肠胃健康',key:'gut'},{ label:'运动活力',key:'exercise' }]
+      // 过渡兜底:注册中心未就绪或某根无子维 → 回落现有硬编码清单(后端未上线 / 接口异常时页面不崩)
+      return this._fallbackDimensions(this.currentTab)
     },
     },
     currentScores: function() {
     currentScores: function() {
       if (this.chartIsEmpty) {
       if (this.chartIsEmpty) {
@@ -361,55 +365,16 @@ export default {
         for (var i = 0; i < dims.length; i++) scores.push(0)
         for (var i = 0; i < dims.length; i++) scores.push(0)
         return scores
         return scores
       }
       }
-      if (this.currentTab === 'cognitive') {
-        return [
-          this._cognitiveScore('memoryScore'),
-          this._cognitiveScore('attentionScore'),
-          this._cognitiveScore('logicScore'),
-          this._cognitiveScore('spatialScore'),
-          this._cognitiveScore('perceptionScore'),
-          this._cognitiveScore('focusScore'),
-          this._cognitiveScore('processingSpeedScore')
-        ]
-      }
-      if (this.currentTab === 'mental') {
-        return [
-          this._emiScore('opennessScore'),
-          this._emiScore('conscientiousnessScore'),
-          this._emiScore('extraversionScore'),
-          this._emiScore('agreeablenessScore'),
-          this._emiScore('neuroticismScore'),
-          this._emiScore('driveAutonomy'),
-          this._emiScore('driveCompetence'),
-          this._emiScore('driveRelatedness'),
-          this._emiScore('emotionStability'),
-          this._emiScore('selfConceptOverall')
-        ]
-      }
-      if (this.currentTab === 'relationship') {
-        return [
-          this._relScore('trustScore'),
-          this._relScore('intimacyScore'),
-          this._relScore('communicationScore'),
-          this._relScore('totalScore'),
-          0
-        ]
-      }
-      if (this.currentTab === 'wealth') {
-        var d = this.wealthDetail
-        if (!d) return [0, 0, 0, 0]
-        return [d.wealthSkill || 0, d.wealthAltruism || 0, d.wealthIncome || 0, d.wealthFx || 0]
-      }
-      // body: 从 API 真实维度数据取分
-      if (this.bodyData && this.bodyData.dimensions && this.bodyData.dimensions.length > 0) {
-        return this.bodyData.dimensions.map(function(d) { return Number(d.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]
+      // 配置驱动:与 currentDimensions 同源同序遍历注册中心配置,按 scoreField 取分
+      var config = getRadarConfig(this.currentTab)
+      if (this.registryReady && config && config.length > 0) {
+        var self = this
+        return config.map(function(c) {
+          return self._readRegistryScore(self.currentTab, c.code, c.scoreField)
+        })
       }
       }
-      return [72, 68, 75, 80, 70, 78, 82]
+      // 过渡兜底:注册中心未就绪 → 回落现有硬编码取分逻辑(与改造前一致)
+      return this._fallbackScores(this.currentTab)
     },
     },
     currentAvgScores: function() {
     currentAvgScores: function() {
       if (this.chartIsEmpty) return []
       if (this.chartIsEmpty) return []
@@ -470,6 +435,13 @@ export default {
       this.loadHealthData()
       this.loadHealthData()
       this.loadPlanList()
       this.loadPlanList()
       this.loadPendingSurveys()
       this.loadPendingSurveys()
+      // 触发统一维度注册中心树拉取(主页雷达配置驱动取数;失败则回落硬编码兜底)
+      var self = this
+      ensureDimensionRegistry().then(function() {
+        self.registryReady = isRegistryReady()
+      }).catch(function() {
+        self.registryReady = false
+      })
     } else {
     } else {
       this.pendingSurveys = []
       this.pendingSurveys = []
     }
     }
@@ -499,6 +471,144 @@ export default {
       var v = this.energySandbox[key]
       var v = this.energySandbox[key]
       return (typeof v === 'number' && v > 0) ? v : 0
       return (typeof v === 'number' && v > 0) ? v : 0
     },
     },
+    /**
+     * 过渡兜底:返回某 tab 的硬编码维度清单(与改造前字面量一致)。
+     * 仅当注册中心未就绪时使用,保证后端未上线 / 接口异常时页面不崩。
+     * @param {string} tab
+     * @returns {Array<{label:string,key:string}>}
+     */
+    _fallbackDimensions: function(tab) {
+      if (tab === 'cognitive') {
+        return [{ label:'记忆力',key:'memory'},{ label:'注意力',key:'attention'},{ label:'逻辑推理',key:'logic'},{ label:'空间想象',key:'spatial'},{ label:'感知能力',key:'perception'},{ label:'专注力',key:'focus'},{ label:'加工速度',key:'processingSpeed' }]
+      }
+      if (tab === 'mental') {
+        return [{ label:'开放性',key:'openness'},{ label:'尽责性',key:'conscientiousness'},{ label:'外向性',key:'extraversion'},{ label:'宜人性',key:'agreeableness'},{ label:'神经质',key:'neuroticism'},{ label:'自主性',key:'driveAutonomy'},{ label:'胜任感',key:'driveCompetence'},{ label:'归属感',key:'driveRelatedness'},{ label:'情绪稳定性',key:'emotionStability'},{ label:'自我概念',key:'selfConceptOverall' }]
+      }
+      if (tab === 'relationship') {
+        return [{ label:'亲子信任',key:'parentTrust'},{ label:'亲子亲密度',key:'parentIntimacy'},{ label:'沟通质量',key:'communication'},{ label:'关系健康度',key:'healthStatus'},{ label:'最近互动',key:'lastInteraction' }]
+      }
+      if (tab === 'wealth') {
+        var isChild = !!uni.getStorageSync('currentChildId')
+        if (isChild) {
+          return [{ label:'学业能力',key:'skill'},{ label:'利他贡献',key:'altruism'},{ label:'积分积累',key:'income'},{ label:'财务素养',key:'fx' }]
+        }
+        return [{ label:'个人能力',key:'skill'},{ label:'利他贡献',key:'altruism'},{ label:'CF值积累',key:'income'},{ label:'财务素养',key:'fx' }]
+      }
+      return [{ label:'生长发育',key:'growth'},{ label:'睡眠质量',key:'sleep'},{ label:'视力健康',key:'vision'},{ label:'免疫力',key:'immunity'},{ label:'营养均衡',key:'nutrition'},{ label:'肠胃健康',key:'gut'},{ label:'运动活力',key:'exercise' }]
+    },
+    /**
+     * 过渡兜底:返回某 tab 的硬编码取分(与改造前字面量一致)。
+     * @param {string} tab
+     * @returns {Array<number>}
+     */
+    _fallbackScores: function(tab) {
+      if (tab === 'cognitive') {
+        return [
+          this._cognitiveScore('memoryScore'),
+          this._cognitiveScore('attentionScore'),
+          this._cognitiveScore('logicScore'),
+          this._cognitiveScore('spatialScore'),
+          this._cognitiveScore('perceptionScore'),
+          this._cognitiveScore('focusScore'),
+          this._cognitiveScore('processingSpeedScore')
+        ]
+      }
+      if (tab === 'mental') {
+        return [
+          this._emiScore('opennessScore'),
+          this._emiScore('conscientiousnessScore'),
+          this._emiScore('extraversionScore'),
+          this._emiScore('agreeablenessScore'),
+          this._emiScore('neuroticismScore'),
+          this._emiScore('driveAutonomy'),
+          this._emiScore('driveCompetence'),
+          this._emiScore('driveRelatedness'),
+          this._emiScore('emotionStability'),
+          this._emiScore('selfConceptOverall')
+        ]
+      }
+      if (tab === 'relationship') {
+        return [
+          this._relScore('trustScore'),
+          this._relScore('intimacyScore'),
+          this._relScore('communicationScore'),
+          this._relScore('totalScore'),
+          0
+        ]
+      }
+      if (tab === 'wealth') {
+        var d = this.wealthDetail
+        if (!d) return [0, 0, 0, 0]
+        return [d.wealthSkill || 0, d.wealthAltruism || 0, d.wealthIncome || 0, d.wealthFx || 0]
+      }
+      // body: 从 API 真实维度数据取分
+      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]
+    },
+    /**
+     * 配置驱动取分:按 tab 与注册中心节点的 scoreField / code 从对应分数包取分。
+     * 包缺失或值非数字 → 0(保持现有零填充行为)。与改造前取分口径逐一对齐。
+     * @param {string} tab
+     * @param {string} code 注册中心节点 code(body 类为 body.growth)
+     * @param {string} scoreField legacyAlias 首元素(取分字段名)
+     * @returns {number}
+     */
+    _readRegistryScore: function(tab, code, scoreField) {
+      if (tab === 'body') {
+        // body 维度按 dimension 字段匹配:兼容两种格式——
+        // ① 当前后端 /api/dimension/overview 仍返回 legacy 码(growth/sleep/...,见 DimensionScoreServiceImpl.DIMENSIONS),
+        //    此时与 legacyAlias 首元素(scoreField)匹配;
+        // ② 后续后端迁移为统一码(body.growth/...)后,与注册中心节点 code 匹配。
+        var dims = this.bodyData && this.bodyData.dimensions
+        if (!dims || !dims.length) return 0
+        for (var i = 0; i < dims.length; i++) {
+          var dItem = dims[i]
+          if (dItem && (dItem.dimension === code || dItem.dimension === scoreField)) {
+            var v = Number(dItem.score)
+            return (typeof v === 'number' && !isNaN(v)) ? v : 0
+          }
+        }
+        return 0
+      }
+      if (tab === 'cognitive') {
+        if (!this.cognitiveData) return 0
+        var cv = this.cognitiveData[scoreField]
+        return (typeof cv === 'number' && cv > 0) ? cv : 0
+      }
+      if (tab === 'mental') {
+        if (!this.emiData) return 0
+        var mv = this.emiData[scoreField]
+        return (typeof mv === 'number' && mv > 0) ? mv : 0
+      }
+      if (tab === 'relationship') {
+        // 最近互动是日期字段,恒为 0(与改造前字面量 0 一致)
+        if (scoreField === 'lastInteractionAt') return 0
+        if (!this.relationshipData || this.relationshipData.length === 0) return 0
+        var total = 0, count = 0
+        for (var r = 0; r < this.relationshipData.length; r++) {
+          var item = this.relationshipData[r]
+          if (item && item[scoreField] != null) {
+            total += Number(item[scoreField])
+            count++
+          }
+        }
+        return count > 0 ? Math.round(total / count) : 0
+      }
+      if (tab === 'wealth') {
+        var wd = this.wealthDetail
+        if (!wd) return 0
+        var wv = wd[scoreField]
+        return (typeof wv === 'number' && wv > 0) ? wv : 0
+      }
+      return 0
+    },
     switchTab: function(code) { this.currentTab = code },
     switchTab: function(code) { this.currentTab = code },
     goPage: function(url) { uni.navigateTo({ url: url }) },
     goPage: function(url) { uni.navigateTo({ url: url }) },
     // 登录浮层打开/关闭(隐藏演示雷达,规避原生 canvas 层级高于浮层的问题)
     // 登录浮层打开/关闭(隐藏演示雷达,规避原生 canvas 层级高于浮层的问题)

+ 13 - 5
cfc-web/src/components/ContentDimensionThresholdEditor.vue

@@ -66,7 +66,7 @@
 </template>
 </template>
 
 
 <script>
 <script>
-import { getDimensionTree } from '@/api/dimension'
+import { getDimensionRegistryTree } from '@/api/dimension'
 import { getContentDimensionThresholdList, batchSaveContentDimensionThreshold } from '@/api/content-dimension-threshold'
 import { getContentDimensionThresholdList, batchSaveContentDimensionThreshold } from '@/api/content-dimension-threshold'
 
 
 export default {
 export default {
@@ -117,7 +117,9 @@ export default {
   methods: {
   methods: {
     async loadDimensionTree() {
     async loadDimensionTree() {
       try {
       try {
-        var res = await getDimensionTree()
+        // 统一维度体系(P1-T4):改走注册中心只读接口 /api/dimension-registry/tree,
+        // 不再直读 /api/admin/dimension-config/tree(旧管理端写接口仅保留给配置管理页)。
+        var res = await getDimensionRegistryTree()
         if (res.code === 200 && Array.isArray(res.data)) {
         if (res.code === 200 && Array.isArray(res.data)) {
           this.flatOptions = this.flattenTree(res.data)
           this.flatOptions = this.flattenTree(res.data)
         }
         }
@@ -129,9 +131,15 @@ export default {
       var result = []
       var result = []
       var walk = function(list, groupName) {
       var walk = function(list, groupName) {
         ;(list || []).forEach(function(n) {
         ;(list || []).forEach(function(n) {
-          var g = groupName || (n.dimensionName || '其他')
-          if (n.dimensionCode) {
-            result.push({ dimensionCode: n.dimensionCode, dimensionName: n.dimensionName, group: g })
+          if (!n) return
+          // 指标节点(nodeKind=indicator)不参与维度阈值选择(阈值按维度 code 配置)
+          if (n.nodeKind === 'indicator') return
+          // 注册中心节点字段为 code/name,兼容旧接口的 dimensionCode/dimensionName
+          var code = n.dimensionCode || n.code
+          var name = n.dimensionName || n.name
+          var g = groupName || (name || '其他')
+          if (code) {
+            result.push({ dimensionCode: code, dimensionName: name, group: g })
           }
           }
           if (n.children && n.children.length) {
           if (n.children && n.children.length) {
             walk(n.children, g)
             walk(n.children, g)

+ 3 - 0
cfc-web/src/components/DimensionSelector/index.vue

@@ -84,6 +84,9 @@ function flattenRegistryTree(nodes, result) {
   result = result || []
   result = result || []
   if (!nodes || !nodes.length) return result
   if (!nodes || !nodes.length) return result
   nodes.forEach(n => {
   nodes.forEach(n => {
+    // P1-T4:指标节点(nodeKind=indicator)不进入维度选择器——旧数据源
+    // (product_dimension_config) 仅含维度节点,混入指标会改变选择项语义。
+    if (n && n.nodeKind === 'indicator') return
     result.push({
     result.push({
       id: n.id,
       id: n.id,
       dimensionCode: n.code,
       dimensionCode: n.code,

+ 120 - 13
cfc-web/src/views/admin/indicators/index.vue

@@ -42,13 +42,13 @@
           <span class="sort-header" @click.stop="onSortClick('name', $event)">名称<span v-if="sortIndex('name') !== null" class="sort-index" :class="sortClass('name') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('name') }}</span>{{ sortIcon('name') }}</span>
           <span class="sort-header" @click.stop="onSortClick('name', $event)">名称<span v-if="sortIndex('name') !== null" class="sort-index" :class="sortClass('name') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('name') }}</span>{{ sortIcon('name') }}</span>
         </template>
         </template>
       </el-table-column>
       </el-table-column>
-      <el-table-column prop="dimensionCode" label="五维归属" width="100">
+      <el-table-column prop="dimensionCode" label="维度归属" width="110">
         <template slot-scope="{ row }">
         <template slot-scope="{ row }">
           <span v-if="row.dimensionCode">{{ dimensionLabel(row.dimensionCode) }}</span>
           <span v-if="row.dimensionCode">{{ dimensionLabel(row.dimensionCode) }}</span>
           <span v-else class="text-muted">通用</span>
           <span v-else class="text-muted">通用</span>
         </template>
         </template>
         <template slot="header">
         <template slot="header">
-          <span class="sort-header" @click.stop="onSortClick('dimensionCode', $event)">五维归属<span v-if="sortIndex('dimensionCode') !== null" class="sort-index" :class="sortClass('dimensionCode') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('dimensionCode') }}</span>{{ sortIcon('dimensionCode') }}</span>
+          <span class="sort-header" @click.stop="onSortClick('dimensionCode', $event)">维度归属<span v-if="sortIndex('dimensionCode') !== null" class="sort-index" :class="sortClass('dimensionCode') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('dimensionCode') }}</span>{{ sortIcon('dimensionCode') }}</span>
         </template>
         </template>
       </el-table-column>
       </el-table-column>
       <el-table-column prop="sourceKind" label="数据来源" width="120">
       <el-table-column prop="sourceKind" label="数据来源" width="120">
@@ -114,14 +114,25 @@
         <el-form-item label="名称" required>
         <el-form-item label="名称" required>
           <el-input v-model="form.name" />
           <el-input v-model="form.name" />
         </el-form-item>
         </el-form-item>
-        <el-form-item label="五维归属">
-          <el-select v-model="form.dimensionCode" class="form-full" clearable placeholder="不选=通用(不关联具体五维)">
-            <el-option label="身" value="body" />
-            <el-option label="智" value="mind" />
-            <el-option label="富" value="wisdom" />
-            <el-option label="行" value="action" />
-            <el-option label="心" value="wealth" />
-          </el-select>
+        <el-form-item label="维度归属">
+          <!-- P1-T3:从统一维度注册中心树选择归属节点(parentId=节点id,dimensionCode=节点code) -->
+          <div class="dim-tree-box">
+            <el-tree
+              ref="dimTree"
+              :data="dimensionTreeData"
+              :props="{ label: 'label', children: 'children', disabled: 'disabled' }"
+              node-key="id"
+              highlight-current
+              :expand-on-click-node="false"
+              :current-node-key="currentTreeNodeId"
+              @node-click="onDimensionNodeClick"
+            />
+            <div class="dim-tree-selected" v-if="selectedDimPath">
+              已选:{{ selectedDimPath }}
+              <el-button type="text" size="mini" @click="clearDimension">清除</el-button>
+            </div>
+            <div class="dim-tree-selected text-muted" v-else>不选=通用(不关联具体维度)</div>
+          </div>
         </el-form-item>
         </el-form-item>
         <el-form-item label="数据来源">
         <el-form-item label="数据来源">
           <el-select v-model="form.sourceKind" class="form-full" clearable placeholder="不选=通用">
           <el-select v-model="form.sourceKind" class="form-full" clearable placeholder="不选=通用">
@@ -162,6 +173,7 @@
 <script>
 <script>
 import SortMixin from '@/mixins/SortMixin'
 import SortMixin from '@/mixins/SortMixin'
 import { listIndicatorDefinitions, createIndicator, updateIndicator, deleteIndicator } from '@/api/indicator'
 import { listIndicatorDefinitions, createIndicator, updateIndicator, deleteIndicator } from '@/api/indicator'
+import { getDimensionRegistryTree } from '@/api/dimension'
 
 
 export default {
 export default {
   name: 'IndicatorsIndex',
   name: 'IndicatorsIndex',
@@ -190,23 +202,94 @@ export default {
         options: '',
         options: '',
         unit: '',
         unit: '',
         dimensionCode: '',
         dimensionCode: '',
+        parentId: null,
         sourceKind: '',
         sourceKind: '',
         sortOrder: 0,
         sortOrder: 0,
         status: 1
         status: 1
       },
       },
-      sortableColumns: { id: 'ID', domain: '领域', category: '分类', code: '编码', name: '名称', dataType: '数据类型', options: '可选值', dimensionCode: '五维归属', sourceKind: '数据来源', sortOrder: '排序', status: '状态' }
+      sortableColumns: { id: 'ID', domain: '领域', category: '分类', code: '编码', name: '名称', dataType: '数据类型', options: '可选值', dimensionCode: '维度归属', sourceKind: '数据来源', sortOrder: '排序', status: '状态' },
+      // 统一维度注册中心树(P1-T3):指标维度归属从五维单选改为从注册中心树选择
+      dimensionTreeData: [],
+      currentTreeNodeId: null,
+      selectedDimPath: '',
+      _dimPathMap: {}
     }
     }
   },
   },
   mounted() {
   mounted() {
     this.loadData()
     this.loadData()
+    // P1-T3:加载统一维度注册中心树,供维度归属树形选择
+    this.loadDimensionTree()
   },
   },
   methods: {
   methods: {
     loadList() {
     loadList() {
       this.loadData()
       this.loadData()
     },
     },
     dimensionLabel(code) {
     dimensionLabel(code) {
-      const map = { body: '身', mind: '智', wisdom: '富', action: '行', wealth: '心' }
-      return map[code] || code
+      // 注册中心 root code 显示中文名;子维度 code(如 body.growth)直接显示
+      const rootMap = { body: '身体', mind: '心理', wisdom: '智能', action: '行动', wealth: '财富' }
+      return rootMap[code] || code
+    },
+    // P1-T3:加载统一维度注册中心树(GET /api/dimension-registry/tree)
+    loadDimensionTree() {
+      getDimensionRegistryTree().then(res => {
+        if (res.code === 200 && Array.isArray(res.data)) {
+          this.dimensionTreeData = this._mapDimNodes(res.data)
+          this._dimPathMap = {}
+          this._buildDimPathMap(res.data, [])
+        }
+      }).catch(() => {})
+    },
+    _mapDimNodes(nodes) {
+      return (nodes || []).map(n => {
+        return {
+          id: n.id,
+          code: n.code,
+          label: n.name || n.code || '',
+          nodeKind: n.nodeKind || '',
+          // 指标节点也暴露归属维度 code(P2-T1:指标挂指标时子指标继承该归属)
+          dimensionCode: n.dimensionCode || '',
+          // P2-T1:允许挂到任意维度节点(root/sub)或指标节点(多级细化),全部可选作父
+          disabled: false,
+          children: this._mapDimNodes(n.children || [])
+        }
+      })
+    },
+    _buildDimPathMap(nodes, prefix) {
+      prefix = prefix || []
+      ;(nodes || []).forEach(n => {
+        var path = prefix.concat(n.name || n.code || '')
+        this._dimPathMap[n.id] = path
+        this._buildDimPathMap(n.children || [], path)
+      })
+    },
+    // 树节点点击:设置挂接节点(parentId=节点id,dimensionCode=节点code)
+    onDimensionNodeClick(data, node) {
+      this.form.parentId = data.id
+      if (data.nodeKind === 'indicator') {
+        // P2-T1:指标挂指标 → 子指标继承父指标的归属维度 code(保持 dimensionCode 合法)
+        this.form.dimensionCode = data.dimensionCode || ''
+      } else {
+        // 维度节点(root/sub) → 归属维度 code 即节点 code
+        this.form.dimensionCode = data.code
+      }
+      this.currentTreeNodeId = data.id
+      var labels = []
+      var p = node
+      while (p && p.data) {
+        labels.unshift(p.data.label)
+        p = p.parent
+      }
+      this.selectedDimPath = labels.join(' / ')
+    },
+    // 清除维度归属(=通用,不关联具体维度)
+    clearDimension() {
+      this.form.parentId = null
+      this.form.dimensionCode = ''
+      this.currentTreeNodeId = null
+      this.selectedDimPath = ''
+      if (this.$refs.dimTree) {
+        this.$refs.dimTree.setCurrentKey(null)
+      }
     },
     },
     sourceKindLabel(kind) {
     sourceKindLabel(kind) {
       const map = { report_indicator: '报告指标', bacteria: '细菌/菌群', food_index: '食物指数', dimension_score: '五维得分' }
       const map = { report_indicator: '报告指标', bacteria: '细菌/菌群', food_index: '食物指数', dimension_score: '五维得分' }
@@ -248,12 +331,22 @@ export default {
           options: '',
           options: '',
           unit: '',
           unit: '',
           dimensionCode: '',
           dimensionCode: '',
+          parentId: null,
           sourceKind: '',
           sourceKind: '',
           sortOrder: 0,
           sortOrder: 0,
           status: 1
           status: 1
         }
         }
       }
       }
       this.dialogVisible = true
       this.dialogVisible = true
+      // 编辑态:回显已挂接的维度节点(高亮 + 显示路径)
+      var parentId = this.form.parentId
+      this.currentTreeNodeId = parentId || null
+      this.selectedDimPath = (this._dimPathMap[parentId] || []).join(' / ')
+      this.$nextTick(() => {
+        if (this.$refs.dimTree && this.form.parentId) {
+          this.$refs.dimTree.setCurrentKey(this.form.parentId)
+        }
+      })
     },
     },
     handleSave() {
     handleSave() {
       this.saving = true
       this.saving = true
@@ -261,6 +354,8 @@ export default {
       // 空串归一化为 null,避免数据库存 ''(语义"通用"应存 NULL)
       // 空串归一化为 null,避免数据库存 ''(语义"通用"应存 NULL)
       if (payload.dimensionCode === '') payload.dimensionCode = null
       if (payload.dimensionCode === '') payload.dimensionCode = null
       if (payload.sourceKind === '') payload.sourceKind = null
       if (payload.sourceKind === '') payload.sourceKind = null
+      // parentId 无挂接时置 null(通用/不关联具体维度节点)
+      if (payload.parentId === '' || payload.parentId === undefined) payload.parentId = null
       var req = this.form.id ? updateIndicator(payload) : createIndicator(payload)
       var req = this.form.id ? updateIndicator(payload) : createIndicator(payload)
       req.then(res => {
       req.then(res => {
         if (res.code === 200) {
         if (res.code === 200) {
@@ -295,4 +390,16 @@ export default {
 /* == C 批内联清理工具类 == */
 /* == C 批内联清理工具类 == */
 .filter-input-200ml12 { width: 200px; margin-left: 12px; }
 .filter-input-200ml12 { width: 200px; margin-left: 12px; }
 .form-full { width: 100%; }
 .form-full { width: 100%; }
+
+/* P1-T3:维度归属树形选择 */
+.dim-tree-box { width: 100%; }
+.dim-tree-box .el-tree {
+  max-height: 220px;
+  overflow-y: auto;
+  border: 1px solid #ebeef5;
+  border-radius: 4px;
+  padding: 4px;
+}
+.dim-tree-selected { margin-top: 6px; font-size: 12px; color: #606266; }
+.dim-tree-selected .el-button { margin-left: 8px; padding: 0; }
 </style>
 </style>

+ 32 - 4
cfc-web/src/views/admin/knowledge/index.vue

@@ -125,7 +125,7 @@
 import SortMixin from '@/mixins/SortMixin'
 import SortMixin from '@/mixins/SortMixin'
 import ListStateMixin from '@/mixins/ListStateMixin'
 import ListStateMixin from '@/mixins/ListStateMixin'
 import { mapState, mapActions } from 'vuex'
 import { mapState, mapActions } from 'vuex'
-import { getDimensionTree } from '@/api/dimension'
+import { getDimensionRegistryTree } from '@/api/dimension'
 import { getKnowledge, deleteKnowledge, bindKnowledgeDimensions, bindKnowledgeTags } from '@/api/dimension'
 import { getKnowledge, deleteKnowledge, bindKnowledgeDimensions, bindKnowledgeTags } from '@/api/dimension'
 import KnowledgeTable from '@/components/KnowledgeTable'
 import KnowledgeTable from '@/components/KnowledgeTable'
 import KnowledgeDialog from '@/components/KnowledgeDialog'
 import KnowledgeDialog from '@/components/KnowledgeDialog'
@@ -176,10 +176,38 @@ export default {
   methods: {
   methods: {
     ...mapActions('dimension', ['fetchKnowledgeList']),
     ...mapActions('dimension', ['fetchKnowledgeList']),
     async fetchDimensionTree() {
     async fetchDimensionTree() {
-      const res = await getDimensionTree()
-      if (res.code === 200) {
-        this.dimensionTree = res.data || []
+      try {
+        // 统一维度体系(P1-T4):改走注册中心只读接口 /api/dimension-registry/tree,
+        // 不再直读 /api/admin/dimension-config/tree(旧写接口仅保留给维度配置管理页)。
+        const res = await getDimensionRegistryTree()
+        if (res.code === 200) {
+          this.dimensionTree = this.mapDimTree(res.data || [])
+        }
+      } catch (e) {
+        console.error('[knowledge] 加载维度树失败', e)
+      }
+    },
+    // 注册中心 DimensionNode({id, code, name, parentId, nodeKind, children}) →
+    // el-tree 所需 {id, dimensionCode, dimensionName, children};指标节点不参与知识维度绑定。
+    mapDimTree(nodes) {
+      var walk = function(list) {
+        var out = []
+        ;(list || []).forEach(function(n) {
+          if (!n || n.nodeKind === 'indicator') return
+          var child = {
+            id: n.id,
+            dimensionCode: n.code,
+            dimensionName: n.name,
+            children: []
+          }
+          if (n.children && n.children.length) {
+            child.children = walk(n.children)
+          }
+          out.push(child)
+        })
+        return out
       }
       }
+      return walk(nodes)
     },
     },
     fetchList() {
     fetchList() {
       const params = { page: this.page, size: this.size }
       const params = { page: this.page, size: this.size }

+ 128 - 0
docs/superpowers/specs/2026-11-05-dimension-scoring-convention.md

@@ -0,0 +1,128 @@
+# 维度评分口径约定(P2-T2)
+
+> 状态:评审稿 · 归属:统一维度体系(2026-11-03-unified-dimension-system-design.md §9 P2-T2)
+> 目的:**文档化**雷达 / 沙盘能量 / 指标归一化 三处共享的 percentile / tier / 归一化口径,消除歧义。
+> 原则:本文件**不改变任何运行时行为**(纯文档 + 可选共享常量,常量未接入业务逻辑)。所有换算逻辑以代码实际实现为准,逐条核实并注明出处。
+
+---
+
+## 1. 共享口径(三处统一)
+
+**量纲**:所有维度得分统一落在 **0–100** 闭区间(`0`=最弱/最差,`100`=最强/最优)。
+
+**统一离散化(percentile 带 / tier)**:以人群百分位断点 **P5 / P25 / P75 / P95** 划分五档(tier)。该断点是三处共享的**唯一** tier 分段依据:
+
+| 数值区间(相对常模/人群) | band(档) | status(语义) |
+|---|---|---|
+| `v < P5` | `veryLow` | 偏低(异常) |
+| `P5 ≤ v < P25` | `low` | 偏低(异常) |
+| `P25 ≤ v ≤ P75` | `mid` | 正常 |
+| `P75 < v ≤ P95` | `high` | 偏高(异常) |
+| `v > P95` | `veryHigh` | 偏高(异常) |
+
+**归一化公式**:指标原始值 → 百分位,采用常模断点 **线性插值**:
+
+```
+percentile(v) =
+    P≤5            , v ≤ P5
+    P≥95           , v ≥ P95
+    P[ p_i + (v - p_i)/(p_{i+1} - p_i) × (p_{i+1} - p_i) ] , p_i ≤ v ≤ p_{i+1}
+```
+
+其中断点序列 `(p_0..p_4) = (P5, P25, P50, P75, P95)`,对应百分位 `(5, 25, 50, 75, 95)`。
+> 注:上式第二行在 `p_{i+1} - p_i > 0` 时即 `cents[i] + (v - p_i)/span × (cents[i+1] - cents[i])`;`span ≤ 0` 时退化为该断点对应百分位。
+
+**常量来源**:`com.etotem.cfc.common.constant.DimensionCodeConstants`(P2-T2 新增)
+`SCORE_MIN=0`、`SCORE_MAX=100`、`BAND_P5=5`、`BAND_P25=25`、`BAND_P75=75`、`BAND_P95=95`。
+
+---
+
+## 2. 子系统 A —— 雷达(RadarChart)
+
+**代码**:`cfc-frontend/components/RadarChart.vue`(uni-app / Vue2);后端取数 `DimensionScoreServiceImpl.calcPopulationScore`。
+
+**实际换算**(从代码核实,非推测):
+- 分值输入 `scores[]`(0–100,与 `dimensions[]` 按索引对齐)。
+- 无效值归一:`_safeScore(v)` —— 非 number / `NaN` / `null` 一律按 `0`(防 Canvas `NaN` 静默失败)。
+- 顶点半径:`r = outerR × clamp(score/100, 0, 1)`(即 0–100 线性映射到 0–100% 半径)。
+- 分值文本:`Math.round(score) + '%'`。
+- 网格层级:`[0.25, 0.5, 0.75, 1.0]`(对应 25/50/75/100 分参考线)。
+- 参考系列(同一 0–100 量纲):
+  - **人群位置** `populationScores[]` = 常模 **P50**(`DimensionScoreServiceImpl.calcPopulationScore`:`cognitive_norms.percentile_50` / `health_norm_reference.percentile_50`;无成员/无常模返回 `null`,画布按 0 处理)。
+  - **家庭平均** `avgScores[]` / `familyScores[]` = 家庭平均分。
+- **tier 文本**:RadarChart **当前不输出** tier 文本;其 0–100 分与 §1 档位按同一断点可互相映射(展示层如需标签,应复用 P5/P25/P75/P95 断点,勿另设阈值)。
+
+**结论**:雷达把 0–100 分直接画为半径占比;tier 语义通过数值隐含(数值 = 百分位等价量纲)。
+
+---
+
+## 3. 子系统 B —— 沙盘能量(Energy)
+
+**代码**:`cfc-backend/src/main/java/com/etotem/cfc/service/EnergyService.java`。
+
+**实际换算**(从代码核实,非推测):
+- 语义:计算每个家庭成员(主账号+成员)的 **身 / 心 / 智 / 行 / 富** 五维能量值,**0–100%**(类注释 line 27)。
+- 各子维公式 → `clamp(..., 0, 100)` 收口。代表公式:
+  - 富·利他贡献:`clamp(20 + (直接邀请人数-1) × 15, 0, 100)`(1 人=20 分,每多 1 人 +15,封顶 100)。
+  - 富·财务积累:`clamp(cf_balance.total_earned / 50, 0, 100)`(5000 CF = 100 分)。
+  - 行动·习惯/打卡:`clamp(连续打卡天数 × 100 / 参考天数, 0, 100)` 及加权合成。
+- **五行调节(在 0–100 基础上乘系数,仍 clamp [0,100])**:
+  - 增益:`boost = (上游-50)/100 × 10`(仅当 `上游>60 && 下游<80` 触发)。
+  - 衰减/透支/克制:`penalty = 0.5 + 0.5 × (自身/100)`(身<40 → 财富 × penalty;行>75 且身<40 → 身 × penalty 等)。
+- 最终能量比 = 该维度能量值(0–100%)。
+
+**结论**:沙盘以 0–100 量化五维能量;tier 通过数值隐含(同 §1 断点)。五行调节是 0–100 量纲上的乘性修正,不改变量纲。
+
+---
+
+## 4. 子系统 C —— 指标归一化(Indicator)
+
+**代码**:
+- 单一写者:`NormalizeIndicatorPipeline.processReport` / `snapshotFoodIndices`(原始值 + `numeric_value` + `ref_range_snapshot` 幂等写入 `indicator_values`)。
+- 常模比对:`attribution/NormCompareEngine.applyBandFromNorm` / `applyBandFromPopulation` / `roughPercentile`。
+- 模型:`attribution/model/IndicatorReading`(band / status / percentile 常量)。
+
+**实际换算**(从代码核实,非推测):
+- 常模命中(`p5/p95` 完整断点存在):
+  - `v < p5` → `veryLow` / 偏低
+  - `v < p25` → `low` / 偏低
+  - `v ≤ p75` → `mid` / 正常
+  - `v ≤ p95` → `high` / 偏高
+  - `v > p95` → `veryHigh` / 偏高
+  - percentile 文本:`roughPercentile` 线性插值(`P≤5` / `P≥95` / 插值 `P<n>`)。
+- 菌群 `populationLevel`(如 `"35%"`)优先作为人群百分位:`≤5 / ≤25 / ≤75 / ≤95` 同上分带,`percentile = "P" + round(pop)`。
+- **常模缺失 → `status="待补"`,`band=unknown`,不猜值**(§8 共享知识·常模口径)。
+
+**结论**:指标显式使用 §1 的 P5/P25/P75/P95 分带与线性插值归一化,是最直接落地 tier 的子系统。
+
+---
+
+## 5. 三处一致性校验
+
+| 维度 | 量纲 | 是否显式 tier | tier 断点 | 归一化方式 |
+|---|---|---|---|---|
+| 雷达 | 0–100 | 否(数值隐含) | P5/P25/P75/P95 | `clamp(score/100,0,1)` → 半径;参考=P50 |
+| 沙盘能量 | 0–100% | 否(数值隐含) | P5/P25/P75/P95 | 子维公式 clamp(0,100) + 五行乘性修正 |
+| 指标 | 0–100 百分位 | 是(band/status) | P5/P25/P75/P95 | 常模断点线性插值 → 百分位带 |
+
+**共享同一口径**:三者同用 0–100 量纲,且 tier 均以 P5/P25/P75/P95 断点为准(指标显式实现,雷达/沙盘数值隐含、可按同断点映射)。**消除歧义点**:任何子系统不得另设 tier 阈值(如 优秀/良好 自定 80/60 分界),如需标签须复用 §1 断点。
+
+---
+
+## 6. 不变式(红线)
+
+- 本文件**不改变**任何取分 / 归一化 / 五行计算逻辑;仅文档化 + 新增未接入的共享常量。
+- 常模缺失(指标)→ `待补`,不猜值;缺失成员/常模(雷达人群位置)→ `null`(画布按 0)。
+- 所有分数 clamp 到 `[0, 100]`;`NaN`/`null` 归一为 0(RadarChart `_safeScore`)。
+
+## 7. 代码出处速查
+
+| 事实 | 出处 |
+|---|---|
+| 0–100 → 半径、null→0、`%` 文本、网格层级 | `cfc-frontend/components/RadarChart.vue`(`_safeScore`、`_drawScoreArea`、`_drawScoreText`、`_drawGrid`) |
+| 人群位置 = 常模 P50 | `cfc-backend/.../service/impl/DimensionScoreServiceImpl.java#calcPopulationScore` |
+| 五维能量 0–100%、clamp、五行 boost/penalty | `cfc-backend/.../service/EnergyService.java` |
+| 指标常模分带 + 线性插值 + populationLevel 回退 + 待补 | `cfc-backend/.../service/attribution/NormCompareEngine.java`(`applyBandFromNorm`/`applyBandFromPopulation`/`roughPercentile`) |
+| band/status 常量(veryLow/low/mid/high/veryHigh、待补/偏低/正常/偏高) | `cfc-backend/.../service/attribution/model/IndicatorReading.java` |
+| 指标单一写者(原始值+参考范围快照) | `cfc-backend/.../service/NormalizeIndicatorPipeline.java` |
+| 共享常量(SCORE_MIN/MAX、BAND_P5/25/75/95) | `cfc-backend/.../common/constant/DimensionCodeConstants.java`(P2-T2) |