Kaynağa Gözat

feat(dimension): P1-T3 指标管理关联收敛+树挂接

- IndicatorService: create/update 前校验 dimensionCode ∈ 注册中心维度节点code集合
  (root/sub,不含指标节点),越界抛 IllegalArgumentException→GlobalExceptionHandler 转 400;
  @Lazy 注入 DimensionRegistryService 打破循环依赖;parentId 经实体自动落库
  (父节点ID,混排进维度树)。
- DimensionRegistryService: 新增 collectDimensionCodes()/isDimensionCode()。
- DatabaseInitializer 迁移449 + schema.sql: indicator_definitions.dimension_code 扩宽至
  VARCHAR(64)(容纳注册中心子维 code 如 mind.selfConceptOverall=23字符)。
- cfc-web 指标管理页: 维度归属由五维单选改为从统一维度注册中心树选择(el-tree,
  数据源 GET /api/dimension-registry/tree),保存传 parentId+dimenionCode,指标节点禁选。
- cfc-frontend useDimensionRegistry.getRadarConfig 防御:跳过 nodeKind=indicator 子节点,
  避免指标挂到根节点后混入健康页雷达轴(P1-T3 连带硬化)。
Agnes Agent 1 hafta önce
ebeveyn
işleme
4a4dca3196

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

@@ -3445,6 +3445,10 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         seedUnifiedDimensionRegistry();
         seedUnifiedDimensionRegistry();
         // 迁移448: 统一维度体系(增量)—— 另外 4 根 33 子维 + 5 根五行归属(幂等 seed,仅追加,不改动 445-447)
         // 迁移448: 统一维度体系(增量)—— 另外 4 根 33 子维 + 5 根五行归属(幂等 seed,仅追加,不改动 445-447)
         seedUnifiedDimensionRegistryExpansion();
         seedUnifiedDimensionRegistryExpansion();
+        // 迁移449: 统一维度体系(P1-T3)—— indicator_definitions.dimension_code 扩宽至 VARCHAR(64),
+        // 以容纳注册中心子维度 code(如 mind.selfConceptOverall=23 字符 > 原 VARCHAR(16)),
+        // 指标可归属/挂接到任意维度节点(红线1:不动 id 主键与 5 张外键表)
+        migrateIndicatorDimensionCodeWidth();
     }
     }
 
 
     /**
     /**
@@ -15248,6 +15252,23 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         log.info("迁移448: 统一维度注册中心增量 seed 完成(mind10/wisdom7/action5/wealth4 共33子维)");
         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,

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

@@ -174,6 +174,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);
     }
     }

+ 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 '排序',

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

@@ -142,6 +142,10 @@ function getRadarConfig(tabCode) {
   for (var j = 0; j < root.children.length; j++) {
   for (var j = 0; j < root.children.length; j++) {
     var child = root.children[j]
     var child = root.children[j]
     if (!child) continue
     if (!child) continue
+    // P1-T3 防御:指标节点(nodeKind=indicator)是维度树的混排子节点,
+    // 不应作为健康页雷达图的轴(雷达轴应为 sub-dim 维度节点)。
+    // 操作者将指标挂到某维度节点下后,该节点 children 会含指标,过滤掉保持雷达 5×7/7/10/5/4 不变。
+    if (child.nodeKind === 'indicator') continue
     config.push({
     config.push({
       label: child.name || '',
       label: child.name || '',
       code: child.code || '',
       code: child.code || '',

+ 111 - 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,85 @@ 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 || '',
+          // P1-T3 仅允许挂接到维度节点(root/sub);指标节点禁选(P2 再放开子树挂接)
+          disabled: n.nodeKind === 'indicator',
+          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
+      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 +322,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 +345,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 +381,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>