Преглед изворни кода

feat: 电商 + 推荐系统 + 阅读统计 综合升级

### 电商体系
- cart: 支持 SKU 选择 (sku_id, spec_option_ids), 字段扩展  (skuId, specDesc, supplierName)
- DatabaseInitializer: 迁移84 — cart 表 sku_id, spec_option_ids 列
- OrderItemVO: 新增 skuId, specOptionIds
- ProductDTO: 新增 effectivePpoint, ppointSource, profitRate
- CartService: addToCart 接收 skuId, 回填 supplierName 从 vendorName

### P点管理
- PpointService: adminList() 管理端分页列表
- AdminProductPpointController: 新增管理端 P点配置控制器

### 商品推荐系统
- ProductRecommendationService: 维度匹配引擎 (dimensionScore 排序 + 90天过滤 + 购买历史排除)
- ProductRecommendationController: POST /api/recommend/dimension-products
- 5维页面 (wisdom/body/mind/action/wealth): DimensionProductList 替换 DimensionProducts

### 健康打卡
- HealthCheckinCard.vue: 新增健康打卡卡片组件
- body/index.vue: 集成 HealthCheckinCard

### 智慧维度
- wisdom/index.vue: 本月阅读卡片 + 阅读统计 API 接入

### 文档
- 五维能量设计: 更新 v2.1 富维度子维度算法 + 身克富杠杆文档
- 五维五行哲学: 补充富子维度表 + 身克富三态公式
Xiaogang Liao пре 2 месеци
родитељ
комит
b7e4c3ed58

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

@@ -5308,5 +5308,9 @@ try {
         } catch (Exception e) {
             log.warn("创建 repurchase_reminder_config 表失败:{}", e.getMessage());
         }
+
+        // 迁移84: cart表添加skuId和specOptionIds字段(支持SKU选择)
+        ensureColumn("cart", "sku_id", "BIGINT COMMENT '选中的SKU ID'");
+        ensureColumn("cart", "spec_option_ids", "VARCHAR(500) COMMENT '选中的规格选项IDs JSON'");
     }
 }

+ 78 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminProductPpointController.java

@@ -0,0 +1,78 @@
+package com.etotem.cfc.controller.admin;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.ProductPpoint;
+import com.etotem.cfc.service.PpointService;
+import org.springframework.web.bind.annotation.PostMapping;
+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.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/admin/product-ppoint")
+public class AdminProductPpointController {
+
+    @Resource
+    private PpointService ppointService;
+
+    @PostMapping("/list")
+    public Result<Map<String, Object>> list(@RequestBody Map<String, Object> params) {
+        int page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
+        int size = params.get("size") != null ? ((Number) params.get("size")).intValue() : 20;
+        String keyword = (String) params.get("keyword");
+        Long productId = params.get("productId") != null ? Long.valueOf(params.get("productId").toString()) : null;
+
+        Page<ProductPpoint> pageParam = new Page<>(page, size);
+        Map<String, Object> result = ppointService.adminList(pageParam, keyword, productId);
+        return Result.success(result);
+    }
+
+    @PostMapping("/save")
+    public Result<Void> save(@RequestBody Map<String, Object> params) {
+        Long productId = Long.valueOf(params.get("productId").toString());
+        Integer ppoint = Integer.valueOf(params.get("ppoint").toString());
+        String startDateStr = (String) params.get("startDate");
+        String endDateStr = (String) params.get("endDate");
+
+        java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd");
+        java.util.Date startDate = null;
+        java.util.Date endDate = null;
+        try {
+            if (startDateStr != null && !startDateStr.isEmpty()) {
+                startDate = sdf.parse(startDateStr);
+            }
+            if (endDateStr != null && !endDateStr.isEmpty()) {
+                endDate = sdf.parse(endDateStr);
+            }
+        } catch (Exception e) {
+            return Result.error("日期格式错误,请使用yyyy-MM-dd");
+        }
+
+        if (ppoint < 0 || ppoint > 10000) {
+            return Result.error("P点取值范围为0-10000(即0%-100%)");
+        }
+
+        ppointService.savePpoint(productId, ppoint, startDate, endDate, null);
+        return Result.success(null);
+    }
+
+    @PostMapping("/delete")
+    public Result<Void> delete(@RequestBody Map<String, Object> params) {
+        Long id = Long.valueOf(params.get("id").toString());
+        ppointService.deleteById(id);
+        return Result.success(null);
+    }
+
+    @PostMapping("/by-product")
+    public Result<List<ProductPpoint>> byProduct(@RequestBody Map<String, Object> params) {
+        Long productId = Long.valueOf(params.get("productId").toString());
+        List<ProductPpoint> records = ppointService.getByProductId(productId);
+        return Result.success(records);
+    }
+}

+ 3 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/cart/CartController.java

@@ -25,8 +25,10 @@ public class CartController {
                                    @RequestAttribute("userId") Long userId) {
         Long productId = Long.valueOf(params.get("productId").toString());
         Integer quantity = params.containsKey("quantity") ? Integer.valueOf(params.get("quantity").toString()) : 1;
+        Long skuId = params.containsKey("skuId") && params.get("skuId") != null
+                ? Long.valueOf(params.get("skuId").toString()) : null;
 
-        CartItemDTO dto = cartService.addToCart(userId, productId, quantity);
+        CartItemDTO dto = cartService.addToCart(userId, productId, quantity, skuId);
         if (dto == null) {
             return Result.error("商品不存在");
         }

+ 70 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/recommendation/ProductRecommendationController.java

@@ -0,0 +1,70 @@
+package com.etotem.cfc.controller.recommendation;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.ProductRecommendationService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 商品推荐接口
+ * 维度页推荐、复购提醒列表
+ */
+@Slf4j
+@Tag(name = "商品推荐", description = "商品推荐相关接口")
+@RestController
+@RequestMapping("/api/recommend")
+public class ProductRecommendationController {
+
+    @Resource
+    private ProductRecommendationService productRecommendationService;
+
+    @Operation(summary = "获取维度页推荐商品")
+    @PostMapping("/dimension-products")
+    public Result<List<Map<String, Object>>> getDimensionProducts(@RequestBody Map<String, Object> params) {
+        String dimensionCode = (String) params.get("dimensionCode");
+        if (dimensionCode == null || dimensionCode.isEmpty()) {
+            return Result.error("dimensionCode不能为空");
+        }
+
+        Object familyIdObj = params.get("familyId");
+        Long familyId = familyIdObj != null ? toLong(familyIdObj) : null;
+
+        Object memberIdObj = params.get("memberId");
+        Long memberId = memberIdObj != null ? toLong(memberIdObj) : null;
+
+        @SuppressWarnings("unchecked")
+        List<Long> excludeIds = (List<Long>) params.get("excludeProductIds");
+
+        Object limitObj = params.get("limit");
+        int limit = limitObj != null ? toInt(limitObj) : 6;
+
+        List<Map<String, Object>> result = productRecommendationService.getDimensionRecommendations(
+                dimensionCode, familyId, memberId, excludeIds, limit);
+
+        return Result.success(result);
+    }
+
+    private Long toLong(Object obj) {
+        if (obj == null) return null;
+        if (obj instanceof Number) return ((Number) obj).longValue();
+        if (obj instanceof String) {
+            try { return Long.parseLong((String) obj); } catch (Exception e) { return null; }
+        }
+        return null;
+    }
+
+    private int toInt(Object obj) {
+        if (obj == null) return 6;
+        if (obj instanceof Number) return ((Number) obj).intValue();
+        if (obj instanceof String) {
+            try { return Integer.parseInt((String) obj); } catch (Exception e) { return 6; }
+        }
+        return 6;
+    }
+}

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/CartItemDTO.java

@@ -7,8 +7,11 @@ public class CartItemDTO {
     private Long id;
     private Long userId;
     private Long productId;
+    private Long skuId;
     private Integer quantity;
     private String productName;
     private Integer unitPrice;
     private String coverImage;
+    private String specDesc;      // SKU规格描述,如"颜色:红色;尺码:M"
+    private String supplierName; // 供应商名称
 }

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/OrderItemVO.java

@@ -5,6 +5,8 @@ import lombok.Data;
 @Data
 public class OrderItemVO {
     private Long productId;
+    private Long skuId;
+    private String specOptionIds;  // JSON string of selected spec option IDs, e.g. "[1,2,3]"
     private Integer quantity = 1;
     private String coverImage;
 

+ 5 - 1
cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java

@@ -30,7 +30,9 @@ public class ProductDTO {
     private Integer deliveryMethod;        // 配送方式: 1=快递 2=自提 3=两者皆可
     private List<String> imageList;
     private Long energyDimensionId;
-    private String priceLabel;     // 游客 = "登录查看价格",登录后 = null
+    private Integer effectivePpoint;     // 有效P点(基点,如1000=10%)
+    private String ppointSource;         // config=管理员配置,default=商品默认
+    private Integer profitRate;         // 商品默认利润率(千分比,备用)
 
     // 测评商品扩展信息
     private String assessmentType;
@@ -41,6 +43,8 @@ public class ProductDTO {
     private Date createdAt;
     private Date updatedAt;
 
+    private String priceLabel;     // 游客 = "登录查看价格",登录后 = null
+
     public static ProductDTO from(Product p) {
         return from(p, false, null);
     }

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/Cart.java

@@ -15,6 +15,8 @@ public class Cart implements Serializable {
     private Long id;
     private Long userId;
     private Long productId;
+    private Long skuId;           // 选中的SKU ID
+    private String specOptionIds;  // 选中的规格选项 IDs(JSON)
     private Integer quantity;
     private LocalDateTime createdAt;
     private LocalDateTime updatedAt;

+ 11 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/CartService.java

@@ -26,7 +26,7 @@ public class CartService {
     private ProductMapper productMapper;
 
     @Transactional
-    public CartItemDTO addToCart(Long userId, Long productId, Integer quantity) {
+    public CartItemDTO addToCart(Long userId, Long productId, Integer quantity, Long skuId) {
         Product product = productMapper.selectById(productId);
         if (product == null) {
             log.warn("addToCart failed: product not found, productId={}", productId);
@@ -37,6 +37,7 @@ public class CartService {
             new LambdaQueryWrapper<Cart>()
                 .eq(Cart::getUserId, userId)
                 .eq(Cart::getProductId, productId)
+                .eq(skuId != null, Cart::getSkuId, skuId)
         );
 
         if (existing != null) {
@@ -47,13 +48,20 @@ public class CartService {
             Cart cart = new Cart();
             cart.setUserId(userId);
             cart.setProductId(productId);
+            cart.setSkuId(skuId);
             cart.setQuantity(quantity);
             cart.setCreatedAt(LocalDateTime.now());
             cart.setUpdatedAt(LocalDateTime.now());
             cartMapper.insert(cart);
         }
 
-        return getCartItemDTO(userId, productId);
+        Cart saved = cartMapper.selectOne(
+            new LambdaQueryWrapper<Cart>()
+                .eq(Cart::getUserId, userId)
+                .eq(Cart::getProductId, productId)
+                .eq(skuId != null, Cart::getSkuId, skuId)
+        );
+        return buildCartItemDTO(saved);
     }
 
     public List<CartItemDTO> getCartList(Long userId) {
@@ -129,8 +137,8 @@ public class CartService {
         if (product != null) {
             dto.setProductName(product.getName());
             dto.setCoverImage(product.getCoverImage());
-            // Price stored in 分(integer), send directly
             dto.setUnitPrice(product.getPrice() != null ? product.getPrice() : 0);
+            dto.setSupplierName(product.getVendorName());
         }
         return dto;
     }

+ 57 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/PpointService.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.entity.Product;
 import com.etotem.cfc.entity.ProductPpoint;
 import com.etotem.cfc.mapper.ProductMapper;
@@ -11,7 +12,10 @@ import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
 import java.util.Date;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
 
 @Service
 public class PpointService {
@@ -107,4 +111,57 @@ public class PpointService {
                         .orderByDesc(ProductPpoint::getStartDate)
         );
     }
+
+    /**
+     * Admin list: paginated P-point configs with product name.
+     */
+    public Map<String, Object> adminList(Page<ProductPpoint> pageParam, String keyword, Long productId) {
+        LambdaQueryWrapper<ProductPpoint> wrapper = new LambdaQueryWrapper<ProductPpoint>()
+                .orderByDesc(ProductPpoint::getCreatedAt);
+
+        if (productId != null) {
+            wrapper.eq(ProductPpoint::getProductId, productId);
+        }
+
+        Page<ProductPpoint> page = productPpointMapper.selectPage(pageParam, wrapper);
+        List<ProductPpoint> records = page.getRecords();
+
+        Map<Long, Product> productMap = new HashMap<>();
+        if (!records.isEmpty()) {
+            List<Long> productIds = records.stream()
+                    .map(ProductPpoint::getProductId)
+                    .distinct()
+                    .collect(Collectors.toList());
+            List<Product> products = productMapper.selectBatchIds(productIds);
+            for (Product p : products) {
+                productMap.put(p.getId(), p);
+            }
+        }
+
+        final Map<Long, Product> pm = productMap;
+        List<Map<String, Object>> enriched = records.stream().map(r -> {
+            Map<String, Object> m = new HashMap<>();
+            m.put("id", r.getId());
+            m.put("productId", r.getProductId());
+            m.put("ppoint", r.getPpoint());
+            m.put("startDate", r.getStartDate());
+            m.put("endDate", r.getEndDate());
+            m.put("createdBy", r.getCreatedBy());
+            m.put("createdAt", r.getCreatedAt());
+            Product p = pm.get(r.getProductId());
+            if (p != null) {
+                m.put("productName", p.getName());
+                m.put("productProfitRate", p.getProfitRate());
+                m.put("effectivePpoint", getEffectivePpoint(r.getProductId()));
+            }
+            return m;
+        }).collect(Collectors.toList());
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("records", enriched);
+        result.put("total", page.getTotal());
+        result.put("page", page.getCurrent());
+        result.put("size", page.getSize());
+        return result;
+    }
 }

+ 356 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ProductRecommendationService.java

@@ -0,0 +1,356 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.*;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 商品推荐服务
+ * 支持维度页推荐、AI对话推荐、报告关联推荐
+ */
+@Slf4j
+@Service
+public class ProductRecommendationService {
+
+    @Resource
+    private ProductMapper productMapper;
+
+    @Resource
+    private ProductDimensionMappingMapper dimensionMappingMapper;
+
+    @Resource
+    private ProductOrderMapper orderMapper;
+
+    @Resource
+    private FiveDimensionScoreMapper fiveDimensionScoreMapper;
+
+    @Resource
+    private ProductRecommendationLogMapper recommendationLogMapper;
+
+    /**
+     * 维度页推荐
+     *
+     * @param dimensionCode    维度编码: body/wisdom/mind/action/wealth
+     * @param familyId         家庭ID
+     * @param memberId         成员ID(可选)
+     * @param excludeProductIds 排除的商品ID列表
+     * @param limit            返回数量
+     * @return 推荐商品列表
+     */
+    public List<Map<String, Object>> getDimensionRecommendations(
+            String dimensionCode, Long familyId, Long memberId,
+            List<Long> excludeProductIds, int limit) {
+
+        // 1. 获取已购商品ID(90天内)
+        Set<Long> purchasedIds = getPurchasedProductIds(familyId, 90);
+        if (excludeProductIds != null) {
+            purchasedIds.addAll(excludeProductIds);
+        }
+
+        // 2. 获取成员维度得分
+        Map<String, Integer> memberScores = getMemberDimensionScores(familyId, memberId);
+
+        // 3. 查询维度匹配商品(优先从 dimension_mapping 表)
+        List<Product> candidates = new ArrayList<>();
+
+        // 从 mapping 表获取关联商品
+        List<ProductDimensionMapping> mappings = dimensionMappingMapper.selectList(
+                new LambdaQueryWrapper<ProductDimensionMapping>()
+                        .eq(ProductDimensionMapping::getDimensionCode, dimensionCode)
+                        .eq(ProductDimensionMapping::getEnabled, 1)
+        );
+
+        if (mappings != null && !mappings.isEmpty()) {
+            List<Long> mappedProductIds = mappings.stream()
+                    .map(ProductDimensionMapping::getProductId)
+                    .filter(id -> !purchasedIds.contains(id))
+                    .collect(Collectors.toList());
+
+            if (!mappedProductIds.isEmpty()) {
+                List<Product> mapped = productMapper.selectList(
+                        new LambdaQueryWrapper<Product>()
+                                .in(Product::getId, mappedProductIds)
+                                .eq(Product::getStatus, "上架")
+                                .gt(Product::getStock, 0)
+                );
+
+                // 构建 productId → mapping 得分
+                Map<Long, Integer> mappingScores = mappings.stream()
+                        .collect(Collectors.toMap(
+                                ProductDimensionMapping::getProductId,
+                                ProductDimensionMapping::getMatchScore,
+                                (a, b) -> a
+                        ));
+                Map<Long, String> mappingReasons = mappings.stream()
+                        .collect(Collectors.toMap(
+                                ProductDimensionMapping::getProductId,
+                                m -> m.getMatchReason() != null ? m.getMatchReason() : "",
+                                (a, b) -> a
+                        ));
+
+                for (Product p : mapped) {
+                    Integer baseScore = mappingScores.getOrDefault(p.getId(), 100);
+                    String reason = mappingReasons.getOrDefault(p.getId(), "");
+                    double finalScore = applyDimensionBoost(baseScore, dimensionCode, memberScores);
+                    candidates.add(p);
+                }
+            }
+        }
+
+        // fallback: 从 Product.domain 匹配
+        if (candidates.isEmpty()) {
+            List<Product> domainMatched = productMapper.selectList(
+                    new LambdaQueryWrapper<Product>()
+                            .eq(Product::getDomain, dimensionCode)
+                            .eq(Product::getStatus, "上架")
+                            .gt(Product::getStock, 0)
+                            .notIn(purchasedIds.isEmpty(), Product::getId, purchasedIds)
+            );
+            candidates.addAll(domainMatched);
+        }
+
+        // 4. 排序并返回
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (Product p : candidates) {
+            if (result.size() >= limit) break;
+
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("id", p.getId());
+            item.put("name", p.getName());
+            item.put("coverImage", p.getCoverImage());
+            item.put("price", p.getPrice());
+            item.put("memberPrice", p.getMemberPrice());
+            item.put("productType", p.getProductType());
+            item.put("url", "/pages/discover/product-detail/product-detail?id=" + p.getId());
+
+            // 推荐理由
+            String reason = buildRecommendationReason(p, dimensionCode, memberScores);
+            item.put("reason", reason);
+
+            // 匹配分
+            double matchScore = calculateMatchScore(p, dimensionCode, memberScores);
+            item.put("matchScore", matchScore);
+
+            result.add(item);
+        }
+
+        return result;
+    }
+
+    /**
+     * 报告关联推荐(健康报告/认知测评触发)
+     */
+    public List<Map<String, Object>> getReportRelatedProducts(
+            String reportType, Object analysis, Long userId, int limit) {
+
+        List<String> dimensionCodes = new ArrayList<>();
+        if ("health_report".equals(reportType)) {
+            // 从健康分析结果提取维度需求
+            dimensionCodes = extractDimensionNeedsFromHealth(analysis);
+        } else if ("cognitive_assessment".equals(reportType)) {
+            // 从认知测评结果提取弱维度
+            dimensionCodes = extractDimensionNeedsFromCognitive(analysis);
+        }
+
+        if (dimensionCodes.isEmpty()) {
+            dimensionCodes.add("body");
+        }
+
+        // 去重
+        List<String> uniqueDims = dimensionCodes.stream().distinct().collect(Collectors.toList());
+
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (String dim : uniqueDims) {
+            if (result.size() >= limit) break;
+            List<Map<String, Object>> prods = getDimensionRecommendations(dim, null, null, null, limit - result.size());
+            for (Map<String, Object> p : prods) {
+                p.put("triggerDimension", dim);
+                result.add(p);
+            }
+        }
+
+        return result;
+    }
+
+    /**
+     * 记录推荐日志
+     */
+    public void logRecommendation(Long userId, Long productId, String scene, String reason, Double matchScore) {
+        try {
+            ProductRecommendationLog logEntity = new ProductRecommendationLog();
+            logEntity.setUserId(userId);
+            logEntity.setProductId(productId);
+            logEntity.setScene(scene);
+            logEntity.setReason(reason);
+            logEntity.setMatchScore(matchScore);
+            logEntity.setCreatedAt(new Date());
+            recommendationLogMapper.insert(logEntity);
+        } catch (Exception e) {
+            log.warn("记录推荐日志失败: userId={}, productId={}, error={}", userId, productId, e.getMessage());
+        }
+    }
+
+    // ─── Private helpers ───
+
+    /**
+     * 获取家庭成员在90天内购买过的商品ID
+     */
+    private Set<Long> getPurchasedProductIds(Long familyId, int daysAgo) {
+        Set<Long> ids = new HashSet<>();
+        if (familyId == null) return ids;
+
+        Calendar cal = Calendar.getInstance();
+        cal.add(Calendar.DAY_OF_YEAR, -daysAgo);
+        Date cutoff = cal.getTime();
+
+        List<ProductOrder> orders = orderMapper.selectList(
+                new LambdaQueryWrapper<ProductOrder>()
+                        .eq(ProductOrder::getFamilyId, familyId)
+                        .eq(ProductOrder::getStatus, "已支付")
+                        .gt(ProductOrder::getPaidAt, cutoff)
+        );
+
+        for (ProductOrder order : orders) {
+            if (order.getProductId() != null) {
+                ids.add(order.getProductId());
+            }
+        }
+        return ids;
+    }
+
+    /**
+     * 获取成员维度得分
+     */
+    private Map<String, Integer> getMemberDimensionScores(Long familyId, Long memberId) {
+        Map<String, Integer> scores = new HashMap<>();
+
+        LambdaQueryWrapper<FiveDimensionScore> qw = new LambdaQueryWrapper<>();
+        qw.eq(familyId != null, FiveDimensionScore::getFamilyId, familyId);
+        qw.eq(memberId != null, FiveDimensionScore::getMemberId, memberId);
+        qw.orderByDesc(FiveDimensionScore::getAssessedAt);
+
+        List<FiveDimensionScore> records = fiveDimensionScoreMapper.selectList(qw);
+
+        // 取每个维度的最新得分
+        Set<String> seen = new HashSet<>();
+        for (FiveDimensionScore s : records) {
+            if (s.getDimensionCode() != null && !seen.contains(s.getDimensionCode())) {
+                seen.add(s.getDimensionCode());
+                scores.put(s.getDimensionCode(), s.getScore() != null ? s.getScore() : 0);
+            }
+        }
+        return scores;
+    }
+
+    /**
+     * 对低得分维度加权 boost
+     */
+    private double applyDimensionBoost(int baseScore, String dimensionCode, Map<String, Integer> memberScores) {
+        Integer score = memberScores.get(dimensionCode);
+        if (score == null || score >= 70) {
+            return baseScore;
+        } else if (score >= 50) {
+            return baseScore * 1.1;
+        } else {
+            return baseScore * 1.2;
+        }
+    }
+
+    /**
+     * 计算综合匹配分
+     */
+    private double calculateMatchScore(Product product, String dimensionCode, Map<String, Integer> memberScores) {
+        int base = 70;
+        Integer myScore = memberScores.get(dimensionCode);
+        if (myScore != null) {
+            if (myScore < 50) base = 90;
+            else if (myScore < 70) base = 80;
+            else base = 70;
+        }
+        return base;
+    }
+
+    /**
+     * 构建推荐理由
+     */
+    private String buildRecommendationReason(Product product, String dimensionCode, Map<String, Integer> memberScores) {
+        Integer score = memberScores.get(dimensionCode);
+        if (score == null) {
+            return "根据您的维度匹配为您推荐";
+        }
+        if (score < 50) {
+            return "该维度得分偏低,重点推荐";
+        } else if (score < 70) {
+            return "该维度有提升空间,推荐关注";
+        }
+        return "丰富您的维度生活";
+    }
+
+    /**
+     * 从健康分析结果提取维度需求
+     */
+    @SuppressWarnings("unchecked")
+    private List<String> extractDimensionNeedsFromHealth(Object analysis) {
+        List<String> dims = new ArrayList<>();
+        if (analysis == null) return dims;
+
+        try {
+            if (analysis instanceof Map) {
+                Map<String, Object> map = (Map<String, Object>) analysis;
+                // 通过营养评分、菌群评分等推断维度
+                Object nutritionScore = map.get("nutritionScore");
+                Object gutScore = map.get("gutHealthScore");
+                Object overallScore = map.get("overallScore");
+
+                if (nutritionScore instanceof Number && ((Number) nutritionScore).intValue() < 60) {
+                    dims.add("body");
+                }
+                if (gutScore instanceof Number && ((Number) gutScore).intValue() < 60) {
+                    dims.add("body");
+                }
+                if (overallScore instanceof Number && ((Number) overallScore).intValue() < 60) {
+                    dims.add("body");
+                    dims.add("mind");
+                }
+            }
+        } catch (Exception e) {
+            log.warn("解析健康分析结果维度失败: {}", e.getMessage());
+        }
+        return dims;
+    }
+
+    /**
+     * 从认知测评结果提取弱维度
+     */
+    @SuppressWarnings("unchecked")
+    private List<String> extractDimensionNeedsFromCognitive(Object analysis) {
+        List<String> dims = new ArrayList<>();
+        if (analysis == null) return dims;
+
+        try {
+            if (analysis instanceof List) {
+                List<String> weakDims = (List<String>) analysis;
+                for (String dim : weakDims) {
+                    if ("focusScore".equals(dim) || "processingSpeedScore".equals(dim)) {
+                        dims.add("wisdom");
+                    } else if ("attentionScore".equals(dim)) {
+                        dims.add("mind");
+                    } else if ("memoryScore".equals(dim)) {
+                        dims.add("wisdom");
+                    } else {
+                        dims.add(dim);
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.warn("解析认知测评结果维度失败: {}", e.getMessage());
+        }
+        return dims;
+    }
+}

+ 190 - 0
cfc-frontend/components/HealthCheckinCard.vue

@@ -0,0 +1,190 @@
+<template>
+  <view class="health-checkin-card">
+    <view class="hcc-header">
+      <text class="hcc-title">🏃 健康打卡</text>
+      <text class="hcc-more" v-if="streak > 0">连续 {{ streak }} 天</text>
+    </view>
+    <view class="hcc-body">
+      <template v-if="todayCheckedIn">
+        <view class="hcc-done">
+          <text class="hcc-done-icon">✓</text>
+          <text class="hcc-done-text">今日已打卡</text>
+        </view>
+        <text class="hcc-energy">+{{ earnedEnergy }} 身能量</text>
+      </template>
+      <template v-else>
+        <view class="hcc-undo" @click="doCheckin">
+          <text class="hcc-btn">立即打卡</text>
+          <text class="hcc-btn-tip">完成健康打卡获得身能量</text>
+        </view>
+      </template>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  props: {
+    childId: { type: Number, default: null }
+  },
+  data: function() {
+    return {
+      checkins: [],
+      loading: false
+    }
+  },
+  computed: {
+    todayCheckedIn: function() {
+      if (!this.checkins || this.checkins.length === 0) return false
+      var today = this.getDateString(new Date())
+      return this.checkins.some(function(c) {
+        if (!c.checkinDate) return false
+        var d = c.checkinDate
+        if (typeof d === 'string') {
+          return d.slice(0, 10) === today
+        }
+        return this.getDateString(new Date(d)).slice(0, 10) === today
+      }.bind(this))
+    },
+    streak: function() {
+      if (!this.checkins || this.checkins.length === 0) return 0
+      var sorted = this.checkins.slice().sort(function(a, b) {
+        if (!a.checkinDate || !b.checkinDate) return 0
+        var da = new Date(a.checkinDate)
+        var db = new Date(b.checkinDate)
+        return db - da
+      })
+      var streak = 0
+      var today = new Date()
+      today.setHours(0, 0, 0, 0)
+      var checkDate = new Date(today)
+      for (var i = 0; i < sorted.length; i++) {
+        var cDate = new Date(sorted[i].checkinDate)
+        cDate.setHours(0, 0, 0, 0)
+        var diff = Math.round((checkDate - cDate) / (1000 * 60 * 60 * 24))
+        if (diff === 0 || diff === 1) {
+          if (diff === 1) streak++
+          if (diff === 0 && streak === 0) streak = 1
+          checkDate = cDate
+          checkDate.setDate(checkDate.getDate() - 1)
+        } else {
+          break
+        }
+      }
+      return streak
+    },
+    earnedEnergy: function() {
+      if (!this.checkins || this.checkins.length === 0) return 5
+      var last = this.checkins[0]
+      return last && last.earnedEnergy ? last.earnedEnergy : 5
+    }
+  },
+  created: function() {
+    this.loadCheckins()
+  },
+  methods: {
+    getDateString: function(date) {
+      var y = date.getFullYear()
+      var m = ('' + (date.getMonth() + 1)).padStart(2, '0')
+      var d = ('' + date.getDate()).padStart(2, '0')
+      return y + '-' + m + '-' + d
+    },
+    loadCheckins: function() {
+      var self = this
+      if (!this.childId) return
+      import('@/utils/api.js').then(function(api) {
+        var now = new Date()
+        var yearMonth = now.getFullYear() + '-' + ('' + (now.getMonth() + 1)).padStart(2, '0')
+        api.healthCheckinList({
+          childId: self.childId,
+          yearMonth: yearMonth
+        }).then(function(res) {
+          if (res.data) {
+            self.checkins = res.data
+          }
+        }).catch(function() {
+          self.checkins = []
+        })
+      })
+    },
+    doCheckin: function() {
+      var self = this
+      if (!this.childId) {
+        uni.showToast({ title: '请先选择孩子', icon: 'none' })
+        return
+      }
+      if (this.loading) return
+      this.loading = true
+      import('@/utils/api.js').then(function(api) {
+        api.healthCheckinCreate({
+          childId: self.childId,
+          checkinDate: self.getDateString(new Date()),
+          behaviors: [],
+          mood: 'happy'
+        }).then(function(res) {
+          self.loading = false
+          uni.showToast({ title: '打卡成功!+' + (res.data && res.data.earnedEnergy || 5) + '身能量', icon: 'none' })
+          self.checkins.unshift(res.data)
+        }).catch(function() {
+          self.loading = false
+        })
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.health-checkin-card {
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 24rpx;
+  margin: 20rpx 20rpx 0 20rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+}
+.hcc-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16rpx;
+}
+.hcc-title { font-size: 28rpx; font-weight: 700; color: #333; }
+.hcc-more { font-size: 22rpx; color: #F97316; }
+.hcc-body { display: flex; align-items: center; justify-content: center; }
+.hcc-done {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+  padding: 20rpx 0;
+}
+.hcc-done-icon {
+  width: 56rpx;
+  height: 56rpx;
+  border-radius: 28rpx;
+  background: linear-gradient(135deg, #10B981, #34D399);
+  color: #fff;
+  font-size: 28rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.hcc-done-text { font-size: 30rpx; color: #10B981; font-weight: 600; }
+.hcc-energy { font-size: 22rpx; color: #999; margin-left: 16rpx; }
+.hcc-undo {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 12rpx 0;
+  width: 100%;
+}
+.hcc-btn {
+  background: linear-gradient(135deg, #FF8C42, #FFB074);
+  color: #fff;
+  font-size: 28rpx;
+  font-weight: 600;
+  padding: 16rpx 60rpx;
+  border-radius: 40rpx;
+}
+.hcc-btn:active { opacity: 0.8; }
+.hcc-btn-tip { font-size: 22rpx; color: #999; margin-top: 8rpx; }
+</style>

+ 5 - 5
cfc-frontend/pages/action/index.vue

@@ -67,12 +67,12 @@
       @activityClick="goActivityDetail"
       @moreActivities="goMoreActivities" />
 
-    <DimensionProducts
+<!-- 商品推荐 -->
+    <DimensionProductList
+      v-if="isLoggedIn"
       dimensionCode="action"
-      :products="dimensionProducts"
-      :isLoggedIn="isLoggedIn"
-      @productClick="goProductDetail"
-      @moreProducts="goMoreProducts" />
+      :familyId="activeChildId"
+      title="为你推荐" />
 
     <!-- 推荐阅读 -->
     <ActionArticleRecommend

+ 12 - 6
cfc-frontend/pages/body/index.vue

@@ -47,6 +47,11 @@
       @childChanged="onChildChanged"
       @scrollTo="scrollToSection" />
 
+    <!-- 健康打卡卡片 -->
+    <HealthCheckinCard
+      v-if="isLoggedIn && activeChildId"
+      :childId="activeChildId" />
+
       <!-- 雷达图 -->
       <view class="section" v-if="dimensionData">
         <view class="section-header" v-if="dimensionData.isAverage">
@@ -164,12 +169,12 @@
       @activityClick="goActivityDetail"
       @moreActivities="goMoreActivities" />
 
-    <!-- 维度商品 -->
-    <DimensionProducts
+    <!-- 商品推荐 -->
+    <DimensionProductList
+      v-if="isLoggedIn"
       dimensionCode="body"
-      :products="dimensionProducts"
-      @productClick="goProductDetail"
-      @moreProducts="goMoreProducts" />
+      :familyId="activeChildId"
+      title="为你推荐" />
 
     <!-- 推荐阅读 -->
     <DimensionArticles
@@ -216,6 +221,7 @@ import DimensionArticles from '../../components/DimensionArticles.vue'
 import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
 import RadarChart from '../../components/RadarChart.vue'
 import HealthDimensionsSection from '../../components/HealthDimensionsSection.vue'
+import HealthCheckinCard from '../../components/HealthCheckinCard.vue'
 import AIFloatingAvatar from '../../components/AIFloatingAvatar.vue'
 
 import { getVisibleSections, getEnergyOverview, getChildren, getTodayTasksByCategory, getActivityList, getProductsByDomain, getFamilyEnergySandbox, getVisibleFamilyMembers, getDimensionOverview, getFeaturedArticles } from '../../utils/api.js'
@@ -249,7 +255,7 @@ const healthRequest = function(url, data) {
 }
 
 export default {
-  components: { TabTransition, PageBanner, LoginGuideCard, FamilyEnergyBar, UserQuickEntry, DimensionTasks, DimensionActivities, DimensionProducts, DimensionArticles, FamilyRelationGraph, RadarChart, HealthDimensionsSection, AIFloatingAvatar },
+  components: { TabTransition, PageBanner, LoginGuideCard, FamilyEnergyBar, UserQuickEntry, DimensionTasks, DimensionActivities, DimensionProducts, DimensionArticles, FamilyRelationGraph, RadarChart, HealthDimensionsSection, HealthCheckinCard, AIFloatingAvatar },
   data() {
     return {
       isLoggedIn: false,

+ 6 - 5
cfc-frontend/pages/mind/index.vue

@@ -246,11 +246,12 @@
       @activityClick="goActivityDetail"
       @moreActivities="goMoreActivities" />
 
-    <DimensionProducts
-      :dimensionCode="currentDimensionCode"
-      :products="dimensionProducts"
-      @productClick="goProductDetail"
-      @moreProducts="goMoreProducts" />
+    <!-- 商品推荐 -->
+    <DimensionProductList
+      v-if="isLoggedIn"
+      dimensionCode="mind"
+      :familyId="activeChildId"
+      title="为你推荐" />
 
     <!-- ===== Phase 1.2: 心理告警横幅 ===== -->
     <PsychCrisisBanner v-if="isLoggedIn" :childId="currentChildId" source="screening" />

+ 7 - 0
cfc-frontend/pages/wealth/index.vue

@@ -115,6 +115,13 @@
         </view>
       </view>
 
+      <!-- 商品推荐 -->
+      <DimensionProductList
+        v-if="isLoggedIn"
+        dimensionCode="wealth"
+        :familyId="activeChildId"
+        title="为你推荐" />
+
       <!-- 底部菜单(精简) -->
       <view class="menu-list">
         <view class="menu-item" v-if="role === 'parent'" @click="goToProfile">

+ 53 - 8
cfc-frontend/pages/wisdom/index.vue

@@ -40,9 +40,24 @@
         fillColor="#6366F1"
         gridColor="#E0E7FF"
         labelColor="#3730A3" />
-    </view>
+        </view>
+
+        <!-- 6. 本月阅读卡片(登录后) -->
+        <view class="section" v-if="isLoggedIn">
+          <view class="reading-stats-card" @click="goReadingDetail">
+            <view class="rsc-header">
+              <text class="rsc-title">📚 本月阅读</text>
+            </view>
+            <view class="rsc-body">
+              <text class="rsc-stat-value" v-if="readingStats && readingStats.totalDurationSeconds !== undefined">
+                {{ (readingStats.totalDurationSeconds / 3600).toFixed(1) }} 小时
+              </text>
+              <text class="rsc-stat-value" v-else-if="readingStats">暂无阅读记录</text>
+            </view>
+          </view>
+        </view>
 
-    <!-- 5. 家庭成员关系图谱(登录后) -->
+        <!-- 5. 家庭成员关系图谱(登录后) -->
     <FamilyRelationGraph
       v-if="isLoggedIn && familyMembersVisible.length > 0"
       dimensionCode="wisdom"
@@ -82,12 +97,12 @@
       @activityClick="goActivityDetail"
       @moreActivities="goMoreActivities" />
 
-    <!-- 10. 维度商品 -->
-    <DimensionProducts
+    <!-- 10. 商品推荐 -->
+    <DimensionProductList
+      v-if="isLoggedIn"
       dimensionCode="wisdom"
-      :products="dimensionProducts"
-      @productClick="goProductDetail"
-      @moreProducts="goMoreProducts" />
+      :familyId="activeChildId"
+      title="为你推荐" />
 
     <!-- 11. 推荐阅读 -->
     <DimensionArticles
@@ -153,7 +168,7 @@ import DimensionArticles from '../../components/DimensionArticles.vue'
 import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
 import AIFloatingAvatar from '../../components/AIFloatingAvatar.vue'
 
-import { getVisibleSections, getChildren, getTodayTasksByCategory, getActivityList, getProductsByDomain, getFamilyEnergySandbox, getAssessmentLatestResult, getFeaturedArticles } from '../../utils/api.js'
+import { getVisibleSections, getChildren, getTodayTasksByCategory, getActivityList, getProductsByDomain, getFamilyEnergySandbox, getAssessmentLatestResult, getFeaturedArticles, getReadingStats } from '../../utils/api.js'
 
 export default {
   components: { TabTransition, PageBanner, LoginGuideCard, RadarChart, FamilyEnergyBar, DimensionTasks, DimensionActivities, DimensionProducts, DimensionArticles, FamilyRelationGraph, AIFloatingAvatar },
@@ -171,6 +186,7 @@ export default {
       dualDimension: null,
       visibleSections: [],
       cognitiveReport: null,
+      readingStats: null,
       dimensionTasks: [],
       dimensionActivities: [],
       dimensionProducts: [],
@@ -252,6 +268,7 @@ export default {
       this.loadChildren()
       this.loadFamilyMembersVisible()
       this.loadCognitiveReport()
+      this.loadReadingStats()
     }
     this.loadDimensionActivities()
     this.loadDimensionProducts()
@@ -328,6 +345,21 @@ export default {
         }
       }).catch(function(e) {})
     },
+    loadReadingStats: function() {
+      var self = this
+      var childId = this.activeChildId
+      if (!childId) return
+      getReadingStats(childId).then(function(res) {
+        if (res.code === 200 && res.data) {
+          self.readingStats = res.data
+        }
+      }).catch(function() {
+        self.readingStats = null
+      })
+    },
+    goReadingDetail: function() {
+      uni.navigateTo({ url: '/pages/mind-detail/article-records' })
+    },
     onChildChanged: function(childId) {
       this.activeChildId = childId
       uni.setStorageSync('currentChildId', childId)
@@ -636,6 +668,19 @@ export default {
   font-weight: 500;
 }
 
+/* ===== 本月阅读卡片 ===== */
+.reading-stats-card {
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+}
+.reading-stats-card:active { opacity: 0.8; }
+.rsc-header { margin-bottom: 12rpx; }
+.rsc-title { font-size: 28rpx; font-weight: 700; color: #333; }
+.rsc-body { display: flex; align-items: center; justify-content: center; padding: 8rpx 0; }
+.rsc-stat-value { font-size: 32rpx; font-weight: 700; color: #6366F1; }
+
 /* ===== 底部占位 ===== */
 .bottom-spacer {
   height: 120rpx;

+ 114 - 7
docs/superpowers/specs/2026-06-05-five-dimension-energy-design.md

@@ -1,14 +1,17 @@
 # 五维能量系统设计
 
-**日期:** 2026-06-05
-**状态:** 已确认,待实施
+**日期:** 2026-06-05(最后更新:2026-07-13)
+**状态:** 已实施(核心) + 富维度子维度已落地
 **分支:** cfclub
+**版本:** v2.1
 
 ## 概述
 
 本项目服务于身、心、智、行、富五个维度,服务内容包括活动、课程、商品、任务、咨询五大类。每个服务实例可影响多个维度,能量值按比例分配。能量系统为全新独立系统,与现有积分系统并行但解耦。
 
 > **哲学基础:** 五维之间的相生相克关系详见 [`2026-06-08-five-dimension-wuxing-philosophy.md`](./2026-06-08-five-dimension-wuxing-philosophy.md)。所有维度顺序、健康指数算法、内容推荐策略均以此为依据。
+>
+> **v2.1 更新(2026-07-13):** 富维度子维度拆分 + 身克富杠杆落地 + `POST /api/energy/wealth-detail` 富维度详情接口。详见 §6。
 
 ### 关键决策
 
@@ -16,8 +19,8 @@
 |--------|------|
 | 维度比例配置方式 | 按服务实例配置(每个具体任务/课程/商品创建时可指定维度比例) |
 | 能量值性质 | 可消耗值(类似积分,可增可减) |
-| 健康指数算法 | 待定,先搭框架返回0占位 |
-| 与积分系统关系 | 全新独立系统,不复用积分 |
+| 健康指数算法 | 已实施:各维度子分加权 + 五行相生增益 + 相克校准(v2.0),富维度子维度已落地(v2.1) |
+| 与积分系统关系 | 全新独立系统,不复用积分,但家长富-金钱/收入复用积分总量做参照 |
 | 架构方案 | 方案B:维度定义表+流水表+余额表 |
 
 ## 1. 数据模型
@@ -331,11 +334,115 @@ getEnergyLogs(childId, dimensionCode, page, size)  // POST /api/energy/logs
 3. 无 → 默认分配到"行"维度100%
 ```
 
-## 5. 待定事项
+## 6. 富维度算法(v2.1)
+
+**日期:** 2026-07-12 实施,2026-07-13 文档化
+**实施提交:** `3a302b3` — feat: 电商体系升级 — 五维能量富维度详情
+**关联计划:** [`2026-07-12-wealth-dimension-redesign.md`](../plans/2026-07-12-wealth-dimension-redesign.md)
+
+### 6.1 背景
+
+成人和孩子的"富"原为单一值(成人=积分总量/500,孩子=积分获取效率),无差异化"富"定义,也无身克富的系统体现。v2.1 将"富"拆为 3 个子维度,并在 `calcParentEnergy`/`calcChildEnergy` 末尾调用 `applyBodyWealthRestraint` 施加身克富杠杆。
+
+### 6.2 孩子子维度(`calcChildWealth`)
+
+| 子维度 | 符号 | 数据源 | 权重 |
+|:------|:----:|--------|:----:|
+| 学业成绩 | `wealthEducation` | DanAssessmentResult 最近3次综合分均值 + 进步趋势修正(最近一次 vs 前面平均的 10% delta) | 30% |
+| 社交筹码 | `wealthSocial` | 活动参与次数(活动系统)— **第一阶段返回 0** | 20% |
+| 规则博弈(积分效率) | `wealthPoints` | 一年内已 earned 积分 / 一年内可获取总积分 × 100 | 50% |
+
+评分公式:`wealth = edu*0.3 + social*0.2 + points*0.5`
+
+实现位置:`EnergyService.calcChildWealthEducation/Social/Points`(540-614 行)。
+
+### 6.3 成人子维度(`calcParentWealth`)
+
+| 子维度 | 符号 | 数据源 | 权重 |
+|:------|:----:|--------|:----:|
+| 金钱/收入 | `wealthIncome` | `User.totalPoints / 500 × 100`,clamp[0,100] | 50% |
+| 社会成就 | `wealthAchievement` | `User.achievements` 扩展字段 JSON — **第一阶段返回 0** | 30% |
+| 资源网络 | `wealthNetwork` | 推荐团队人数 — **第一阶段返回 0** | 20% |
+
+评分公式:`wealth = income*0.5 + achievement*0.3 + network*0.2`
+
+实现位置:`EnergyService.calcParentWealthIncome/Achievement/Network`(253-280 行)。
+
+### 6.4 身克富杠杆(`applyBodyWealthRestraint`)
+
+在 `calcParentEnergy`/`calcChildEnergy` 中,`wealthScore` 计算完成后、综合分 `overallScore` 之前调用:
+
+```java
+applyBodyWealthRestraint(bodyScore, dto.getWealthScore(), dto);
+```
+
+| 条件 | 状态 (`bodyWealthStatus`) | 行为 | 用户文案 (`bodyWealthMessage`) |
+|------|------------------------|------|----------------------------|
+| 身 < 40 | `penalty` | wealth *= (0.5 + 0.5 × 身/100),即最低打对折 | "健康正在稀释财富能量,建议优先关注身体健康" |
+| 身 < 50 且 富 > 身+20 | `overdraw` | 不下调 wealthScore,仅透支预警 | "财富能量超出身体承受范围,注意劳逸结合" |
+| 否则 | `normal` | 不调整;身≥70 给正向文案 | 身≥70:"身体底盘稳固,财富能量充沛" / 其他:空串 |
+
+实现位置:`EnergyService.applyBodyWealthRestraint`(622-645 行)。
+
+> **算法哲学依据:** 见 [五维五行哲学体系 §2 相克关系](./2026-06-08-five-dimension-wuxing-philosophy.md#2-相克关系v20-更新)。身克富 = "健康是创富的底盘,身体不好财富没有意义"。v2.0 将"克"从惩罚层面升级为信号/平衡层面:penalty 是校准触发,overdraw 是引导触发,normal 是正向标记。
+
+### 6.5 DTO 扩展
+
+`MemberEnergyDTO` v2.1 新增字段:
+
+```java
+// 富的子维度(parent)
+private Integer wealthIncome;       // 金钱/收入
+private Integer wealthAchievement;  // 社会成就
+private Integer wealthNetwork;      // 资源网络
+
+// 富的子维度(child)
+private Integer wealthEducation;    // 学业成绩
+private Integer wealthSocial;       // 社交筹码
+private Integer wealthPoints;       // 规则博弈(积分效率)
+
+// 身克富标记
+private String bodyWealthStatus;    // normal / overdraw / penalty
+private String bodyWealthMessage;   // 用户可见的消息
+```
+
+> 注:`wealthScore`(综合富分)已在 v1 定义,v2.1 由 `calcChildWealth`/`calcParentWealth` 赋值,并可被 `applyBodyWealthRestraint` 在 penalty 状态下下调。
+
+### 6.6 富维度详情接口
+
+```
+POST /api/energy/wealth-detail
+Body: { "memberId": <Long>, "memberType": "child"|"parent" }
+Header: Authorization: Bearer <jwt>
+```
+
+**逻辑**(`EnergyController.getWealthDetail`,221-250 行):
+1. 取当前 JWT 用户,校验已加入家庭
+2. 调用 `energyService.calculateFamilyEnergy(familyId)` 算家庭整个 sandbox
+3. 按 `memberId + memberType` 过滤出该成员的 `MemberEnergyDTO`
+4. 返回含全部子维度字段 + 身克富标记
+
+**前端调用:** `utils/api.js` 新增 `getWealthDetail(memberId, memberType)`,`pages/wealth/index.vue` 用其替代原 `getEnergyOverview` 单独加载富维度数据。
+
+### 6.7 不做的事
+
+- 不新建 schema 表、不改 schema.sql、不改 DatabaseInitializer(富子维度无需存储,全部由现有数据源即时计算)
+- 不改 `EnergySandboxDTO`(家庭聚合值不受影响,子维度只在单成员 DTO 上承载)
+- 不改任何 Mapper
+
+## 7. 待定事项
 
 | 事项 | 说明 | 影响 |
 |------|------|------|
-| 健康指数算法 | 各维度健康指数 + 总体健康指数的计算公式 | API返回0占位,不影响数据结构 |
 | 能量过期/衰减 | 是否需要能量值过期机制 | 暂不实现,energy_log.created_at 可支撑后续时间窗口计算 |
-| Web管理端 | 管理端维度比例配置页面 | 本次只做后端API,管理端UI后续迭代 |
+| Web管理端 | 管理端维度比例配置页面 | 后端API已就绪,管理端UI后续迭代 |
 | 能量兑换/消耗 | 扣除能量的具体业务场景 | deductEnergy 方法已预留,具体场景待定 |
+| 成人富-成就/网络数据源 | 第一阶段回 0,待 `User.achievements` 字段和推荐团队体系落地 | 子维度结构已定,仅需补充数据源 |
+| 孩子富-社交筹码数据源 | 第一阶段回 0,待活动参与系统完善 | 同上 |
+
+## 附录:文档演变
+
+| 版本 | 日期 | 变更内容 |
+|:----:|:----:|----------|
+| v1.0 | 2026-06-05 | 初稿,能量系统架构方案B(维度定义表+流水表+余额表),健康指数先返回0占位 |
+| v2.1 | 2026-07-13 | 富维度子维度拆分(孩子3+成人3)+ 身克富三态杠杆算法 + `POST /api/energy/wealth-detail` 接口 + DTO 扩展文档化 |

+ 10 - 1
docs/superpowers/specs/2026-06-08-five-dimension-wuxing-philosophy.md

@@ -36,10 +36,18 @@
 |:----:|:----:|:--------------|:--------------|
 | **身** | 主动健康——体质、精力、睡眠 | 健康报告overallScore、运动任务 | 任务完成率、健康报告 |
 | **智** | 赋能未来——认知能力、逻辑、知识 | DAN COG六子分加权 | 学习/培训任务完成率 |
-| **富** | 利他创富——金钱、成就、资源 | 积分获取效率+学业成绩+社交筹码 | 积分总量+成就/资源(待补) |
+| **富** | 利他创富——金钱、成就、资源 | 学业成绩(30%)+社交筹码(20%)+规则博弈(50%) | 金钱/收入(50%)+社会成就(30%)+资源网络(20%) |
 | **行** | 人际和谐——陪伴在场、关系密度、利他行为 | 家庭共同任务+活动参与+分享行为 | 家人互动任务+社交活跃+推荐行为 |
 | **心** | 大爱相随——情绪稳定、理解包容、传承传递 | 情绪打卡+DAN EMI+家庭天盘+传承 | (暂无数据源,回0) |
 
+> **富子维度(v2.1 落地)** — 详见 [五维能量系统设计 §6 富维度算法](./2026-06-05-five-dimension-energy-design.md#6-富维度算法v21)
+>
+> 孩子:学业成绩 = DAN测评最近3次综合分+进步趋势;社交筹码 = 活动参与次数(第一阶段回0);规则博弈 = 一年内积分获取/可获取总量
+>
+> 成人:金钱/收入 = 积分总量/500(满分100);社会成就 = `User.achievements` 扩展字段 JSON(第一阶段回0);资源网络 = 推荐团队人数(第一阶段回0)
+>
+> 身克富杠杆(applied after `calcChildWealth`/`calcParentWealth`):`身<40` → penalty(wealth *= 0.5 + 0.5×身/100,文案"健康正在稀释财富能量");`身<50 && 富>身+20` → overdraw(透支预警,不下调,文案"财富能量超出身体承受范围");否则 → normal(身≥70正向文案"身体底盘稳固")。DTO 字段:`bodyWealthStatus` ∈ {normal, overdraw, penalty} + `bodyWealthMessage`。
+
 > 详细子维度定义和权重见 [五相克完整设计](./2026-07-12-wuxing-ke-full-design.md#维度体系总览)
 
 ### 相生链
@@ -323,3 +331,4 @@ List<BoostDetail> boostDetails;
 |:----:|:----:|----------|
 | v1.0 | 2026-06-08 | 初稿,定义五维↔五行对应、相生相克原则 |
 | v2.0 | 2026-07-12 | ①克从"惩罚"升级为"信号/平衡";②维度定义细化(行→人际和谐、心三层拆分)③完整生克算法;④扩展DTO;⑤跨文档引用 |
+| v2.1 | 2026-07-13 | 富子维度表补全(孩子3+成人3,去掉"待补"占位),补全身克富杠杆三态公式与 DTO 字段说明 |