瀏覽代碼

feat(family): add family metric aggregation config + pearl/octopus family collect APIs

- family_metric_agg_config 表(迁移433):平台级全局一份,按数据项一行一条
- 预置 8 行:五维各维度 AVG + 综合 AVG + pearl.resources/octopus.effects COLLECT
- 配置 CRUD:/api/admin/family-metric-agg/{list,save,delete,update-status}(admin 角色)
- EnergyService.doCalculateFamilyEnergy 五维聚合改读配置(AVG/SUM/COLLECT,默认 AVG 向后兼容)
- 家庭汇集:/api/pearl/family/resources + /api/octopus/effect/family/list(members 数组 + colorKey)
- schema.sql 同步 + API_REFERENCE 4.57 + PROJECT-OVERVIEW 更新
iwt 1 周之前
父節點
當前提交
15940d3df3

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

@@ -3389,10 +3389,14 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         migrateSalesAwardTierThresholds();
         // 迁移429(家庭): 存量家庭名回填(家庭主账号已填真实姓名但家庭名仍为旧规则 → 按新规则回填「XX家庭」;DML,无 schema 变更,幂等)
         migrateFamilyNameBackfill();
+        // 迁移429(家庭): 存量家庭名回填(家庭主账号已填真实姓名但家庭名仍为旧规则 → 按新规则回填「XX家庭」;DML,无 schema 变更,幂等)
+        migrateFamilyNameBackfill();
 
         // 迁移432: report_type_registry 添加 linked_product_id 列(报告类型→商城商品链接配置,规格 2026-10-30-report-type-product-link-design)
         ensureColumn("report_type_registry", "linked_product_id",
                 "BIGINT DEFAULT NULL COMMENT '关联商城商品 products.id,NULL=不可链接'");
+        // 迁移433: 家庭指标聚合方式配置表(五维能量汇总/珍珠图/章鱼图汇集配置)
+        createFamilyMetricAggConfigTable();
     }
 
     /**
@@ -14552,7 +14556,66 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
     }
 
+    // ==================== 迁移433:家庭指标聚合方式配置(五维能量汇总/珍珠图/章鱼图汇集配置) ====================
 
+    /**
+     * 迁移433: 家庭指标聚合方式配置表 family_metric_agg_config。
+     * 平台级全局一份,按数据项一行一条。
+     * 预置 8 行数据:五维各维度 AVG + 综合 AVG + pearl.resources COLLECT + octopus.effects COLLECT
+     */
+    void createFamilyMetricAggConfigTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `family_metric_agg_config` ("
+                    + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, "
+                    + "`metric_key` VARCHAR(64) NOT NULL COMMENT '数据项唯一标识,如 energy.body、pearl.resources', "
+                    + "`metric_name` VARCHAR(64) NOT NULL COMMENT '中文名,如「身体维度能量分」', "
+                    + "`dimension_code` VARCHAR(32) COMMENT '所属维度:body/mind/wisdom/action/wealth/common', "
+                    + "`agg_type` VARCHAR(16) NOT NULL COMMENT '汇总方式:SUM=合计 / AVG=平均 / COLLECT=汇集', "
+                    + "`value_type` VARCHAR(16) NOT NULL COMMENT '值类型:NUMBER(数值)/ COLLECTION(集合)', "
+                    + "`display_mode` VARCHAR(32) COMMENT '展示形态:number/bar/radar/merge_graph', "
+                    + "`sort_order` INT DEFAULT 0 COMMENT '排序', "
+                    + "`status` VARCHAR(16) DEFAULT 'active' COMMENT '状态:active/disabled', "
+                    + "`remark` VARCHAR(255) COMMENT '备注', "
+                    + "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, "
+                    + "`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
+                    + "UNIQUE KEY `uk_metric_key` (`metric_key`) "
+                    + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭指标聚合方式配置'");
+            log.info("迁移433: 已创建 family_metric_agg_config 表");
+        } catch (Exception e) {
+            log.warn("迁移433: 创建 family_metric_agg_config 表失败(可能已存在): {}", e.getMessage());
+        }
+        // 预置数据(幂等 upsert)
+        upsertFamilyMetricAggConfig("energy.body", "身体维度能量分", "body", "AVG", "NUMBER", "number", 1, "active", "五维身体维度家庭汇总,默认平均值");
+        upsertFamilyMetricAggConfig("energy.mind", "心理维度能量分", "mind", "AVG", "NUMBER", "number", 2, "active", "五维心理维度家庭汇总,默认平均值");
+        upsertFamilyMetricAggConfig("energy.wisdom", "认知维度能量分", "wisdom", "AVG", "NUMBER", "number", 3, "active", "五维认知维度家庭汇总,默认平均值");
+        upsertFamilyMetricAggConfig("energy.action", "社交维度能量分", "action", "AVG", "NUMBER", "number", 4, "active", "五维社交维度家庭汇总,默认平均值");
+        upsertFamilyMetricAggConfig("energy.wealth", "成就维度能量分", "wealth", "AVG", "NUMBER", "number", 5, "active", "五维成就维度家庭汇总,默认平均值");
+        upsertFamilyMetricAggConfig("energy.overall", "综合能量分", "common", "AVG", "NUMBER", "number", 6, "active", "五维综合能量分家庭汇总,默认平均值");
+        upsertFamilyMetricAggConfig("pearl.resources", "珍珠图资源(我有什么)", "action", "COLLECT", "COLLECTION", "merge_graph", 7, "active", "珍珠图家庭汇集,合并成一张图按成员着色");
+        upsertFamilyMetricAggConfig("octopus.effects", "章鱼图关键人/成效", "action", "COLLECT", "COLLECTION", "merge_graph", 8, "active", "章鱼图家庭汇集,合并成一张图按成员着色");
+    }
+
+    private void upsertFamilyMetricAggConfig(String metricKey, String metricName, String dimensionCode,
+                                              String aggType, String valueType, String displayMode,
+                                              Integer sortOrder, String status, String remark) {
+        try {
+            // 先尝试更新
+            int updated = jdbcTemplate.update(
+                    "UPDATE `family_metric_agg_config` SET metric_name=?, dimension_code=?, agg_type=?, value_type=?, display_mode=?, sort_order=?, status=?, remark=?, updated_at=CURRENT_TIMESTAMP WHERE metric_key=?",
+                    metricName, dimensionCode, aggType, valueType, displayMode, sortOrder, status, remark, metricKey);
+            if (updated == 0) {
+                // 不存在则插入
+                jdbcTemplate.update(
+                        "INSERT INTO `family_metric_agg_config` (metric_key, metric_name, dimension_code, agg_type, value_type, display_mode, sort_order, status, remark) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
+                        metricKey, metricName, dimensionCode, aggType, valueType, displayMode, sortOrder, status, remark);
+                log.info("迁移433: 已插入预置配置 {}", metricKey);
+            } else {
+                log.info("迁移433: 已更新预置配置 {}", metricKey);
+            }
+        } catch (Exception e) {
+            log.warn("迁移433: 预置配置 {} 处理失败(忽略): {}", metricKey, e.getMessage());
+        }
+    }
 
     void createPpointConsumptionLogTable() {
         try {

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/KeyPersonController.java

@@ -34,6 +34,12 @@ public class KeyPersonController {
         return keyPersonService.listEffects(userId, null, page, pageSize);
     }
 
+    /** 章鱼图家庭汇集:每个家庭成员一份成效列表(合并成一张图,按成员着色/标注) */
+    @PostMapping("/effect/family/list")
+    public Result<Map<String, Object>> familyListEffects(@RequestAttribute("familyId") Long familyId) {
+        return keyPersonService.getFamilyOctopusEffects(familyId);
+    }
+
     @PostMapping("/effect/add")
     public Result<com.etotem.cfc.entity.OctopusEffectRecord> addEffect(@RequestBody Map<String, Object> params,
                                                                        @RequestAttribute("userId") Long userId) {

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/PearlController.java

@@ -22,6 +22,12 @@ public class PearlController {
         return resourceService.getPearlResources(userId);
     }
 
+    /** 珍珠图家庭汇集:每个家庭成员一份资源 items(合并成一张图,按成员着色/标注) */
+    @PostMapping("/family/resources")
+    public Result<Map<String, Object>> familyResources(@RequestAttribute("familyId") Long familyId) {
+        return resourceService.getFamilyPearlResources(familyId);
+    }
+
     /** 登记珍珠图资源(信息/场所/技能手工登记) */
     @PostMapping("/item/add")
     public Result<ResourceItem> addItem(@RequestBody Map<String, Object> params,

+ 190 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/FamilyMetricAggConfigController.java

@@ -0,0 +1,190 @@
+package com.etotem.cfc.controller.admin;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.FamilyMetricAggConfig;
+import com.etotem.cfc.mapper.FamilyMetricAggConfigMapper;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 管理端 - 家庭指标聚合方式配置(平台级全局一份)。
+ * <p>
+ * 配置作用域为平台级(不分家庭),供五维能量汇总 / 珍珠图 / 章鱼图汇集使用。
+ * agg_type:SUM=合计 / AVG=平均 / COLLECT=汇集。
+ */
+@Tag(name = "管理端 - 家庭指标聚合方式配置")
+@RestController
+@RequestMapping("/api/admin/family-metric-agg")
+public class FamilyMetricAggConfigController {
+
+    @Resource
+    private FamilyMetricAggConfigMapper familyMetricAggConfigMapper;
+
+    /** 全量配置列表(配置项 < 50,无需分页) */
+    @Operation(summary = "家庭指标聚合方式配置列表")
+    @PostMapping("/list")
+    public Result<List<Map<String, Object>>> list(@RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        List<FamilyMetricAggConfig> configs = familyMetricAggConfigMapper.selectList(
+                new LambdaQueryWrapper<FamilyMetricAggConfig>()
+                        .orderByAsc(FamilyMetricAggConfig::getSortOrder)
+                        .orderByAsc(FamilyMetricAggConfig::getId));
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (FamilyMetricAggConfig c : configs) {
+            result.add(toMap(c));
+        }
+        return Result.success(result);
+    }
+
+    /** 新增/更新配置(body 带 id 则更新,否则新增) */
+    @Operation(summary = "保存家庭指标聚合方式配置")
+    @PostMapping("/save")
+    public Result<Boolean> save(@RequestBody Map<String, Object> params,
+                                @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        String metricKey = str(params.get("metricKey"));
+        String metricName = str(params.get("metricName"));
+        if (metricKey == null || metricKey.trim().isEmpty()) {
+            return Result.error("metricKey不能为空");
+        }
+        if (metricName == null || metricName.trim().isEmpty()) {
+            return Result.error("metricName不能为空");
+        }
+        String aggType = str(params.get("aggType"));
+        String valueType = str(params.get("valueType"));
+        if (aggType == null || aggType.trim().isEmpty()) {
+            return Result.error("aggType不能为空");
+        }
+        if (valueType == null || valueType.trim().isEmpty()) {
+            return Result.error("valueType不能为空");
+        }
+
+        Long id = longVal(params.get("id"));
+        FamilyMetricAggConfig config;
+        if (id != null) {
+            config = familyMetricAggConfigMapper.selectById(id);
+            if (config == null) {
+                return Result.error("配置不存在");
+            }
+        } else {
+            FamilyMetricAggConfig existing = familyMetricAggConfigMapper.selectOne(
+                    new LambdaQueryWrapper<FamilyMetricAggConfig>()
+                            .eq(FamilyMetricAggConfig::getMetricKey, metricKey.trim())
+                            .last("LIMIT 1"));
+            if (existing != null) {
+                return Result.error("metricKey已存在,请直接编辑对应记录");
+            }
+            config = new FamilyMetricAggConfig();
+            config.setCreatedAt(new Date());
+        }
+        config.setMetricKey(metricKey.trim());
+        config.setMetricName(metricName.trim());
+        config.setDimensionCode(str(params.get("dimensionCode")));
+        config.setAggType(aggType.trim().toUpperCase());
+        config.setValueType(valueType.trim().toUpperCase());
+        config.setDisplayMode(str(params.get("displayMode")));
+        config.setSortOrder(intVal(params.get("sortOrder"), 0));
+        config.setStatus(str(params.get("status")) != null ? str(params.get("status")).trim() : "active");
+        config.setRemark(str(params.get("remark")));
+        config.setUpdatedAt(new Date());
+
+        if (id != null) {
+            familyMetricAggConfigMapper.updateById(config);
+        } else {
+            familyMetricAggConfigMapper.insert(config);
+        }
+        return Result.success(Boolean.TRUE);
+    }
+
+    /** 删除配置 */
+    @Operation(summary = "删除家庭指标聚合方式配置")
+    @PostMapping("/delete")
+    public Result<Boolean> delete(@RequestBody Map<String, Object> params,
+                                  @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        Long id = longVal(params.get("id"));
+        if (id == null) {
+            return Result.error("id不能为空");
+        }
+        familyMetricAggConfigMapper.deleteById(id);
+        return Result.success(Boolean.TRUE);
+    }
+
+    /** 更新状态(active/disabled) */
+    @Operation(summary = "更新家庭指标聚合方式配置状态")
+    @PostMapping("/update-status")
+    public Result<Boolean> updateStatus(@RequestBody Map<String, Object> params,
+                                        @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        Long id = longVal(params.get("id"));
+        String status = str(params.get("status"));
+        if (id == null) {
+            return Result.error("id不能为空");
+        }
+        if (status == null || status.trim().isEmpty()) {
+            return Result.error("status不能为空");
+        }
+        FamilyMetricAggConfig config = familyMetricAggConfigMapper.selectById(id);
+        if (config == null) {
+            return Result.error("配置不存在");
+        }
+        config.setStatus(status.trim());
+        config.setUpdatedAt(new Date());
+        familyMetricAggConfigMapper.updateById(config);
+        return Result.success(Boolean.TRUE);
+    }
+
+    private Map<String, Object> toMap(FamilyMetricAggConfig c) {
+        Map<String, Object> m = new java.util.LinkedHashMap<>();
+        m.put("id", c.getId());
+        m.put("metricKey", c.getMetricKey());
+        m.put("metricName", c.getMetricName());
+        m.put("dimensionCode", c.getDimensionCode());
+        m.put("aggType", c.getAggType());
+        m.put("valueType", c.getValueType());
+        m.put("displayMode", c.getDisplayMode());
+        m.put("sortOrder", c.getSortOrder());
+        m.put("status", c.getStatus());
+        m.put("remark", c.getRemark());
+        return m;
+    }
+
+    private String str(Object o) {
+        return o == null ? null : o.toString();
+    }
+
+    private Long longVal(Object o) {
+        return o == null ? null : Long.valueOf(o.toString());
+    }
+
+    private Integer intVal(Object o, Integer def) {
+        if (o == null) {
+            return def;
+        }
+        try {
+            return Integer.valueOf(o.toString());
+        } catch (NumberFormatException e) {
+            return def;
+        }
+    }
+}

+ 55 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyMetricAggConfig.java

@@ -0,0 +1,55 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 家庭指标聚合方式配置(平台级全局一份,按数据项一行一条)。
+ * <p>
+ * metric_key 唯一标识:energy.body/energy.mind/energy.wisdom/energy.action/energy.wealth/energy.overall/pearl.resources/octopus.effects
+ * agg_type:SUM=合计 / AVG=平均 / COLLECT=汇集
+ * value_type:NUMBER(数值)/ COLLECTION(集合)
+ */
+@Data
+@TableName("family_metric_agg_config")
+public class FamilyMetricAggConfig implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 数据项唯一标识,如 energy.body、pearl.resources */
+    private String metricKey;
+
+    /** 中文名,如「身体维度能量分」 */
+    private String metricName;
+
+    /** 所属维度:body/mind/wisdom/action/wealth/common */
+    private String dimensionCode;
+
+    /** 汇总方式:SUM=合计 / AVG=平均 / COLLECT=汇集 */
+    private String aggType;
+
+    /** 值类型:NUMBER(数值)/ COLLECTION(集合) */
+    private String valueType;
+
+    /** 展示形态:number/bar/radar/merge_graph,可空 */
+    private String displayMode;
+
+    /** 排序 */
+    private Integer sortOrder;
+
+    /** 状态:active/disabled */
+    private String status;
+
+    /** 备注 */
+    private String remark;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/FamilyMetricAggConfigMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.etotem.cfc.entity.FamilyMetricAggConfig;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface FamilyMetricAggConfigMapper extends BaseMapper<FamilyMetricAggConfig> {
+}

+ 110 - 6
cfc-backend/src/main/java/com/etotem/cfc/service/EnergyService.java

@@ -90,6 +90,9 @@ public class EnergyService {
     @Resource
     private GuideMapper guideMapper;
 
+    @Resource
+    private FamilyMetricAggConfigMapper familyMetricAggConfigMapper;
+
     /**
      * 计算整个家庭的能量沙盘数据
      *
@@ -154,7 +157,8 @@ public class EnergyService {
 
         dto.setMembers(members);
 
-        // 4. 聚合家庭五维平均值
+        // 4. 聚合家庭五维(按配置 family_metric_agg_config 取 agg_type:AVG=平均 / SUM=合计 / COLLECT=汇集)
+        //    默认 AVG 保证向后兼容;读不到配置时回落 AVG;COLLECT 对数值维度不适用,返回 AVG 并记录日志
         int bodySum = 0, mindSum = 0, wisdomSum = 0, actionSum = 0, wealthSum = 0;
         for (MemberEnergyDTO m : members) {
             bodySum += safeScore(m.getBodyScore());
@@ -164,11 +168,11 @@ public class EnergyService {
             wealthSum += safeScore(m.getWealthScore());
         }
         int count = members.size();
-        dto.setBodyScore(bodySum / count);
-        dto.setMindScore(mindSum / count);
-        dto.setWisdomScore(wisdomSum / count);
-        dto.setActionScore(actionSum / count);
-        dto.setWealthScore(wealthSum / count);
+        dto.setBodyScore(aggregateFamilyMetric("energy.body", bodySum, count));
+        dto.setMindScore(aggregateFamilyMetric("energy.mind", mindSum, count));
+        dto.setWisdomScore(aggregateFamilyMetric("energy.wisdom", wisdomSum, count));
+        dto.setActionScore(aggregateFamilyMetric("energy.action", actionSum, count));
+        dto.setWealthScore(aggregateFamilyMetric("energy.wealth", wealthSum, count));
 
         int overall = (dto.getBodyScore() + dto.getMindScore() + dto.getWisdomScore()
                 + dto.getActionScore() + dto.getWealthScore()) / 5;
@@ -177,6 +181,52 @@ public class EnergyService {
         return dto;
     }
 
+    /**
+     * 按配置聚合家庭数值维度指标。
+     *
+     * <p>依据 {@code family_metric_agg_config} 中 metricKey 对应的 agg_type:
+     * <ul>
+     *   <li>SUM → 合计(返回 sum,不除以 count)</li>
+     *   <li>COLLECT → 数值维度不适用,回落 AVG 并记录日志</li>
+     *   <li>AVG 或读不到配置 → 平均(默认,保持既有行为)</li>
+     * </ul>
+     */
+    private int aggregateFamilyMetric(String metricKey, int sum, int count) {
+        if (count <= 0) {
+            return 0;
+        }
+        String aggType = resolveMetricAggType(metricKey);
+        if ("SUM".equalsIgnoreCase(aggType)) {
+            return sum;
+        }
+        if ("COLLECT".equalsIgnoreCase(aggType)) {
+            log.info("配置项 {} 为 COLLECT 类型,对数值维度不适用,回落 AVG 计算", metricKey);
+        }
+        return sum / count;
+    }
+
+    /**
+     * 读取某数据项的汇总方式(agg_type),读不到配置或配置无效时回落 AVG。
+     */
+    private String resolveMetricAggType(String metricKey) {
+        if (metricKey == null) {
+            return "AVG";
+        }
+        try {
+            FamilyMetricAggConfig config = familyMetricAggConfigMapper.selectOne(
+                    new LambdaQueryWrapper<FamilyMetricAggConfig>()
+                            .eq(FamilyMetricAggConfig::getMetricKey, metricKey)
+                            .eq(FamilyMetricAggConfig::getStatus, "active")
+                            .last("LIMIT 1"));
+            if (config != null && config.getAggType() != null && !config.getAggType().trim().isEmpty()) {
+                return config.getAggType().trim().toUpperCase();
+            }
+        } catch (Exception e) {
+            log.warn("读取家庭指标聚合配置失败 metricKey={}: {}", metricKey, e.getMessage());
+        }
+        return "AVG";
+    }
+
     /**
      * 在家庭沙盘中查找指定成员的能量数据。
      * 匹配顺序:memberId+memberType → 仅 memberId → family_members.id 解析到的 userId(家庭主账号)。
@@ -307,6 +357,60 @@ public class EnergyService {
         return result;
     }
 
+    /**
+     * 收集家庭内所有「数据归属主体」列表(供珍珠图/章鱼图家庭汇集接口复用)。
+     * <p>
+     * 与 {@link #doCalculateFamilyEnergy} 的成员收集逻辑一致:
+     * 家庭主账号(userMapper 取 role=parent 的用户)+ 家庭成员(getActualChildren 去重)。
+     * </p>
+     * <p>每项字段:</p>
+     * <ul>
+     *   <li>{@code memberId}:前端归属键(主账号=users.id;成员=family_members.id)</li>
+     *   <li>{@code memberName}:昵称</li>
+     *   <li>{@code memberType}:user=家庭主账号 / member=家庭成员(契约约定)</li>
+     *   <li>{@code colorKey}:稳定色值键(0..n-1 索引,同一成员前后一致)</li>
+     *   <li>{@code dataUserId}:拉取个人数据所用 userId(主账号=userId;成员=family_members.userId,可为 null)</li>
+     * </ul>
+     */
+    public List<Map<String, Object>> collectFamilyMetricMembers(Long familyId) {
+        List<Map<String, Object>> result = new ArrayList<>();
+        if (familyId == null) {
+            return result;
+        }
+        List<User> parents = userMapper.selectList(
+                new LambdaQueryWrapper<User>()
+                        .eq(User::getFamilyId, familyId)
+                        .orderByAsc(User::getId));
+        List<FamilyMember> children = getActualChildren(familyId);
+
+        int colorIdx = 0;
+        for (User parent : parents) {
+            if (parent == null || !hasParentRole(parent)) {
+                continue;
+            }
+            Map<String, Object> m = new LinkedHashMap<>();
+            m.put("memberId", parent.getId());
+            m.put("memberName", parent.getNickname() != null ? parent.getNickname() : "成员");
+            m.put("memberType", "user");
+            m.put("colorKey", colorIdx++);
+            m.put("dataUserId", parent.getId());
+            result.add(m);
+        }
+        for (FamilyMember child : children) {
+            if (child == null) {
+                continue;
+            }
+            Map<String, Object> m = new LinkedHashMap<>();
+            m.put("memberId", child.getId());
+            m.put("memberName", child.getNickname() != null ? child.getNickname() : "成员");
+            m.put("memberType", "member");
+            m.put("colorKey", colorIdx++);
+            m.put("dataUserId", child.getUserId());
+            result.add(m);
+        }
+        return result;
+    }
+
     // ==================== 家庭主账号能量计算 ====================
 
     private MemberEnergyDTO calcParentEnergy(User parent) {

+ 34 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/KeyPersonService.java

@@ -61,6 +61,40 @@ public class KeyPersonService {
         return Result.success(data);
     }
 
+    /**
+     * 章鱼图家庭汇集:每个家庭成员一份成效列表(合并成一张图,按成员着色/标注)。
+     * 契约:data = { members: [ { memberId, memberName, memberType, colorKey, effects: [原章鱼成效项结构] } ] }
+     * 复用 {@link #listEffects} 的个人成效查询逻辑(含自动计算标签),不重写业务规则。
+     */
+    public Result<Map<String, Object>> getFamilyOctopusEffects(Long familyId) {
+        List<Map<String, Object>> members = new ArrayList<>();
+        List<Map<String, Object>> familyMembers = energyService.collectFamilyMetricMembers(familyId);
+        for (Map<String, Object> member : familyMembers) {
+            Map<String, Object> memberEntry = new LinkedHashMap<>();
+            memberEntry.put("memberId", member.get("memberId"));
+            memberEntry.put("memberName", member.get("memberName"));
+            memberEntry.put("memberType", member.get("memberType"));
+            memberEntry.put("colorKey", member.get("colorKey"));
+            Object dataUserIdObj = member.get("dataUserId");
+            Long dataUserId = dataUserIdObj != null ? Long.valueOf(dataUserIdObj.toString()) : null;
+            memberEntry.put("effects", dataUserId != null ? fetchEffectList(dataUserId) : new ArrayList<>());
+            members.add(memberEntry);
+        }
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("members", members);
+        return Result.success(data);
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<Map<String, Object>> fetchEffectList(Long userId) {
+        Result<Map<String, Object>> result = listEffects(userId, null, 1, Integer.MAX_VALUE);
+        if (result == null || result.getData() == null) {
+            return new ArrayList<>();
+        }
+        Object listObj = result.getData().get("list");
+        return listObj instanceof List ? (List<Map<String, Object>>) listObj : new ArrayList<>();
+    }
+
     private Map<String, Object> toEffectVO(OctopusEffectRecord r, List<String> computedTypes) {
         Map<String, Object> m = new LinkedHashMap<>();
         m.put("id", r.getId());

+ 78 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/ResourceService.java

@@ -48,6 +48,9 @@ public class ResourceService {
     @Resource
     private PearlInteractionReminderMapper reminderMapper;
 
+    @Resource
+    private EnergyService energyService;
+
     /** 25 类连接类型 → 前端选择器/渲染配置 */
     public List<Map<String, Object>> getConnectionTypes() {
         List<Map<String, Object>> list = new ArrayList<>();
@@ -64,6 +67,80 @@ public class ResourceService {
 
     /** 珍珠图聚合:25 类社会连接 + 历史资源(legacy PERSON/SKILL/INFO/PLACE) */
     public Result<Map<String, Object>> getPearlResources(Long userId) {
+        return Result.success(buildPearlData(userId));
+    }
+
+    /**
+     * 珍珠图家庭汇集:每个家庭成员一份珍珠资源 items(合并成一张图,按成员着色/标注)。
+     * 契约:data = { members: [ { memberId, memberName, memberType, colorKey, items: [原珍珠资源项结构] } ] }
+     */
+    public Result<Map<String, Object>> getFamilyPearlResources(Long familyId) {
+        List<Map<String, Object>> members = new ArrayList<>();
+        List<Map<String, Object>> familyMembers = energyService.collectFamilyMetricMembers(familyId);
+        for (Map<String, Object> member : familyMembers) {
+            Map<String, Object> memberEntry = new LinkedHashMap<>();
+            memberEntry.put("memberId", member.get("memberId"));
+            memberEntry.put("memberName", member.get("memberName"));
+            memberEntry.put("memberType", member.get("memberType"));
+            memberEntry.put("colorKey", member.get("colorKey"));
+            Object dataUserIdObj = member.get("dataUserId");
+            Long dataUserId = dataUserIdObj != null ? Long.valueOf(dataUserIdObj.toString()) : null;
+            memberEntry.put("items", dataUserId != null ? flattenPearlItems(buildPearlData(dataUserId)) : new ArrayList<>());
+            members.add(memberEntry);
+        }
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("members", members);
+        return Result.success(data);
+    }
+
+    /** 将珍珠图 groups + legacyGroups 展平为单层 items(带 groupType/groupTypeName/ring 归属,供合并图) */
+    @SuppressWarnings("unchecked")
+    private List<Map<String, Object>> flattenPearlItems(Map<String, Object> pearlData) {
+        List<Map<String, Object>> items = new ArrayList<>();
+        if (pearlData == null) {
+            return items;
+        }
+        Object groupsObj = pearlData.get("groups");
+        if (groupsObj instanceof List) {
+            for (Object g : (List<Object>) groupsObj) {
+                if (!(g instanceof Map)) continue;
+                Map<String, Object> group = (Map<String, Object>) g;
+                Object groupItems = group.get("items");
+                if (groupItems instanceof List) {
+                    for (Object it : (List<Object>) groupItems) {
+                        if (!(it instanceof Map)) continue;
+                        Map<String, Object> item = new LinkedHashMap<>((Map<String, Object>) it);
+                        item.put("groupType", group.get("type"));
+                        item.put("groupTypeName", group.get("typeName"));
+                        item.put("ring", group.get("ring"));
+                        items.add(item);
+                    }
+                }
+            }
+        }
+        Object legacyObj = pearlData.get("legacyGroups");
+        if (legacyObj instanceof List) {
+            for (Object g : (List<Object>) legacyObj) {
+                if (!(g instanceof Map)) continue;
+                Map<String, Object> group = (Map<String, Object>) g;
+                Object groupItems = group.get("items");
+                if (groupItems instanceof List) {
+                    for (Object it : (List<Object>) groupItems) {
+                        if (!(it instanceof Map)) continue;
+                        Map<String, Object> item = new LinkedHashMap<>((Map<String, Object>) it);
+                        item.put("groupType", group.get("type"));
+                        item.put("groupTypeName", group.get("typeName"));
+                        item.put("ring", "inner");
+                        items.add(item);
+                    }
+                }
+            }
+        }
+        return items;
+    }
+
+    /** 构建珍珠图原始数据(groups + legacyGroups) */
+    private Map<String, Object> buildPearlData(Long userId) {
         // 历史资源:legacy 分组的 contacts(人脉)与 skillLogs(技能)照旧
         List<Contact> contacts = contactMapper.selectList(
                 new LambdaQueryWrapper<Contact>().eq(Contact::getUserId, userId)
@@ -151,7 +228,7 @@ public class ResourceService {
         Map<String, Object> result = new LinkedHashMap<>();
         result.put("groups", groups);
         result.put("legacyGroups", legacyGroups);
-        return Result.success(result);
+        return result;
     }
 
     /** 登记资源(珍珠图:25 类社会连接,connectionType 必填) */

+ 19 - 0
cfc-backend/src/main/resources/schema.sql

@@ -6973,3 +6973,22 @@ CREATE TABLE IF NOT EXISTS indicator_favorite (
     KEY idx_member_source (member_id, source_type),
     KEY idx_user_created (user_id, created_at)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户关注指标收藏';
+
+-- 家庭指标聚合方式配置表(平台级全局一份,按数据项一行一条)
+-- agg_type:SUM=合计 / AVG=平均 / COLLECT=汇集
+-- value_type:NUMBER(数值)/ COLLECTION(集合)
+CREATE TABLE IF NOT EXISTS family_metric_agg_config (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    metric_key VARCHAR(64) NOT NULL COMMENT '数据项唯一标识,如 energy.body、pearl.resources',
+    metric_name VARCHAR(64) NOT NULL COMMENT '中文名,如「身体维度能量分」',
+    dimension_code VARCHAR(32) COMMENT '所属维度:body/mind/wisdom/action/wealth/common',
+    agg_type VARCHAR(16) NOT NULL COMMENT '汇总方式:SUM=合计 / AVG=平均 / COLLECT=汇集',
+    value_type VARCHAR(16) NOT NULL COMMENT '值类型:NUMBER(数值)/ COLLECTION(集合)',
+    display_mode VARCHAR(32) COMMENT '展示形态:number/bar/radar/merge_graph',
+    sort_order INT DEFAULT 0 COMMENT '排序',
+    status VARCHAR(16) DEFAULT 'active' COMMENT '状态:active/disabled',
+    remark VARCHAR(255) COMMENT '备注',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_metric_key (metric_key)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭指标聚合方式配置';

文件差異過大導致無法顯示
+ 0 - 0
docs/superpowers/PROJECT-OVERVIEW.md


+ 50 - 0
docs/superpowers/api/API_REFERENCE.md

@@ -2403,3 +2403,53 @@ W0.2 双配置中心合并:`product_config` 已并入 `service_config`(唯
   - 落库 `health_plans`,`plan_type=family_attribution_plan`,`status=GENERATED`(⑤)
 
 *文档更新:2026-10-28(家庭归因核心闭环 B1–B4:/api/attribution/analyze、/api/attribution/adjust、/api/attribution/confirm-adjust、/api/attribution/plan、/api/survey/attribution/confirm-questions;/api/survey/submit 扩展 riskHits)*
+
+
+---
+
+## 4.57 家庭指标聚合方式配置 + 珍珠图/章鱼图家庭汇集
+
+> 五维能量页(身/智/富/行/心)三视角:① 个人看自己各维度;② 家庭管理员看家庭成员各维度;③ 看整个家庭各维度汇总。
+> 家庭各维度汇总方式可配置:**SUM=合计 / AVG=平均 / COLLECT=汇集**(如行页面珍珠图、章鱼图)。
+> 配置作用域 = **平台级全局一份**(`family_metric_agg_config` 表,按数据项一行一条,迁移433)。
+> 默认五维 AVG 保持既有页面数值表现不变(向后兼容);SUM/COLLECT 由后台配置页调整即生效。
+
+**数据表 `family_metric_agg_config`**:
+
+| 字段 | 说明 |
+|------|------|
+| `metric_key` (UNIQUE) | 数据项唯一标识:`energy.body`/`energy.mind`/`energy.wisdom`/`energy.action`/`energy.wealth`/`energy.overall`/`pearl.resources`/`octopus.effects` |
+| `metric_name` | 中文名 |
+| `dimension_code` | 所属维度:body/mind/wisdom/action/wealth/common |
+| `agg_type` | SUM=合计 / AVG=平均 / COLLECT=汇集 |
+| `value_type` | NUMBER(数值)/ COLLECTION(集合) |
+| `display_mode` | number/bar/radar/merge_graph |
+| `sort_order` / `status` / `remark` | 排序 / active|disabled / 备注 |
+
+**配置 CRUD**(`/api/admin/family-metric-agg/*`,供 cfc-web 管理端,`admin` 角色守卫):
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/admin/family-metric-agg/list` | 全量配置列表(配置项 < 50,无需分页)。返回 `[ { id, metricKey, metricName, dimensionCode, aggType, valueType, displayMode, sortOrder, status, remark } ]` |
+| `POST /api/admin/family-metric-agg/save` | 新增/更新(body 带 `id` 则更新,否则新增)。Body:`{ id?, metricKey, metricName, dimensionCode, aggType, valueType, displayMode?, sortOrder?, status?, remark? }` → `true` |
+| `POST /api/admin/family-metric-agg/delete` | Body `{ id }` → `true` |
+| `POST /api/admin/family-metric-agg/update-status` | Body `{ id, status }` → `true` |
+
+**家庭汇集数据**(供小程序行页面,全 POST):
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/pearl/family/resources` | 珍珠图家庭汇集。data:`{ members: [ { memberId, memberName, memberType, colorKey, items: [原珍珠资源项结构 + groupType/groupTypeName/ring] } ] }` |
+| `POST /api/octopus/effect/family/list` | 章鱼图家庭汇集。data:`{ members: [ { memberId, memberName, memberType, colorKey, effects: [原章鱼成效项结构] } ] }` |
+
+- `memberType`:`user`(家庭主账号,memberId=users.id)/ `member`(家庭成员,memberId=family_members.id)
+- `colorKey`:稳定色值键(0..n-1 索引,同一成员前后一致),供前端按成员着色
+- 成员收集与 `EnergyService.doCalculateFamilyEnergy` 一致(家庭主账号 role=parent + `getActualChildren` 去重);个人数据查询复用既有逻辑(`ResourceService.getPearlResources` / `KeyPersonService.listEffects`),不重写业务规则
+
+**五维聚合改造(`EnergyService.doCalculateFamilyEnergy` 第 4 步)**:按 `metric_key`(`energy.body`…)查配置取 `agg_type`:
+- AVG → 既有平均逻辑(默认,行为不变)
+- SUM → 合计(sum,不再除以 count)
+- COLLECT → 数值维度不适用,回落 AVG + 日志记录
+- 读不到配置 → 回落 AVG(保证兼容)
+
+*文档更新:2026-10-31(家庭数据汇总方式配置(后端):family_metric_agg_config 表 + 配置 CRUD 4 接口 + 珍珠图/章鱼图家庭汇集 2 接口 + 五维聚合读配置)*

部分文件因文件數量過多而無法顯示