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

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 неделя назад
Родитель
Сommit
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();
         // 迁移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();
     }
 
     /**
@@ -15248,6 +15252,23 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         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,

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

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

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

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

@@ -142,6 +142,10 @@ function getRadarConfig(tabCode) {
   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 || '',

+ 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>
         </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,85 @@ 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 || '',
+          // 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) {
       const map = { report_indicator: '报告指标', bacteria: '细菌/菌群', food_index: '食物指数', dimension_score: '五维得分' }
@@ -248,12 +322,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 +345,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 +381,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>