瀏覽代碼

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

Agnes Agent 1 周之前
父節點
當前提交
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;
 
 public class DimensionCodeConstants {
+    // 统一维度体系常量:5 根(body/mind/wisdom/action/wealth)+ 子维构成 body7/wisdom7/mind10/action5/wealth4(共33子维)
     // 5 根维度
     public static final String BODY = "body";
     public static final String MIND = "mind";
@@ -32,4 +33,89 @@ public class DimensionCodeConstants {
     public static final String WUXING_METAL = "金";
     public static final String WUXING_WATER = "水";
     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();
         // 迁移447: 统一维度体系(P0-T3)—— 固化 5 根 + 7 body 子维度节点(幂等 seed)
         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 完成");
     }
 
+    /**
+     * 迁移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,不存在则插入。 */
     private void upsertDimensionSeed(String code, String name, String dimensionType, Long parentId,
                                      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
     private FamilyMemberMapper familyMemberMapper;
 
+    /**
+     * 获取健康维度概览。
+     *
+     * @deprecated 统一维度体系下前端改走 /api/dimension-registry/*(注册中心只读接口),
+     *             本接口保留至零调用确认后下线(设计文档 §5.3),不删除。
+     */
+    @Deprecated
     @Operation(summary = "获取健康维度概览")
     @PostMapping("/overview")
     public Result<HealthDimensionVO> overview(
@@ -42,6 +49,13 @@ public class HealthDimensionController {
         return Result.success(vo);
     }
 
+    /**
+     * 获取雷达图三层数据:实际值/人群位置/家庭占比。
+     *
+     * @deprecated 统一维度体系下前端改走 /api/dimension-registry/*(注册中心只读接口),
+     *             本接口保留至零调用确认后下线(设计文档 §5.3),不删除。
+     */
+    @Deprecated
     @Operation(summary = "获取雷达图三层数据:实际值/人群位置/家庭占比")
     @PostMapping("/radar-data")
     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,
- * legacyAlias, wuxingRuleRef, children?}。</p>
+ * legacyAlias, wuxingRuleRef, children?};P2-T1 起指标节点额外带 dimensionCode
+ * (指标归属维度 code,供指标挂指标时继承归属,null=通用)。</p>
  */
 public class DimensionNode {
 
@@ -18,6 +19,10 @@ public class DimensionNode {
     private String sourceSystem;
     private String legacyAlias;
     private String wuxingRuleRef;
+
+    /** 指标节点的归属维度 code(仅 nodeKind=indicator 有值;维度节点为 null)。P2-T1。 */
+    private String dimensionCode;
+
     private List<DimensionNode> children;
 
     public DimensionNode() {
@@ -39,6 +44,8 @@ public class DimensionNode {
     public void setLegacyAlias(String legacyAlias) { this.legacyAlias = legacyAlias; }
     public String getWuxingRuleRef() { return 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 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.DanKnowledgeBaseTag;
 import com.etotem.cfc.entity.DanKnowledgeTag;
-import com.etotem.cfc.entity.ProductDimensionConfig;
 import com.etotem.cfc.mapper.DanKnowledgeBaseDimensionMapper;
 import com.etotem.cfc.mapper.DanKnowledgeBaseMapper;
 import com.etotem.cfc.mapper.DanKnowledgeBaseTagMapper;
 import com.etotem.cfc.mapper.DanKnowledgeTagMapper;
-import com.etotem.cfc.mapper.ProductDimensionConfigMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
@@ -46,7 +44,7 @@ public class DanKnowledgeBaseService extends ServiceImpl<DanKnowledgeBaseMapper,
     private DanKnowledgeTagMapper danKnowledgeTagMapper;
 
     @Resource
-    private ProductDimensionConfigMapper productDimensionConfigMapper;
+    private DimensionRegistryService dimensionRegistryService;
 
     @Resource
     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) {
             List<Long> tids = tagIdsByKnowledge.getOrDefault(record.getId(), Collections.emptyList());
@@ -322,15 +310,7 @@ public class DanKnowledgeBaseService extends ServiceImpl<DanKnowledgeBaseMapper,
         }
         List<Long> dimIds = knowledge.getDimensionIds();
         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)
                 .filter(Objects::nonNull).collect(Collectors.toList()));
         } else {
@@ -390,21 +370,15 @@ public class DanKnowledgeBaseService extends ServiceImpl<DanKnowledgeBaseMapper,
                 .eq(DanKnowledgeBaseDimension::getKnowledgeId, knowledgeId));
         if (dims == null || dims.isEmpty()) return new java.util.ArrayList<>();
         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) {
-            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) {
-        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 java.util.ArrayList;
+import java.util.Collection;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -80,6 +81,9 @@ public class DimensionRegistryService {
             n.setNodeKind("indicator");
             n.setSourceSystem("indicator");
             n.setLegacyAlias(ind.getLegacyAlias());
+            // P2-T1:指标节点暴露归属维度 code(指标挂指标时子指标继承该归属,
+            // 保证子指标 dimensionCode 仍 ∈ 注册中心维度节点 code 集合)
+            n.setDimensionCode(ind.getDimensionCode());
             idToNode.putIfAbsent(n.getId(), n);
         }
 
@@ -146,6 +150,67 @@ public class DimensionRegistryService {
         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 唯一。 */
     public ProductDimensionConfig saveConfig(ProductDimensionConfig node) {
         if (node == null) {
@@ -174,6 +239,51 @@ public class DimensionRegistryService {
         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() {
         try {
             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.mapper.IndicatorDefinitionMapper;
 import com.etotem.cfc.mapper.IndicatorValueMapper;
+import org.springframework.context.annotation.Lazy;
 import org.springframework.stereotype.Service;
 import javax.annotation.Resource;
 import java.util.List;
@@ -19,6 +20,18 @@ public class IndicatorService {
     @Resource
     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() {
         return indicatorDefinitionMapper.selectList(null);
     }
@@ -42,15 +55,38 @@ public class IndicatorService {
     }
 
     public IndicatorDefinition createDefinition(IndicatorDefinition def) {
+        validateDimensionCode(def);
         indicatorDefinitionMapper.insert(def);
         return def;
     }
 
     public IndicatorDefinition updateDefinition(IndicatorDefinition def) {
+        validateDimensionCode(def);
         indicatorDefinitionMapper.updateById(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) {
         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;
 
 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.LoggerFactory;
 import org.springframework.beans.factory.annotation.Value;
@@ -38,7 +35,7 @@ public class LanggraphSyncService {
     private String difyApiKey;
 
     @Resource
-    private ProductDimensionConfigMapper productDimensionConfigMapper;
+    private DimensionRegistryService dimensionRegistryService;
 
     private final RestTemplate restTemplate = new RestTemplate();
 
@@ -47,13 +44,12 @@ public class LanggraphSyncService {
         if (dimensionCodes == null || dimensionCodes.isEmpty()) return;
         for (String code : dimensionCodes) {
             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);
                     continue;
                 }
-                String url = difyBaseUrl + "/v1/datasets/" + config.getDifyDatasetId() + "/documents";
+                String url = difyBaseUrl + "/v1/datasets/" + datasetId + "/documents";
                 HttpHeaders headers = new HttpHeaders();
                 headers.setContentType(MediaType.APPLICATION_JSON);
                 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,空=通用)',
     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',
-    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, 混排进维度树)',
     legacy_alias VARCHAR(512) COMMENT '历史别名(JSON数组)',
     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 ReportUploadCard from '../../components/report-upload-card.vue'
 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 shareMixin from '../../components/share-mixin.js'
 
@@ -324,7 +325,10 @@ export default {
       childrenList: [],
       relationshipEmpty: false,
       // 登录浮层打开标志(打开期间隐藏演示雷达,避免原生 canvas 盖在浮层之上)
-      loginSheetVisible: false
+      loginSheetVisible: false,
+      // 统一维度注册中心是否就绪(成功取到 tree 后由 ensureDimensionRegistry 置位)
+      // 未就绪时主页雷达回落硬编码兜底,保证后端未上线 / 接口异常时页面不崩
+      registryReady: false
     }
   },
   computed: {
@@ -337,23 +341,23 @@ export default {
       return tab ? tab.color : BrandColors.body
     },
     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')
-        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() {
       if (this.chartIsEmpty) {
@@ -361,55 +365,16 @@ export default {
         for (var i = 0; i < dims.length; i++) scores.push(0)
         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() {
       if (this.chartIsEmpty) return []
@@ -470,6 +435,13 @@ export default {
       this.loadHealthData()
       this.loadPlanList()
       this.loadPendingSurveys()
+      // 触发统一维度注册中心树拉取(主页雷达配置驱动取数;失败则回落硬编码兜底)
+      var self = this
+      ensureDimensionRegistry().then(function() {
+        self.registryReady = isRegistryReady()
+      }).catch(function() {
+        self.registryReady = false
+      })
     } else {
       this.pendingSurveys = []
     }
@@ -499,6 +471,144 @@ export default {
       var v = this.energySandbox[key]
       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 },
     goPage: function(url) { uni.navigateTo({ url: url }) },
     // 登录浮层打开/关闭(隐藏演示雷达,规避原生 canvas 层级高于浮层的问题)

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

@@ -66,7 +66,7 @@
 </template>
 
 <script>
-import { getDimensionTree } from '@/api/dimension'
+import { getDimensionRegistryTree } from '@/api/dimension'
 import { getContentDimensionThresholdList, batchSaveContentDimensionThreshold } from '@/api/content-dimension-threshold'
 
 export default {
@@ -117,7 +117,9 @@ export default {
   methods: {
     async loadDimensionTree() {
       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)) {
           this.flatOptions = this.flattenTree(res.data)
         }
@@ -129,9 +131,15 @@ export default {
       var result = []
       var walk = function(list, groupName) {
         ;(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) {
             walk(n.children, g)

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

@@ -84,6 +84,9 @@ function flattenRegistryTree(nodes, result) {
   result = result || []
   if (!nodes || !nodes.length) return result
   nodes.forEach(n => {
+    // P1-T4:指标节点(nodeKind=indicator)不进入维度选择器——旧数据源
+    // (product_dimension_config) 仅含维度节点,混入指标会改变选择项语义。
+    if (n && n.nodeKind === 'indicator') return
     result.push({
       id: n.id,
       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>
         </template>
       </el-table-column>
-      <el-table-column prop="dimensionCode" label="五维归属" width="100">
+      <el-table-column prop="dimensionCode" label="维度归属" width="110">
         <template slot-scope="{ row }">
           <span v-if="row.dimensionCode">{{ dimensionLabel(row.dimensionCode) }}</span>
           <span v-else class="text-muted">通用</span>
         </template>
         <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>
       </el-table-column>
       <el-table-column prop="sourceKind" label="数据来源" width="120">
@@ -114,14 +114,25 @@
         <el-form-item label="名称" required>
           <el-input v-model="form.name" />
         </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 label="数据来源">
           <el-select v-model="form.sourceKind" class="form-full" clearable placeholder="不选=通用">
@@ -162,6 +173,7 @@
 <script>
 import SortMixin from '@/mixins/SortMixin'
 import { listIndicatorDefinitions, createIndicator, updateIndicator, deleteIndicator } from '@/api/indicator'
+import { getDimensionRegistryTree } from '@/api/dimension'
 
 export default {
   name: 'IndicatorsIndex',
@@ -190,23 +202,94 @@ export default {
         options: '',
         unit: '',
         dimensionCode: '',
+        parentId: null,
         sourceKind: '',
         sortOrder: 0,
         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() {
     this.loadData()
+    // P1-T3:加载统一维度注册中心树,供维度归属树形选择
+    this.loadDimensionTree()
   },
   methods: {
     loadList() {
       this.loadData()
     },
     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) {
       const map = { report_indicator: '报告指标', bacteria: '细菌/菌群', food_index: '食物指数', dimension_score: '五维得分' }
@@ -248,12 +331,22 @@ export default {
           options: '',
           unit: '',
           dimensionCode: '',
+          parentId: null,
           sourceKind: '',
           sortOrder: 0,
           status: 1
         }
       }
       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() {
       this.saving = true
@@ -261,6 +354,8 @@ export default {
       // 空串归一化为 null,避免数据库存 ''(语义"通用"应存 NULL)
       if (payload.dimensionCode === '') payload.dimensionCode = 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)
       req.then(res => {
         if (res.code === 200) {
@@ -295,4 +390,16 @@ export default {
 /* == C 批内联清理工具类 == */
 .filter-input-200ml12 { width: 200px; margin-left: 12px; }
 .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>

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

@@ -125,7 +125,7 @@
 import SortMixin from '@/mixins/SortMixin'
 import ListStateMixin from '@/mixins/ListStateMixin'
 import { mapState, mapActions } from 'vuex'
-import { getDimensionTree } from '@/api/dimension'
+import { getDimensionRegistryTree } from '@/api/dimension'
 import { getKnowledge, deleteKnowledge, bindKnowledgeDimensions, bindKnowledgeTags } from '@/api/dimension'
 import KnowledgeTable from '@/components/KnowledgeTable'
 import KnowledgeDialog from '@/components/KnowledgeDialog'
@@ -176,10 +176,38 @@ export default {
   methods: {
     ...mapActions('dimension', ['fetchKnowledgeList']),
     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() {
       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) |