Переглянути джерело

Merge branch 'cfclub' of http://git.iwintrue.com/liaoxg/cfc into cfclub

# Conflicts:
#	cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
#	cfc-frontend/pages/action/index.vue
#	cfc-frontend/pages/mind/index.vue
#	cfc-frontend/pages/wisdom/index.vue
#	cfc-frontend/utils/api.js
User 2 місяців тому
батько
коміт
eddeefd646
100 змінених файлів з 6383 додано та 927 видалено
  1. 8 2
      cfc-backend/config/application-prod.yml
  2. 5 2
      cfc-backend/config/application.yml
  3. 121 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/CircleController.java
  4. 33 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/EnergyController.java
  5. 2 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/UserController.java
  6. 2 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCommissionController.java
  7. 4 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminProductOrderController.java
  8. 78 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminProductPpointController.java
  9. 113 2
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/KnowledgeBaseController.java
  10. 4 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java
  11. 3 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/cart/CartController.java
  12. 90 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProductController.java
  13. 70 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/recommendation/ProductRecommendationController.java
  14. 61 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/recommendation/RepurchaseReminderController.java
  15. 4 2
      cfc-backend/src/main/java/com/etotem/cfc/controller/shop/AfterSalesController.java
  16. 10 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/stats/StatsController.java
  17. 3 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/tianpan/TianpanController.java
  18. 12 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/AddFamilyMemberDTO.java
  19. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/CartItemDTO.java
  20. 35 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/MemberEnergyDTO.java
  21. 2 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/OrderItemVO.java
  22. 16 1
      cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java
  23. 2 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/RecommendationQuery.java
  24. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/UpdateUserDTO.java
  25. 2 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/Cart.java
  26. 8 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/DanKnowledgeBase.java
  27. 13 1
      cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyMember.java
  28. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/entity/PendingRefund.java
  29. 37 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ProductDimensionMapping.java
  30. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/entity/ProductOrder.java
  31. 32 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ProductRecommendationLog.java
  32. 36 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/RepurchaseReminderConfig.java
  33. 34 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/RepurchaseReminderRecord.java
  34. 34 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/SocialCircle.java
  35. 28 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/SocialCircleMember.java
  36. 2 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/User.java
  37. 31 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/UserProfileHistory.java
  38. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductDimensionMappingMapper.java
  39. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductRecommendationLogMapper.java
  40. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/RepurchaseReminderConfigMapper.java
  41. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/RepurchaseReminderRecordMapper.java
  42. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/SocialCircleMapper.java
  43. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/SocialCircleMemberMapper.java
  44. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/UserProfileHistoryMapper.java
  45. 8 6
      cfc-backend/src/main/java/com/etotem/cfc/service/AfterSalesService.java
  46. 53 0
      cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentService.java
  47. 12 3
      cfc-backend/src/main/java/com/etotem/cfc/service/CartService.java
  48. 518 0
      cfc-backend/src/main/java/com/etotem/cfc/service/CircleMatchService.java
  49. 182 0
      cfc-backend/src/main/java/com/etotem/cfc/service/CircleService.java
  50. 28 0
      cfc-backend/src/main/java/com/etotem/cfc/service/DanKnowledgeBaseService.java
  51. 406 12
      cfc-backend/src/main/java/com/etotem/cfc/service/EnergyService.java
  52. 113 4
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyContextService.java
  53. 4 0
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java
  54. 63 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthAnalysisService.java
  55. 23 1
      cfc-backend/src/main/java/com/etotem/cfc/service/PendingRefundService.java
  56. 57 0
      cfc-backend/src/main/java/com/etotem/cfc/service/PpointService.java
  57. 24 3
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java
  58. 356 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductRecommendationService.java
  59. 5 1
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java
  60. 176 0
      cfc-backend/src/main/java/com/etotem/cfc/service/RepurchaseReminderService.java
  61. 37 0
      cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java
  62. 89 0
      cfc-backend/src/main/resources/schema.sql
  63. 222 0
      cfc-frontend/components/ActionArticleRecommend.vue
  64. 140 0
      cfc-frontend/components/CircleCard.vue
  65. 210 0
      cfc-frontend/components/CircleDetail.vue
  66. 195 0
      cfc-frontend/components/DimensionProductList.vue
  67. 190 0
      cfc-frontend/components/HealthCheckinCard.vue
  68. 193 0
      cfc-frontend/components/PearlDiagram.vue
  69. 120 0
      cfc-frontend/components/RepurchaseReminder.vue
  70. 0 92
      cfc-frontend/components/UserQuickEntry.vue
  71. 13 7
      cfc-frontend/pages.json
  72. 130 79
      cfc-frontend/pages/body/index.vue
  73. 177 10
      cfc-frontend/pages/discover/product-detail/product-detail.vue
  74. 108 1
      cfc-frontend/pages/family/add-member.vue
  75. 7 0
      cfc-frontend/pages/profile/components/ProfileMenu.vue
  76. 123 0
      cfc-frontend/pages/profile/index.vue
  77. 1 1
      cfc-frontend/pages/shop/after-sales/after-sales.vue
  78. 16 0
      cfc-frontend/pages/shop/cart/cart.vue
  79. 14 0
      cfc-frontend/pages/shop/checkout/checkout.vue
  80. 3 1
      cfc-frontend/pages/tianpan/daily-fortune.vue
  81. 3 1
      cfc-frontend/pages/tianpan/related-items.vue
  82. 3 1
      cfc-frontend/pages/tianpan/relation-detail.vue
  83. 20 2
      cfc-frontend/pages/user-edit/user-edit.vue
  84. 274 636
      cfc-frontend/pages/wealth/index.vue
  85. 6 6
      cfc-frontend/store/modules/tianpan.js
  86. 1 1
      cfc-web/.last_build_commit
  87. 2 2
      cfc-web/package-lock.json
  88. 1 1
      cfc-web/package.json
  89. 182 0
      cfc-web/public/CHANGELOG.md
  90. 33 0
      cfc-web/src/api/admin.js
  91. 19 0
      cfc-web/src/api/dimension.js
  92. 151 21
      cfc-web/src/components/KnowledgeDialog/index.vue
  93. 13 0
      cfc-web/src/router/index.js
  94. 1 0
      cfc-web/src/views/Layout.vue
  95. 5 11
      cfc-web/src/views/admin/ArticleManage.vue
  96. 3 1
      cfc-web/src/views/admin/Dashboard.vue
  97. 199 0
      cfc-web/src/views/admin/EcomSupplierManage.vue
  98. 26 7
      cfc-web/src/views/admin/OrderManage.vue
  99. 1 0
      cfc-web/src/views/admin/PendingRefund.vue
  100. 420 0
      docs/product-recommendation/PLAN.md

+ 8 - 2
cfc-backend/config/application-prod.yml

@@ -1,12 +1,18 @@
 # 生产环境配置
-# 使用 251 服务器 MySQL,账号 zxyj/zxyj@123
+server:
+  port: 9082
+
 spring:
   datasource:
     driver-class-name: com.mysql.cj.jdbc.Driver
-    url: jdbc:mysql://192.168.16.251:3306/zxyj?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true
+    url: jdbc:mysql://127.0.0.1:3306/zxyj?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true
     username: zxyj
     password: zxyj@123
 
+jwt:
+  secret: cfc-secret-key-2026-spring-boot-jwt-token
+  expiration: 86400000
+
 sfms:
   datasource:
     enabled: false

+ 5 - 2
cfc-backend/config/application.yml

@@ -8,12 +8,15 @@
 # 切换: 修改 spring.datasource.url / username / password 即可
 # ============================================================
 
+server:
+  port: 9082
+
 spring:
   datasource:
     driver-class-name: com.mysql.cj.jdbc.Driver
     url: jdbc:mysql://127.0.0.1:3306/zxyj?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true
-    username: root
-    password: cfc123
+    username: zxyj
+    password: zxyj@123
     hikari:
       maximum-pool-size: 20
       minimum-idle: 5

+ 121 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/CircleController.java

@@ -0,0 +1,121 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.SocialCircle;
+import com.etotem.cfc.service.CircleService;
+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.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/circle")
+public class CircleController {
+
+    @Resource
+    private CircleService circleService;
+
+    /**
+     * 获取用户已加入的圈子列表
+     */
+    @PostMapping("/my-circles")
+    public Result<List<Map<String, Object>>> myCircles(@RequestBody Map<String, Object> params) {
+        Long memberId = params.get("memberId") != null
+                ? Long.valueOf(params.get("memberId").toString()) : null;
+        String memberType = params.get("memberType") != null
+                ? params.get("memberType").toString() : "child";
+        if (memberId == null) {
+            return Result.error("memberId不能为空");
+        }
+        List<Map<String, Object>> circles = circleService.getMyCircles(memberId, memberType);
+        return Result.success(circles);
+    }
+
+    /**
+     * 发现推荐圈子
+     */
+    @PostMapping("/discover")
+    public Result<List<Map<String, Object>>> discover(@RequestBody Map<String, Object> params) {
+        Long childId = params.get("childId") != null
+                ? Long.valueOf(params.get("childId").toString()) : null;
+        if (childId == null) {
+            return Result.error("childId不能为空");
+        }
+        List<Map<String, Object>> circles = circleService.discoverCircles(childId);
+        return Result.success(circles);
+    }
+
+    /**
+     * 加入圈子
+     */
+    @PostMapping("/join")
+    public Result<String> join(@RequestBody Map<String, Object> params) {
+        Long circleId = params.get("circleId") != null
+                ? Long.valueOf(params.get("circleId").toString()) : null;
+        Long memberId = params.get("memberId") != null
+                ? Long.valueOf(params.get("memberId").toString()) : null;
+        String memberType = params.get("memberType") != null
+                ? params.get("memberType").toString() : "child";
+
+        if (circleId == null) return Result.error("circleId不能为空");
+        if (memberId == null) return Result.error("memberId不能为空");
+
+        boolean success = circleService.joinCircle(circleId, memberId, memberType);
+        return success ? Result.success("加入成功") : Result.error("加入失败");
+    }
+
+    /**
+     * 退出圈子
+     */
+    @PostMapping("/leave")
+    public Result<String> leave(@RequestBody Map<String, Object> params) {
+        Long circleId = params.get("circleId") != null
+                ? Long.valueOf(params.get("circleId").toString()) : null;
+        Long memberId = params.get("memberId") != null
+                ? Long.valueOf(params.get("memberId").toString()) : null;
+        String memberType = params.get("memberType") != null
+                ? params.get("memberType").toString() : "child";
+
+        if (circleId == null) return Result.error("circleId不能为空");
+        if (memberId == null) return Result.error("memberId不能为空");
+
+        boolean success = circleService.leaveCircle(circleId, memberId, memberType);
+        return success ? Result.success("退出成功") : Result.error("退出失败");
+    }
+
+    /**
+     * 创建圈子
+     */
+    @PostMapping("/create")
+    public Result<SocialCircle> create(@RequestBody Map<String, Object> params,
+                                        @RequestAttribute(value = "userId", required = false) Long userId) {
+        String name = params.get("name") != null ? params.get("name").toString() : null;
+        String type = params.get("type") != null ? params.get("type").toString() : null;
+        String matchSource = params.get("matchSource") != null ? params.get("matchSource").toString() : null;
+        Long sourceId = params.get("sourceId") != null
+                ? Long.valueOf(params.get("sourceId").toString()) : null;
+        Long creatorId = params.get("creatorId") != null
+                ? Long.valueOf(params.get("creatorId").toString()) : null;
+        String creatorType = params.get("creatorType") != null
+                ? params.get("creatorType").toString() : "child";
+
+        if (name == null) return Result.error("name不能为空");
+        if (type == null) return Result.error("type不能为空");
+
+        if (creatorId == null && userId != null) {
+            creatorId = userId;
+        }
+        if (creatorId == null) {
+            return Result.error("creatorId不能为空");
+        }
+
+        SocialCircle circle = circleService.createCircle(name, type, matchSource,
+                sourceId, creatorId, creatorType);
+        return Result.success(circle);
+    }
+}

+ 33 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/EnergyController.java

@@ -3,6 +3,7 @@ package com.etotem.cfc.controller;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.EnergySandboxDTO;
+import com.etotem.cfc.dto.MemberEnergyDTO;
 import com.etotem.cfc.entity.EnergyLog;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.UserMapper;
@@ -215,4 +216,36 @@ public class EnergyController {
         }
         return Result.success(latest);
     }
+
+    @Operation(summary = "获取富维度详情")
+    @PostMapping("/wealth-detail")
+    public Result<MemberEnergyDTO> getWealthDetail(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId) {
+        Long memberId = params.get("memberId") != null
+                ? Long.valueOf(params.get("memberId").toString()) : null;
+        String memberType = (String) params.get("memberType");
+
+        if (memberId == null || memberType == null) {
+            return Result.error("memberId和memberType不能为空");
+        }
+
+        User user = userMapper.selectById(userId);
+        if (user == null || user.getFamilyId() == null) {
+            return Result.error("用户未加入家庭");
+        }
+
+        EnergySandboxDTO sandbox = energyService.calculateFamilyEnergy(user.getFamilyId());
+        if (sandbox.getMembers() == null || sandbox.getMembers().isEmpty()) {
+            return Result.error("未找到成员能量数据");
+        }
+
+        MemberEnergyDTO result = sandbox.getMembers().stream()
+                .filter(m -> memberId.equals(m.getMemberId()) && memberType.equals(m.getMemberType()))
+                .findFirst()
+                .orElse(null);
+
+        if (result == null) return Result.error("未找到指定成员的能量数据");
+        return Result.success(result);
+    }
 }

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/UserController.java

@@ -93,6 +93,8 @@ public class UserController {
         if (params.containsKey("familyRole")) dto.setFamilyRole((String) params.get("familyRole"));
         if (params.containsKey("idCard")) dto.setIdCard((String) params.get("idCard"));
         if (params.containsKey("mascot")) dto.setMascot((String) params.get("mascot"));
+        if (params.containsKey("hobbies")) dto.setHobbies((String) params.get("hobbies"));
+        if (params.containsKey("dietPreferences")) dto.setDietPreferences((String) params.get("dietPreferences"));
 
         boolean success = userService.updateUserInfo(userId, dto);
         if (success) {

+ 2 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCommissionController.java

@@ -81,9 +81,10 @@ public class AdminCommissionController {
     @PostMapping("/refunds/pending")
     public Result<Page<PendingRefund>> pendingRefunds(@RequestBody Map<String, Object> params) {
         String status = (String) params.get("status");
+        String orderType = (String) params.get("orderType");
         int page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
         int size = params.get("size") != null ? ((Number) params.get("size")).intValue() : 20;
-        return Result.success(pendingRefundService.getAllPendingRefunds(status, page, size));
+        return Result.success(pendingRefundService.getAllPendingRefunds(status, orderType, page, size));
     }
 
     @PostMapping("/refunds/summary")

+ 4 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminProductOrderController.java

@@ -26,9 +26,12 @@ public class AdminProductOrderController {
     @PostMapping("/list")
     public Result<Map<String, Object>> list(@RequestBody Map<String, Object> params) {
         String status = (String) params.get("status");
+        String keyword = (String) params.get("keyword");
+        String startDate = (String) params.get("startDate");
+        String endDate = (String) params.get("endDate");
         Integer page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
         Integer size = params.get("size") != null ? ((Number) params.get("size")).intValue() : 20;
-        return orderService.adminOrderPage(page, size, status);
+        return orderService.adminOrderPage(page, size, status, keyword, startDate, endDate);
     }
 
     @Operation(summary = "订单详情")

+ 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);
+    }
+}

+ 113 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/KnowledgeBaseController.java

@@ -3,15 +3,28 @@ package com.etotem.cfc.controller.admin;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.DanKnowledgeBase;
 import com.etotem.cfc.service.DanKnowledgeBaseService;
+import com.etotem.cfc.service.DifySyncService;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.util.StringUtils;
 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.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.client.RestTemplate;
+import org.springframework.web.multipart.MultipartFile;
 
 import javax.annotation.Resource;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.UUID;
 
 @RestController
 @RequestMapping("/api/admin/knowledge-base")
@@ -20,6 +33,11 @@ public class KnowledgeBaseController {
     @Resource
     private DanKnowledgeBaseService danKnowledgeBaseService;
 
+    @Resource
+    private DifySyncService difySyncService;
+
+    private final RestTemplate restTemplate = new RestTemplate();
+
     @PostMapping("/list")
     public Result<Map<String, Object>> list(@RequestBody Map<String, Object> params) {
         String keyword = (String) params.get("keyword");
@@ -54,9 +72,21 @@ public class KnowledgeBaseController {
             knowledge.setStatus(((Number) params.get("status")).intValue());
         }
         knowledge.setRemark((String) params.get("remark"));
+        knowledge.setFileUrl((String) params.get("fileUrl"));
+        knowledge.setFileName((String) params.get("fileName"));
+        knowledge.setSourceUrl((String) params.get("sourceUrl"));
+        knowledge.setSourceType((String) params.get("sourceType"));
         List<Long> dimensionIds = (List<Long>) params.get("dimensionIds");
         List<Long> tagIds = (List<Long>) params.get("tagIds");
-        return Result.success(danKnowledgeBaseService.saveWithAssociations(knowledge, dimensionIds, tagIds));
+        DanKnowledgeBase saved = danKnowledgeBaseService.saveWithAssociations(knowledge, dimensionIds, tagIds);
+        if (saved.getId() != null && StringUtils.hasText(saved.getContent())) {
+            try {
+                List<String> codes = danKnowledgeBaseService.getDimensionCodesForKnowledge(saved.getId());
+                difySyncService.syncToDify(saved, codes);
+            } catch (Exception e) {
+            }
+        }
+        return Result.success(saved);
     }
 
     @PostMapping("/delete")
@@ -82,4 +112,85 @@ public class KnowledgeBaseController {
         danKnowledgeBaseService.bindTags(knowledgeId, tagIds);
         return Result.success(null);
     }
-}
+
+    @PostMapping("/upload-file")
+    public Result<Map<String, String>> uploadFile(@RequestParam("file") MultipartFile file) {
+        if (file.isEmpty()) {
+            return Result.error("文件不能为空");
+        }
+        String originalFilename = file.getOriginalFilename();
+        String ext = "";
+        if (originalFilename != null && originalFilename.contains(".")) {
+            ext = originalFilename.substring(originalFilename.lastIndexOf("."));
+        }
+        String savedName = UUID.randomUUID().toString().replace("-", "") + ext;
+        try {
+            Path uploadDir = Paths.get(System.getProperty("user.dir"), "uploads", "knowledge");
+            Files.createDirectories(uploadDir);
+            Path targetPath = uploadDir.resolve(savedName);
+            Files.copy(file.getInputStream(), targetPath);
+            String fileUrl = "/uploads/knowledge/" + savedName;
+            Map<String, String> result = new HashMap<>();
+            result.put("fileUrl", fileUrl);
+            result.put("fileName", originalFilename);
+            return Result.success(result);
+        } catch (IOException e) {
+            return Result.error("上传失败: " + e.getMessage());
+        }
+    }
+
+    @PostMapping("/fetch-url")
+    public Result<Map<String, String>> fetchUrl(@RequestBody Map<String, String> params) {
+        String url = params.get("url");
+        if (!StringUtils.hasText(url)) {
+            return Result.error("网址不能为空");
+        }
+        try {
+            HttpHeaders headers = new HttpHeaders();
+            headers.set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
+            HttpEntity<String> entity = new HttpEntity<>(headers);
+            org.springframework.http.ResponseEntity<String> resp = restTemplate.exchange(
+                url, org.springframework.http.HttpMethod.GET, entity, String.class);
+            String html = resp.getBody();
+            if (html == null) {
+                return Result.error("无法获取网页内容");
+            }
+            String title = extractTitle(html);
+            String content = extractContent(html);
+            Map<String, String> result = new HashMap<>();
+            result.put("title", title);
+            result.put("content", content);
+            result.put("sourceUrl", url);
+            return Result.success(result);
+        } catch (Exception e) {
+            return Result.error("获取失败: " + e.getMessage());
+        }
+    }
+
+    private String extractTitle(String html) {
+        int titleStart = html.indexOf("<title");
+        if (titleStart == -1) return "";
+        int tagEnd = html.indexOf(">", titleStart);
+        int titleEnd = html.indexOf("</title>");
+        if (tagEnd == -1 || titleEnd == -1) return "";
+        return html.substring(tagEnd + 1, titleEnd).replaceAll("<[^>]+>", "").trim();
+    }
+
+    private String extractContent(String html) {
+        int bodyStart = html.indexOf("<body");
+        if (bodyStart == -1) bodyStart = 0;
+        int bodyTagEnd = html.indexOf(">", bodyStart);
+        String content = bodyTagEnd > -1 ? html.substring(bodyTagEnd + 1) : html;
+        content = content.replaceAll("(?s)<script[^>]*>.*?</script>", "");
+        content = content.replaceAll("(?s)<style[^>]*>.*?</style>", "");
+        content = content.replaceAll("(?s)<nav[^>]*>.*?</nav>", "");
+        content = content.replaceAll("(?s)<footer[^>]*>.*?</footer>", "");
+        content = content.replaceAll("(?s)<header[^>]*>.*?</header>", "");
+        content = content.replaceAll("(?s)<[^>]+>", " ");
+        content = content.replaceAll("\\s+", " ").trim();
+        if (content.length() > 5000) {
+            content = content.substring(0, 5000);
+        }
+        return content;
+    }
+}

+ 4 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java

@@ -262,6 +262,10 @@ public class AIChatController {
                         rq.setNutritionTags(tags);
                         rq.setTypes(types != null && !types.isEmpty() ? types : null);
                         rq.setLimit(limit);
+                        rq.setUserId(userId);
+                        if (user != null) {
+                            rq.setFamilyId(user.getFamilyId());
+                        }
                         recommendations = recommendationService.search(rq);
                     }
                 }

+ 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("商品不存在");
         }

+ 90 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProductController.java

@@ -4,7 +4,13 @@ import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.ProductDTO;
 import com.etotem.cfc.dto.ProductListQueryDTO;
 import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.ProductSku;
+import com.etotem.cfc.service.PpointService;
 import com.etotem.cfc.service.ProductService;
+import com.etotem.cfc.service.ProductSkuService;
+import com.alibaba.fastjson.JSON;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestHeader;
@@ -13,6 +19,8 @@ import org.springframework.web.bind.annotation.RequestAttribute;
 import org.springframework.web.bind.annotation.RestController;
 
 import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
@@ -20,9 +28,17 @@ import java.util.Map;
 @RequestMapping("/api/product")
 public class ProductController {
 
+    private static final org.slf4j.Logger log = LoggerFactory.getLogger(ProductController.class);
+
     @Resource
     private ProductService productService;
 
+    @Resource
+    private ProductSkuService productSkuService;
+
+    @Resource
+    private PpointService ppointService;
+
     @PostMapping("/list")
     public Result<Map<String, Object>> list(@RequestBody ProductListQueryDTO query,
                                              @RequestAttribute(value = "userId", required = false) Long userId) {
@@ -68,4 +84,78 @@ public class ProductController {
         List<String> images = (List<String>) params.get("images");
         return productService.updateImages(productId, userId, images);
     }
+
+    @PostMapping("/spec/map")
+    public Result<Map<String, Object>> specMap(@RequestBody Map<String, Object> params) {
+        @SuppressWarnings("unchecked")
+        List<Object> productIdList = (List<Object>) params.get("productIds");
+        if (productIdList == null || productIdList.isEmpty()) {
+            return Result.success(new HashMap<>());
+        }
+
+        Map<String, Object> result = new HashMap<>();
+        for (Object pid : productIdList) {
+            Long productId = Long.valueOf(pid.toString());
+            List<ProductSku> skus = productSkuService.listByProductId(productId);
+
+            List<Map<String, Object>> groups = new ArrayList<>();
+            Map<String, Integer> nameToGroupId = new HashMap<>();
+            int groupId = 1;
+
+            for (ProductSku sku : skus) {
+                if (sku.getSpecs() == null) continue;
+                try {
+                    com.alibaba.fastjson.JSONArray arr = JSON.parseArray(sku.getSpecs());
+                    if (arr == null) continue;
+                    for (int i = 0; i < arr.size(); i++) {
+                        com.alibaba.fastjson.JSONObject specObj = arr.getJSONObject(i);
+                        if (specObj == null) continue;
+                        String name = specObj.getString("name");
+                        String value = specObj.getString("value");
+                        if (name == null || value == null) continue;
+
+                        if (!nameToGroupId.containsKey(name)) {
+                            nameToGroupId.put(name, groupId++);
+                            Map<String, Object> grp = new HashMap<>();
+                            grp.put("groupId", nameToGroupId.get(name));
+                            grp.put("groupName", name);
+                            grp.put("options", new ArrayList<Map<String, Object>>());
+                            groups.add(grp);
+                        }
+
+                        Map<String, Object> option = new HashMap<>();
+                        option.put("id", sku.getId());
+                        option.put("groupId", nameToGroupId.get(name));
+                        option.put("name", value);
+                        option.put("skuId", sku.getId());
+                        option.put("price", sku.getPrice());
+                        option.put("stock", sku.getStock());
+                        option.put("enabled", sku.getEnabled());
+
+                        for (Map<String, Object> g : groups) {
+                            if (g.get("groupId").equals(nameToGroupId.get(name))) {
+                                @SuppressWarnings("unchecked")
+                                List<Map<String, Object>> opts = (List<Map<String, Object>>) g.get("options");
+                                boolean exists = false;
+                                for (Map<String, Object> o : opts) {
+                                    if (value.equals(o.get("name")) && sku.getId().equals(o.get("skuId"))) {
+                                        exists = true;
+                                        break;
+                                    }
+                                }
+                                if (!exists) {
+                                    opts.add(option);
+                                }
+                            }
+                        }
+                    }
+                } catch (Exception e) {
+                    log.warn("parse specs JSON failed for sku {}: {}", sku.getId(), e.getMessage());
+                }
+            }
+
+            result.put(productId.toString(), groups);
+        }
+        return Result.success(result);
+    }
 }

+ 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;
+    }
+}

+ 61 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/recommendation/RepurchaseReminderController.java

@@ -0,0 +1,61 @@
+package com.etotem.cfc.controller.recommendation;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.RepurchaseReminderService;
+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 RepurchaseReminderController {
+
+    @Resource
+    private RepurchaseReminderService repurchaseReminderService;
+
+    @Operation(summary = "获取待处理的复购提醒")
+    @PostMapping("/repurchase-reminders/list")
+    public Result<List<Map<String, Object>>> getPendingReminders(
+            @RequestAttribute("userId") Long userId) {
+        List<Map<String, Object>> reminders = repurchaseReminderService.getPendingReminders(userId);
+        return Result.success(reminders);
+    }
+
+    @Operation(summary = "标记复购提醒为已点击")
+    @PostMapping("/repurchase-reminders/click")
+    public Result<Void> clickReminder(@RequestBody Map<String, Object> params) {
+        Object idObj = params.get("id");
+        if (idObj == null) {
+            return Result.error("id不能为空");
+        }
+        Long reminderId = idObj instanceof Number ? ((Number) idObj).longValue() : Long.valueOf(idObj.toString());
+        repurchaseReminderService.onReminderClicked(reminderId);
+        return Result.success();
+    }
+
+    @Operation(summary = "标记复购提醒为已购买")
+    @PostMapping("/repurchase-reminders/purchased")
+    public Result<Void> purchasedReminder(@RequestBody Map<String, Object> params) {
+        Object idObj = params.get("id");
+        if (idObj == null) {
+            return Result.error("id不能为空");
+        }
+        Long reminderId = idObj instanceof Number ? ((Number) idObj).longValue() : Long.valueOf(idObj.toString());
+        Object orderIdObj = params.get("orderId");
+        Long orderId = orderIdObj != null
+                ? (orderIdObj instanceof Number ? ((Number) orderIdObj).longValue() : Long.valueOf(orderIdObj.toString()))
+                : null;
+        repurchaseReminderService.onReminderPurchased(reminderId, orderId);
+        return Result.success();
+    }
+}

+ 4 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/shop/AfterSalesController.java

@@ -27,8 +27,10 @@ public class AfterSalesController {
     }
 
     @PostMapping("/list")
-    public Result<List<AfterSalesRequest>> list(@RequestAttribute("userId") Long userId) {
-        return afterSalesService.list(userId);
+    public Result<List<AfterSalesRequest>> list(@RequestBody Map<String, Object> params,
+                                                @RequestAttribute("userId") Long userId) {
+        String status = params != null ? (String) params.get("status") : null;
+        return afterSalesService.list(userId, status);
     }
 
     @PostMapping("/detail")

+ 10 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/stats/StatsController.java

@@ -2,6 +2,7 @@ package com.etotem.cfc.controller.stats;
 
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.AfterSalesRequest;
 import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.CommissionRecord;
 import com.etotem.cfc.entity.Family;
@@ -9,6 +10,7 @@ import com.etotem.cfc.entity.MemberUpgradeRecord;
 import com.etotem.cfc.entity.Task;
 import com.etotem.cfc.entity.TrialMembership;
 import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.AfterSalesRequestMapper;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.CommissionRecordMapper;
 import com.etotem.cfc.mapper.FamilyMapper;
@@ -69,6 +71,9 @@ public class StatsController {
     @Resource
     private MemberUpgradeRecordMapper memberUpgradeRecordMapper;
 
+    @Resource
+    private AfterSalesRequestMapper afterSalesRequestMapper;
+
     @Operation(summary = "管理后台仪表盘汇总")
     @PostMapping("/dashboard")
     public Result<Map<String, Object>> getDashboardSummary() {
@@ -92,6 +97,10 @@ public class StatsController {
             return taskMapper.selectList(q);
         }, taskExecutor);
 
+        CompletableFuture<Long> pendingRefundFuture = CompletableFuture.supplyAsync(() ->
+            afterSalesRequestMapper.selectCount(new QueryWrapper<AfterSalesRequest>()
+                .eq("status", "pending")), taskExecutor);
+
         try {
             data.put("totalFamilies", familyFuture.get());
             data.put("totalParents", parentFuture.get());
@@ -99,6 +108,7 @@ public class StatsController {
             data.put("totalTeachers", teacherFuture.get());
             data.put("pendingGuideCount", pendingGuideFuture.get());
             data.put("pendingPackageCount", pendingPackageFuture.get());
+            data.put("pendingRefundCount", pendingRefundFuture.get());
             data.put("recentTasks", recentTasksFuture.get());
         } catch (Exception e) {
             return Result.error("获取仪表盘数据失败");

+ 3 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/tianpan/TianpanController.java

@@ -95,6 +95,8 @@ public class TianpanController {
         
         Long member1Id = ((Number) params.get("member1Id")).longValue();
         Long member2Id = ((Number) params.get("member2Id")).longValue();
+        String memberType1 = (String) params.getOrDefault("memberType1", "family_member");
+        String memberType2 = (String) params.getOrDefault("memberType2", "family_member");
 
         String role = (String) request.getAttribute("role");
         Long currentUserId = ((Number) request.getAttribute("userId")).longValue();
@@ -109,7 +111,7 @@ public class TianpanController {
             return Result.error(403, "无权访问兼容性数据");
         }
 
-        CompatibilityResultVO data = tianpanService.computeCompatibility(member1Id, "child", member2Id, "child");
+        CompatibilityResultVO data = tianpanService.computeCompatibility(member1Id, memberType1, member2Id, memberType2);
         return Result.success(data);
     }
 

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/AddFamilyMemberDTO.java

@@ -32,6 +32,18 @@ public class AddFamilyMemberDTO {
     /** 出生日期 yyyy-MM-dd */
     private String birthday;
 
+    /** 出生时辰: 子丑寅卯辰巳午未申酉戌亥 (选填) */
+    private String birthHour;
+
+    /** 出生体重(克) (选填) */
+    private Integer birthWeight;
+
+    /** 出生地 (选填) */
+    private String birthPlace;
+
+    /** 是否剖腹产: 0=顺产, 1=剖腹产 (选填) */
+    private Integer isCesarean;
+
     /** 关系类型标识(选填,不再作为主要关系依据,改为基于generationLevel+peerType自动推断) */
     private String relationshipType;
 

+ 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; // 供应商名称
 }

+ 35 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/MemberEnergyDTO.java

@@ -31,4 +31,39 @@ public class MemberEnergyDTO {
 
     /** 个人综合能量值 */
     private Integer overallScore;
+
+    // 富的子维度(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;   // 用户可见的消息
+
+    // 行克身标记(v2.0 五行相克)
+    private String actionBodyStatus;    // normal / overdraw / penalty / cautious / balanced
+    private String actionBodyMessage;
+
+    // 富克心标记
+    private String wealthHeartStatus;   // normal / eruption / judgmental / materialized / nourished
+    private String wealthHeartMessage;
+
+    // 心克智标记
+    private String mindWisdomStatus;     // normal / overprotect / cold_wise / balanced
+    private String mindWisdomMessage;
+
+    // 智克行标记
+    private String wisdomActionStatus;  // normal / blind / paralysis / balanced
+    private String wisdomActionMessage;
+
+    // 心的子维度(仅孩子有真实数据,成人回0)
+    private Integer heartEmotionStable;  // 心·情绪稳定 (0-100)
+    private Integer heartUnderstanding;  // 心·理解包容 (0-100)
+    private Integer heartLegacy;         // 心·传承传递 (0-100),第一阶段回0
 }

+ 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;
 

+ 16 - 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);
     }
@@ -96,4 +100,15 @@ public class ProductDTO {
         }
         return d;
     }
+
+    public static ProductDTO from(Product p, boolean isGuest, String memberLevel,
+            Integer effectivePpoint, String ppointSource) {
+        ProductDTO d = from(p, isGuest, memberLevel);
+        if (d != null && effectivePpoint != null) {
+            d.setEffectivePpoint(effectivePpoint);
+            d.setPpointSource(ppointSource);
+            d.setProfitRate(p.getProfitRate());
+        }
+        return d;
+    }
 }

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

@@ -11,4 +11,6 @@ public class RecommendationQuery {
     private List<String> nutritionTags;   // 营养需求标签
     private List<String> types;           // ["product", "activity", "article"]
     private Integer limit = 5;            // 每类最多返回数量
+    private Long userId;                  // 用户 ID
+    private Long familyId;                // 家庭 ID
 }

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

@@ -27,6 +27,9 @@ public class UpdateUserDTO {
     // 家长身份
     private String familyRole;
 
+    private String hobbies;
+    private String dietPreferences;
+
     // AI助手形象: xibao(浠宝)/fubao(福宝)
     private String mascot;
 }

+ 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;

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/DanKnowledgeBase.java

@@ -25,6 +25,14 @@ public class DanKnowledgeBase implements Serializable {
 
     private String remark;
 
+    private String fileUrl;
+
+    private String fileName;
+
+    private String sourceUrl;
+
+    private String sourceType;
+
     private Date createdAt;
 
     private Date updatedAt;

+ 13 - 1
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyMember.java

@@ -35,9 +35,21 @@ public class FamilyMember implements Serializable {
     /** 性别: male/female */
     private String gender;
 
-    /** 出日期 */
+    /** 出���日期 */
     private Date birthday;
 
+    /** 出生时辰: 子丑寅卯辰巳午未申酉戌亥 */
+    private String birthHour;
+
+    /** 出生体重(克) */
+    private Integer birthWeight;
+
+    /** 出生地 */
+    private String birthPlace;
+
+    /** 是否剖腹产: 0=顺产, 1=剖腹产 */
+    private Integer isCesarean;
+
     /** 关系类型key(关联relationship_types.type_key) */
     private String relationshipType;
 

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/entity/PendingRefund.java

@@ -43,7 +43,7 @@ public class PendingRefund implements Serializable {
     /** 下次重试时间 */
     private Date nextRetryTime;
 
-    /** 状态:pending(待处理)/processing(处理中)/success(成功)/failed(失败) */
+    /** 状态:pending(待处理)/processing(处理中)/success(成功)/failed(失败)/cancelled(已中止) */
     private String status;
 
     /** 失败原因 */

+ 37 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ProductDimensionMapping.java

@@ -0,0 +1,37 @@
+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;
+
+@Data
+@TableName("product_dimension_mapping")
+public class ProductDimensionMapping implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long productId;
+
+    /** 维度:body/wisdom/mind/action/wealth */
+    private String dimensionCode;
+
+    /** 匹配度 0-100 */
+    private Integer matchScore;
+
+    /** 匹配原因 */
+    private String matchReason;
+
+    /** 推荐标签 JSON */
+    private String tags;
+
+    private Integer enabled;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/entity/ProductOrder.java

@@ -42,7 +42,7 @@ public class ProductOrder implements Serializable {
     private Date confirmTime;          // 确认收货时间
     private Date paidAt;
     // 退款相关字段
-    private Integer refundStatus;      // 退款状态:0无 1申请中 2已退款 3拒绝
+    private Integer refundStatus;      // 退款状态:0无 1申请中 2已退款 3拒绝 4退款到帐中
     private String refundReason;       // 退款原因
     private Integer refundAmount;      // 退款金额(分)
     private Date refundTime;           // 退款时间

+ 32 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ProductRecommendationLog.java

@@ -0,0 +1,32 @@
+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;
+
+@Data
+@TableName("product_recommendation_log")
+public class ProductRecommendationLog implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long userId;
+
+    private Long productId;
+
+    /** 触发场景: report_upload / manual / chat */
+    private String scene;
+
+    /** 推荐理由 */
+    private String reason;
+
+    /** 匹配分 */
+    private Double matchScore;
+
+    private Date createdAt;
+}

+ 36 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/RepurchaseReminderConfig.java

@@ -0,0 +1,36 @@
+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;
+
+@Data
+@TableName("repurchase_reminder_config")
+public class RepurchaseReminderConfig implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 商品类目 */
+    private String productCategory;
+
+    /** 特定商品 ID(优先于 category) */
+    private Long productId;
+
+    /** 提醒天数 */
+    private Integer reminderDays;
+
+    /** 提醒话术模板 */
+    private String reminderTemplate;
+
+    /** 最大提醒次数 */
+    private Integer maxReminders;
+
+    private Integer enabled;
+
+    private Date createdAt;
+}

+ 34 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/RepurchaseReminderRecord.java

@@ -0,0 +1,34 @@
+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;
+
+@Data
+@TableName("repurchase_reminder_record")
+public class RepurchaseReminderRecord implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long userId;
+
+    private Long productId;
+
+    private Long orderId;
+
+    /** 提醒天数 */
+    private Integer reminderDays;
+
+    private Date sentAt;
+
+    /** 是否点击 */
+    private Integer clicked;
+
+    /** 是否购买 */
+    private Integer purchased;
+}

+ 34 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/SocialCircle.java

@@ -0,0 +1,34 @@
+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;
+
+@Data
+@TableName("social_circle")
+public class SocialCircle implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 圈子名称 */
+    private String name;
+
+    /** 类型: topic/activity/hobby/ability/product/health/provider */
+    private String type;
+
+    /** 匹配源: article/activity/game/assessment/product/health_report/teacher */
+    private String matchSource;
+
+    /** 匹配源ID */
+    private Long sourceId;
+
+    /** 成员数 */
+    private Integer memberCount;
+
+    private Date createdAt;
+}

+ 28 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/SocialCircleMember.java

@@ -0,0 +1,28 @@
+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;
+
+@Data
+@TableName("social_circle_member")
+public class SocialCircleMember implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 圈子ID */
+    private Long circleId;
+
+    /** 用户ID */
+    private Long memberId;
+
+    /** parent/child */
+    private String memberType;
+
+    private Date joinedAt;
+}

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

@@ -75,6 +75,8 @@ public class User implements Serializable {
     private String bloodType; // 血型
     private String highestEducation; // 最高学历
     private String maritalStatus; // 婚姻状态
+    private String hobbies; // 兴趣爱好
+    private String dietPreferences; // 饮食偏好
 
     // 家长身份: 爸爸/妈妈/爷爷/奶奶/姥爷/姥姥/其他
     private String familyRole;

+ 31 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/UserProfileHistory.java

@@ -0,0 +1,31 @@
+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;
+
+@Data
+@TableName("user_profile_history")
+public class UserProfileHistory implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long userId;
+
+    private String fieldName;
+
+    private String oldValue;
+
+    private String newValue;
+
+    private String source;
+
+    private Date changedAt;
+
+    private Date createdAt;
+}

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

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

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

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

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

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

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

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

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

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

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

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

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/UserProfileHistoryMapper.java

@@ -0,0 +1,7 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.UserProfileHistory;
+
+public interface UserProfileHistoryMapper extends BaseMapper<UserProfileHistory> {
+}

+ 8 - 6
cfc-backend/src/main/java/com/etotem/cfc/service/AfterSalesService.java

@@ -55,12 +55,14 @@ public class AfterSalesService {
         return Result.success(request);
     }
 
-    public Result<List<AfterSalesRequest>> list(Long userId) {
-        List<AfterSalesRequest> list = afterSalesRequestMapper.selectList(
-            new LambdaQueryWrapper<AfterSalesRequest>()
-                .eq(AfterSalesRequest::getUserId, userId)
-                .orderByDesc(AfterSalesRequest::getCreatedAt)
-        );
+    public Result<List<AfterSalesRequest>> list(Long userId, String status) {
+        LambdaQueryWrapper<AfterSalesRequest> wrapper = new LambdaQueryWrapper<AfterSalesRequest>()
+            .eq(AfterSalesRequest::getUserId, userId)
+            .orderByDesc(AfterSalesRequest::getCreatedAt);
+        if (status != null && !status.isEmpty()) {
+            wrapper.eq(AfterSalesRequest::getStatus, status);
+        }
+        List<AfterSalesRequest> list = afterSalesRequestMapper.selectList(wrapper);
         return Result.success(list);
     }
 

+ 53 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentService.java

@@ -15,16 +15,24 @@ import com.etotem.cfc.mapper.AssessmentRecordMapper;
 import com.etotem.cfc.mapper.DanAssessmentResultMapper;
 import com.etotem.cfc.mapper.FamilyAssessmentConfigMapper;
 import org.springframework.stereotype.Service;
+import lombok.extern.slf4j.Slf4j;
 
 import com.etotem.cfc.dto.ParsedReport;
+import com.etotem.cfc.dto.RecommendationQuery;
+import com.etotem.cfc.dto.RecommendationResult;
+import com.etotem.cfc.entity.ProductRecommendationLog;
 import com.etotem.cfc.common.DuplicateGrowthRecordException;
 
 import javax.annotation.Resource;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Calendar;
 import java.util.Date;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 
+@Slf4j
 @Service
 public class AssessmentService extends ServiceImpl<AssessmentMaterialMapper, AssessmentMaterial> {
 
@@ -46,6 +54,12 @@ public class AssessmentService extends ServiceImpl<AssessmentMaterialMapper, Ass
     @Resource
     private ReportParseService reportParseService;
 
+    @Resource
+    private RecommendationService recommendationService;
+
+    @Resource
+    private com.etotem.cfc.mapper.ProductRecommendationLogMapper productRecommendationLogMapper;
+
     public AssessmentMaterial getActiveMaterial() {
         LambdaQueryWrapper<AssessmentMaterial> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(AssessmentMaterial::getStatus, "active");
@@ -199,6 +213,45 @@ public class AssessmentService extends ServiceImpl<AssessmentMaterialMapper, Ass
         }
         danAssessmentResultMapper.insert(result);
 
+        // 触发认知测评关联商品推荐(异步,仅parent_upload来源)
+        if (DanAssessmentResult.SOURCE_PARENT_UPLOAD.equals(result.getSource())) {
+            final DanAssessmentResult finalResult = result;
+            new Thread(() -> {
+                try {
+                    Map<String, Integer> scoreMap = new LinkedHashMap<>();
+                    scoreMap.put("专注力", finalResult.getFocusScore() != null ? finalResult.getFocusScore() : 0);
+                    scoreMap.put("记忆力", finalResult.getMemoryScore() != null ? finalResult.getMemoryScore() : 0);
+                    scoreMap.put("感知力", finalResult.getPerceptionScore() != null ? finalResult.getPerceptionScore() : 0);
+                    scoreMap.put("逻辑思维", finalResult.getLogicScore() != null ? finalResult.getLogicScore() : 0);
+                    scoreMap.put("空间思维", finalResult.getSpatialScore() != null ? finalResult.getSpatialScore() : 0);
+                    scoreMap.put("加工速度", finalResult.getProcessingSpeedScore() != null ? finalResult.getProcessingSpeedScore() : 0);
+                    List<Map.Entry<String, Integer>> sorted = new ArrayList<>(scoreMap.entrySet());
+                    sorted.sort((a, b) -> a.getValue().compareTo(b.getValue()));
+                    List<String> weakDims = new ArrayList<>();
+                    for (int i = 0; i < Math.min(2, sorted.size()); i++) {
+                        weakDims.add(sorted.get(i).getKey());
+                    }
+                    RecommendationQuery query = new RecommendationQuery();
+                    query.setNutritionTags(new ArrayList<>(weakDims));
+                    query.setTypes(Arrays.asList("product"));
+                    query.setLimit(3);
+                    List<RecommendationResult> recs = recommendationService.search(query);
+                    for (RecommendationResult rec : recs) {
+                        ProductRecommendationLog logEntry = new ProductRecommendationLog();
+                        logEntry.setUserId(finalResult.getFamilyMemberId());
+                        logEntry.setProductId(rec.getId());
+                        logEntry.setScene("report_upload");
+                        logEntry.setReason("认知测评(" + String.join(",", weakDims) + ")触发");
+                        logEntry.setCreatedAt(new Date());
+                        productRecommendationLogMapper.insert(logEntry);
+                    }
+                    log.info("认知测评关联推荐已生成,familyMemberId={}, 弱维度={}", finalResult.getFamilyMemberId(), weakDims);
+                } catch (Exception e) {
+                    log.warn("认知测评推荐生成失败: error={}", e.getMessage());
+                }
+            }, "cognitive-rec-" + result.getId()).start();
+        }
+
         // Auto-create growth record from this assessment result (non-fatal on failure)
         try {
             ParsedReport parsed = reportParseService.parseFromResult(result);

+ 12 - 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) {
@@ -125,12 +133,13 @@ public class CartService {
         dto.setId(cart.getId());
         dto.setUserId(cart.getUserId());
         dto.setProductId(cart.getProductId());
+        dto.setSkuId(cart.getSkuId());
         dto.setQuantity(cart.getQuantity());
         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;
     }

+ 518 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CircleMatchService.java

@@ -0,0 +1,518 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.ActivityRegistration;
+import com.etotem.cfc.entity.DanAssessmentResult;
+import com.etotem.cfc.entity.Family;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.entity.ProductOrder;
+import com.etotem.cfc.entity.SocialCircle;
+import com.etotem.cfc.mapper.ActivityRegistrationMapper;
+import com.etotem.cfc.mapper.DanAssessmentResultMapper;
+import com.etotem.cfc.mapper.FamilyMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.HealthReportMapper;
+import com.etotem.cfc.mapper.ProductOrderMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * 圈子匹配引擎
+ * 基于身·心·智·富·行五维数据发现共同点,推荐圈子
+ */
+@Slf4j
+@Service
+public class CircleMatchService {
+
+    @Resource
+    private ActivityRegistrationMapper activityRegistrationMapper;
+
+    @Resource
+    private DanAssessmentResultMapper danAssessmentResultMapper;
+
+    @Resource
+    private HealthReportMapper healthReportMapper;
+
+    @Resource
+    private ProductOrderMapper productOrderMapper;
+
+    @Resource
+    private FamilyMapper familyMapper;
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Resource
+    private CircleService circleService;
+
+    /**
+     * 为孩子发现匹配圈子(按匹配源分组)
+     */
+    public List<Map<String, Object>> discover(Long childId) {
+        List<Map<String, Object>> allRecommendations = new ArrayList<>();
+        Set<String> dedupKey = new HashSet<>();
+
+        // 1. 共同活动匹配
+        List<Map<String, Object>> activityCircles = matchByActivity(childId);
+        for (Map<String, Object> c : activityCircles) {
+            String key = c.get("type") + "_" + c.get("sourceId");
+            if (dedupKey.add(key)) {
+                allRecommendations.add(c);
+            }
+        }
+
+        // 2. 共同认知能力匹配
+        List<Map<String, Object>> abilityCircles = matchByAbility(childId);
+        for (Map<String, Object> c : abilityCircles) {
+            String key = c.get("type") + "_" + c.get("sourceId");
+            if (dedupKey.add(key)) {
+                allRecommendations.add(c);
+            }
+        }
+
+        // 3. 共同情绪特征匹配
+        List<Map<String, Object>> topicCircles = matchByEmotion(childId);
+        for (Map<String, Object> c : topicCircles) {
+            String key = c.get("type") + "_" + c.get("sourceId");
+            if (dedupKey.add(key)) {
+                allRecommendations.add(c);
+            }
+        }
+
+        // 4. 共同身体状况匹配
+        List<Map<String, Object>> healthCircles = matchByHealth(childId);
+        for (Map<String, Object> c : healthCircles) {
+            String key = c.get("type") + "_" + c.get("sourceId");
+            if (dedupKey.add(key)) {
+                allRecommendations.add(c);
+            }
+        }
+
+        // 5. 共同产品匹配
+        List<Map<String, Object>> productCircles = matchByProduct(childId);
+        for (Map<String, Object> c : productCircles) {
+            String key = c.get("type") + "_" + c.get("sourceId");
+            if (dedupKey.add(key)) {
+                allRecommendations.add(c);
+            }
+        }
+
+        // 6. 共同服务商匹配
+        List<Map<String, Object>> providerCircles = matchByProvider(childId);
+        for (Map<String, Object> c : providerCircles) {
+            String key = c.get("type") + "_" + c.get("sourceId");
+            if (dedupKey.add(key)) {
+                allRecommendations.add(c);
+            }
+        }
+
+        return allRecommendations;
+    }
+
+    /**
+     * 1. 共同活动匹配:参与同一活动的其他孩子
+     */
+    private List<Map<String, Object>> matchByActivity(Long childId) {
+        List<Map<String, Object>> result = new ArrayList<>();
+        try {
+            // 查找孩子参与的活动
+            LambdaQueryWrapper<ActivityRegistration> regWrapper = new LambdaQueryWrapper<ActivityRegistration>()
+                    .eq(ActivityRegistration::getChildId, childId);
+            List<ActivityRegistration> myRegs = activityRegistrationMapper.selectList(regWrapper);
+
+            for (ActivityRegistration reg : myRegs) {
+                if (reg.getActivityId() == null) continue;
+
+                // 查找参与同一活动的其他孩子
+                LambdaQueryWrapper<ActivityRegistration> sameActWrapper = new LambdaQueryWrapper<ActivityRegistration>()
+                        .eq(ActivityRegistration::getActivityId, reg.getActivityId())
+                        .ne(ActivityRegistration::getChildId, childId);
+                List<ActivityRegistration> sameAct = activityRegistrationMapper.selectList(sameActWrapper);
+
+                if (!sameAct.isEmpty()) {
+                    // 创建或复用圈子
+                    Map<String, Object> circle = getOrCreateRecommendation(
+                            "共同活动圈", "activity", "activity",
+                            reg.getActivityId().longValue(), childId);
+                    if (circle != null) {
+                        circle.put("matchReason", "参加了同一活动");
+                        result.add(circle);
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.warn("共同活动匹配异常: {}", e.getMessage());
+        }
+        return result;
+    }
+
+    /**
+     * 2. 共同认知能力匹配:DAN COG 子分差值 < 10
+     */
+    private List<Map<String, Object>> matchByAbility(Long childId) {
+        List<Map<String, Object>> result = new ArrayList<>();
+        try {
+            DanAssessmentResult myDan = findLatestDan(childId);
+            if (myDan == null) return result;
+
+            // 查找其他认知能力相近的孩子
+            LambdaQueryWrapper<DanAssessmentResult> qw = new LambdaQueryWrapper<DanAssessmentResult>()
+                    .eq(DanAssessmentResult::getStatus, "completed")
+                    .ne(DanAssessmentResult::getChildId, childId)
+                    .orderByDesc(DanAssessmentResult::getAssessmentDate);
+            List<DanAssessmentResult> others = danAssessmentResultMapper.selectList(qw);
+
+            // 按childId去重取最新
+            Map<Long, DanAssessmentResult> latestByChild = new HashMap<>();
+            for (DanAssessmentResult r : others) {
+                if (!latestByChild.containsKey(r.getChildId())) {
+                    latestByChild.put(r.getChildId(), r);
+                }
+            }
+
+            for (DanAssessmentResult other : latestByChild.values()) {
+                if (isCogSimilar(myDan, other)) {
+                    Map<String, Object> circle = getOrCreateRecommendation(
+                            "认知能力圈", "ability", "assessment",
+                            myDan.getId(), childId);
+                    if (circle != null) {
+                        circle.put("matchReason", "认知能力相似");
+                        result.add(circle);
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.warn("共同认知能力匹配异常: {}", e.getMessage());
+        }
+        return result;
+    }
+
+    /**
+     * 3. 共同情绪特征匹配:EMI 子分相近
+     */
+    private List<Map<String, Object>> matchByEmotion(Long childId) {
+        List<Map<String, Object>> result = new ArrayList<>();
+        try {
+            DanAssessmentResult myDan = findLatestDan(childId);
+            if (myDan == null) return result;
+
+            LambdaQueryWrapper<DanAssessmentResult> qw = new LambdaQueryWrapper<DanAssessmentResult>()
+                    .eq(DanAssessmentResult::getStatus, "completed")
+                    .ne(DanAssessmentResult::getChildId, childId)
+                    .orderByDesc(DanAssessmentResult::getAssessmentDate);
+            List<DanAssessmentResult> others = danAssessmentResultMapper.selectList(qw);
+
+            Map<Long, DanAssessmentResult> latestByChild = new HashMap<>();
+            for (DanAssessmentResult r : others) {
+                if (!latestByChild.containsKey(r.getChildId())) {
+                    latestByChild.put(r.getChildId(), r);
+                }
+            }
+
+            for (DanAssessmentResult other : latestByChild.values()) {
+                if (isEmiSimilar(myDan, other)) {
+                    Map<String, Object> circle = getOrCreateRecommendation(
+                            "情绪成长圈", "topic", "assessment",
+                            myDan.getId(), childId);
+                    if (circle != null) {
+                        circle.put("matchReason", "情绪特征相近");
+                        result.add(circle);
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.warn("共同情绪特征匹配异常: {}", e.getMessage());
+        }
+        return result;
+    }
+
+    /**
+     * 4. 共同身体状况匹配:健康指标相近
+     */
+    private List<Map<String, Object>> matchByHealth(Long childId) {
+        List<Map<String, Object>> result = new ArrayList<>();
+        try {
+            // 查找孩子的最新健康报告
+            LambdaQueryWrapper<HealthReport> myHw = new LambdaQueryWrapper<HealthReport>()
+                    .eq(HealthReport::getUserId, childId)
+                    .eq(HealthReport::getStatus, "active")
+                    .orderByDesc(HealthReport::getReportDate)
+                    .last("LIMIT 1");
+            HealthReport myReport = healthReportMapper.selectOne(myHw);
+            if (myReport == null || myReport.getOverallScore() == null) return result;
+
+            // 查找其他健康指标相近的孩子
+            LambdaQueryWrapper<HealthReport> othersHw = new LambdaQueryWrapper<HealthReport>()
+                    .eq(HealthReport::getStatus, "active")
+                    .ne(HealthReport::getUserId, childId)
+                    .orderByDesc(HealthReport::getReportDate);
+            List<HealthReport> others = healthReportMapper.selectList(othersHw);
+
+            Map<Long, HealthReport> latestByUser = new HashMap<>();
+            for (HealthReport r : others) {
+                if (!latestByUser.containsKey(r.getUserId())) {
+                    latestByUser.put(r.getUserId(), r);
+                }
+            }
+
+            int myScore = myReport.getOverallScore();
+            for (HealthReport other : latestByUser.values()) {
+                if (other.getOverallScore() != null
+                        && Math.abs(other.getOverallScore() - myScore) < 15) {
+                    Map<String, Object> circle = getOrCreateRecommendation(
+                            "健康生活圈", "health", "health_report",
+                            myReport.getId(), childId);
+                    if (circle != null) {
+                        circle.put("matchReason", "身体状况相近");
+                        result.add(circle);
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.warn("共同身体状况匹配异常: {}", e.getMessage());
+        }
+        return result;
+    }
+
+    /**
+     * 5. 共同产品匹配:购买了同一商品
+     */
+    private List<Map<String, Object>> matchByProduct(Long childId) {
+        List<Map<String, Object>> result = new ArrayList<>();
+        try {
+            // 使用 product_order 表查询
+            String tableName = "product_order";
+            String sql = "SELECT DISTINCT product_id FROM " + tableName
+                    + " WHERE buyer_id = " + childId
+                    + " AND product_id IS NOT NULL";
+            List<Map<String, Object>> myProducts;
+            try {
+                myProducts = productOrderMapper.selectMaps(
+                        new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<ProductOrder>()
+                                .select("DISTINCT product_id")
+                                .eq("buyer_id", childId)
+                                .isNotNull("product_id"));
+            } catch (Exception e) {
+                log.warn("product_order查询失败,尝试product_orders: {}", e.getMessage());
+                try {
+                    myProducts = productOrderMapper.selectMaps(
+                            new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<ProductOrder>()
+                                    .select("DISTINCT product_id")
+                                    .eq("buyer_id", childId)
+                                    .isNotNull("product_id"));
+                } catch (Exception e2) {
+                    log.warn("product_orders查询也失败: {}", e2.getMessage());
+                    myProducts = new ArrayList<>();
+                }
+            }
+
+            for (Map<String, Object> row : myProducts) {
+                Object pid = row.get("product_id");
+                if (pid == null) continue;
+                Long productId = Long.valueOf(pid.toString());
+
+                // 查找买了同一商品的其他用户
+                String searchSql = "SELECT DISTINCT buyer_id FROM " + tableName
+                        + " WHERE product_id = " + productId
+                        + " AND buyer_id != " + childId;
+                List<Map<String, Object>> others;
+                try {
+                    others = productOrderMapper.selectMaps(
+                            new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<ProductOrder>()
+                                    .select("DISTINCT buyer_id")
+                                    .eq("product_id", productId)
+                                    .ne("buyer_id", childId));
+                } catch (Exception e) {
+                    continue;
+                }
+
+                if (!others.isEmpty()) {
+                    Map<String, Object> circle = getOrCreateRecommendation(
+                            "好物分享圈", "product", "product",
+                            productId, childId);
+                    if (circle != null) {
+                        circle.put("matchReason", "购买了相同商品");
+                        result.add(circle);
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.warn("共同产品匹配异常: {}", e.getMessage());
+        }
+        return result;
+    }
+
+    /**
+     * 6. 共同服务商匹配:绑定了同一位teacher
+     */
+    private List<Map<String, Object>> matchByProvider(Long childId) {
+        List<Map<String, Object>> result = new ArrayList<>();
+        try {
+            // 查找孩子所在的家庭
+            LambdaQueryWrapper<FamilyMember> fmw = new LambdaQueryWrapper<FamilyMember>()
+                    .eq(FamilyMember::getId, childId);
+            FamilyMember member = familyMemberMapper.selectOne(fmw);
+            if (member == null || member.getFamilyId() == null) return result;
+
+            // 查找家庭绑定的teacher
+            Family family = familyMapper.selectById(member.getFamilyId());
+            if (family == null || family.getTeacherId() == null) return result;
+
+            Long teacherId = family.getTeacherId();
+
+            // 查找绑定了同一teacher的其他家庭的孩子
+            LambdaQueryWrapper<Family> familyQw = new LambdaQueryWrapper<Family>()
+                    .eq(Family::getTeacherId, teacherId)
+                    .ne(Family::getId, member.getFamilyId());
+            List<Family> sameTeacherFamilies = familyMapper.selectList(familyQw);
+
+            for (Family f : sameTeacherFamilies) {
+                LambdaQueryWrapper<FamilyMember> childQw = new LambdaQueryWrapper<FamilyMember>()
+                        .eq(FamilyMember::getFamilyId, f.getId());
+                List<FamilyMember> siblings = familyMemberMapper.selectList(childQw);
+                if (!siblings.isEmpty()) {
+                    Map<String, Object> circle = getOrCreateRecommendation(
+                            "规划师同门圈", "provider", "teacher",
+                            teacherId, childId);
+                    if (circle != null) {
+                        circle.put("matchReason", "同一成长规划师");
+                        result.add(circle);
+                    }
+                    break; // 一个teacher只创建一个圈子
+                }
+            }
+        } catch (Exception e) {
+            log.warn("共同服务商匹配异常: {}", e.getMessage());
+        }
+        return result;
+    }
+
+    // ==================== 辅助方法 ====================
+
+    /**
+     * 获取或创建推荐圈子
+     */
+    private Map<String, Object> getOrCreateRecommendation(String baseName, String type,
+                                                          String matchSource, Long sourceId,
+                                                          Long childId) {
+        try {
+            // 查找是否已有相同源ID+类型的圈子
+            LambdaQueryWrapper<SocialCircle> qw = new LambdaQueryWrapper<SocialCircle>()
+                    .eq(SocialCircle::getSourceId, sourceId)
+                    .eq(SocialCircle::getType, type);
+            SocialCircle existing = circleService.getOne(qw);
+
+            if (existing != null) {
+                Map<String, Object> item = new HashMap<>();
+                item.put("id", existing.getId());
+                item.put("name", existing.getName());
+                item.put("type", existing.getType());
+                item.put("matchSource", existing.getMatchSource());
+                item.put("sourceId", existing.getSourceId());
+                item.put("memberCount", existing.getMemberCount());
+                return item;
+            }
+
+            // 创建新圈子
+            SocialCircle circle = circleService.createCircle(baseName, type, matchSource,
+                    sourceId, childId, "child");
+            Map<String, Object> item = new HashMap<>();
+            item.put("id", circle.getId());
+            item.put("name", circle.getName());
+            item.put("type", circle.getType());
+            item.put("matchSource", circle.getMatchSource());
+            item.put("sourceId", circle.getSourceId());
+            item.put("memberCount", circle.getMemberCount());
+            return item;
+        } catch (Exception e) {
+            log.warn("创建推荐圈子失败: {}", e.getMessage());
+            return null;
+        }
+    }
+
+    /**
+     * 获取孩子最新的DAN测评结果
+     */
+    private DanAssessmentResult findLatestDan(Long childId) {
+        LambdaQueryWrapper<DanAssessmentResult> qw = new LambdaQueryWrapper<DanAssessmentResult>()
+                .eq(DanAssessmentResult::getChildId, childId)
+                .eq(DanAssessmentResult::getStatus, "completed")
+                .orderByDesc(DanAssessmentResult::getAssessmentDate)
+                .last("LIMIT 1");
+        return danAssessmentResultMapper.selectOne(qw);
+    }
+
+    /**
+     * 判断COG认知能力是否相近(各维度差值<10)
+     */
+    private boolean isCogSimilar(DanAssessmentResult a, DanAssessmentResult b) {
+        int diffSum = 0;
+        int count = 0;
+
+        if (a.getAttentionScore() != null && b.getAttentionScore() != null) {
+            diffSum += Math.abs(a.getAttentionScore() - b.getAttentionScore());
+            count++;
+        }
+        if (a.getFocusScore() != null && b.getFocusScore() != null) {
+            diffSum += Math.abs(a.getFocusScore() - b.getFocusScore());
+            count++;
+        }
+        if (a.getMemoryScore() != null && b.getMemoryScore() != null) {
+            diffSum += Math.abs(a.getMemoryScore() - b.getMemoryScore());
+            count++;
+        }
+        if (a.getLogicScore() != null && b.getLogicScore() != null) {
+            diffSum += Math.abs(a.getLogicScore() - b.getLogicScore());
+            count++;
+        }
+        if (a.getPerceptionScore() != null && b.getPerceptionScore() != null) {
+            diffSum += Math.abs(a.getPerceptionScore() - b.getPerceptionScore());
+            count++;
+        }
+        if (a.getSpatialScore() != null && b.getSpatialScore() != null) {
+            diffSum += Math.abs(a.getSpatialScore() - b.getSpatialScore());
+            count++;
+        }
+
+        if (count == 0) return false;
+        return (diffSum / count) < 10;
+    }
+
+    /**
+     * 判断EMI情绪特征是否相近
+     */
+    private boolean isEmiSimilar(DanAssessmentResult a, DanAssessmentResult b) {
+        int diffSum = 0;
+        int count = 0;
+
+        if (a.getEmotionManagementScore() != null && b.getEmotionManagementScore() != null) {
+            diffSum += Math.abs(a.getEmotionManagementScore() - b.getEmotionManagementScore());
+            count++;
+        }
+        if (a.getEmpathyScore() != null && b.getEmpathyScore() != null) {
+            diffSum += Math.abs(a.getEmpathyScore() - b.getEmpathyScore());
+            count++;
+        }
+        if (a.getSocialAdaptabilityScore() != null && b.getSocialAdaptabilityScore() != null) {
+            diffSum += Math.abs(a.getSocialAdaptabilityScore() - b.getSocialAdaptabilityScore());
+            count++;
+        }
+        if (a.getSelfMotivationScore() != null && b.getSelfMotivationScore() != null) {
+            diffSum += Math.abs(a.getSelfMotivationScore() - b.getSelfMotivationScore());
+            count++;
+        }
+
+        if (count == 0) return false;
+        return (diffSum / count) < 10;
+    }
+}

+ 182 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CircleService.java

@@ -0,0 +1,182 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.etotem.cfc.entity.SocialCircle;
+import com.etotem.cfc.entity.SocialCircleMember;
+import com.etotem.cfc.mapper.SocialCircleMapper;
+import com.etotem.cfc.mapper.SocialCircleMemberMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Service
+public class CircleService extends ServiceImpl<SocialCircleMapper, SocialCircle> {
+
+    @Resource
+    private SocialCircleMemberMapper socialCircleMemberMapper;
+
+    @Resource
+    private CircleMatchService circleMatchService;
+
+    /**
+     * 获取用户已加入的圈子列表
+     */
+    public List<Map<String, Object>> getMyCircles(Long memberId, String memberType) {
+        // 查询用户加入的圈子成员记录
+        LambdaQueryWrapper<SocialCircleMember> mw = new LambdaQueryWrapper<SocialCircleMember>()
+                .eq(SocialCircleMember::getMemberId, memberId)
+                .eq(SocialCircleMember::getMemberType, memberType);
+        List<SocialCircleMember> myMemberships = socialCircleMemberMapper.selectList(mw);
+
+        if (myMemberships.isEmpty()) {
+            return new ArrayList<>();
+        }
+
+        List<Long> circleIds = myMemberships.stream()
+                .map(SocialCircleMember::getCircleId)
+                .collect(Collectors.toList());
+
+        // 查询圈子详情
+        LambdaQueryWrapper<SocialCircle> cw = new LambdaQueryWrapper<SocialCircle>()
+                .in(SocialCircle::getId, circleIds);
+        List<SocialCircle> circles = this.list(cw);
+
+        // 组装结果
+        Map<Long, SocialCircle> circleMap = circles.stream()
+                .collect(Collectors.toMap(SocialCircle::getId, c -> c));
+
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (SocialCircleMember membership : myMemberships) {
+            SocialCircle circle = circleMap.get(membership.getCircleId());
+            if (circle != null) {
+                Map<String, Object> item = new HashMap<>();
+                item.put("id", circle.getId());
+                item.put("name", circle.getName());
+                item.put("type", circle.getType());
+                item.put("matchSource", circle.getMatchSource());
+                item.put("memberCount", circle.getMemberCount());
+                result.add(item);
+            }
+        }
+        return result;
+    }
+
+    /**
+     * 发现推荐圈子
+     */
+    public List<Map<String, Object>> discoverCircles(Long childId) {
+        // 获取用户已加入圈子ID集合
+        LambdaQueryWrapper<SocialCircleMember> mw = new LambdaQueryWrapper<SocialCircleMember>()
+                .eq(SocialCircleMember::getMemberId, childId)
+                .eq(SocialCircleMember::getMemberType, "child");
+        List<SocialCircleMember> existing = socialCircleMemberMapper.selectList(mw);
+        List<Long> joinedCircleIds = existing.stream()
+                .map(SocialCircleMember::getCircleId)
+                .collect(Collectors.toList());
+
+        // 调用匹配引擎获取推荐
+        List<Map<String, Object>> recommendations = circleMatchService.discover(childId);
+
+        // 过滤已加入的圈子
+        List<Map<String, Object>> filtered = new ArrayList<>();
+        for (Map<String, Object> rec : recommendations) {
+            Long circleId = (Long) rec.get("id");
+            if (circleId != null && !joinedCircleIds.contains(circleId)) {
+                filtered.add(rec);
+            }
+        }
+        return filtered;
+    }
+
+    /**
+     * 加入圈子
+     */
+    @Transactional
+    public boolean joinCircle(Long circleId, Long memberId, String memberType) {
+        // 检查是否已加入
+        LambdaQueryWrapper<SocialCircleMember> check = new LambdaQueryWrapper<SocialCircleMember>()
+                .eq(SocialCircleMember::getCircleId, circleId)
+                .eq(SocialCircleMember::getMemberId, memberId);
+        SocialCircleMember existing = socialCircleMemberMapper.selectOne(check);
+        if (existing != null) {
+            return true; // 已加入,视为成功
+        }
+
+        // 添加成员记录
+        SocialCircleMember member = new SocialCircleMember();
+        member.setCircleId(circleId);
+        member.setMemberId(memberId);
+        member.setMemberType(memberType);
+        member.setJoinedAt(new Date());
+        socialCircleMemberMapper.insert(member);
+
+        // 更新圈子成员数
+        SocialCircle circle = this.getById(circleId);
+        if (circle != null) {
+            int count = circle.getMemberCount() != null ? circle.getMemberCount() + 1 : 1;
+            circle.setMemberCount(count);
+            this.updateById(circle);
+        }
+
+        return true;
+    }
+
+    /**
+     * 退出圈子
+     */
+    @Transactional
+    public boolean leaveCircle(Long circleId, Long memberId, String memberType) {
+        LambdaQueryWrapper<SocialCircleMember> wrapper = new LambdaQueryWrapper<SocialCircleMember>()
+                .eq(SocialCircleMember::getCircleId, circleId)
+                .eq(SocialCircleMember::getMemberId, memberId)
+                .eq(SocialCircleMember::getMemberType, memberType);
+        int deleted = socialCircleMemberMapper.delete(wrapper);
+        if (deleted > 0) {
+            // 更新圈子成员数
+            SocialCircle circle = this.getById(circleId);
+            if (circle != null && circle.getMemberCount() != null && circle.getMemberCount() > 0) {
+                circle.setMemberCount(circle.getMemberCount() - 1);
+                this.updateById(circle);
+            }
+        }
+        return deleted > 0;
+    }
+
+    /**
+     * 创建圈子并自动加入
+     */
+    @Transactional
+    public SocialCircle createCircle(String name, String type, String matchSource,
+                                     Long sourceId, Long creatorId, String creatorType) {
+        SocialCircle circle = new SocialCircle();
+        circle.setName(name);
+        circle.setType(type);
+        circle.setMatchSource(matchSource);
+        circle.setSourceId(sourceId);
+        circle.setMemberCount(1);
+        circle.setCreatedAt(new Date());
+        this.save(circle);
+
+        // 创建者自动加入
+        SocialCircleMember member = new SocialCircleMember();
+        member.setCircleId(circle.getId());
+        member.setMemberId(creatorId);
+        if (creatorType != null) {
+            member.setMemberType(creatorType);
+        } else {
+            member.setMemberType("child");
+        }
+        member.setJoinedAt(new Date());
+        socialCircleMemberMapper.insert(member);
+
+        return circle;
+    }
+}

+ 28 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/DanKnowledgeBaseService.java

@@ -6,9 +6,11 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.etotem.cfc.entity.DanKnowledgeBase;
 import com.etotem.cfc.entity.DanKnowledgeBaseDimension;
 import com.etotem.cfc.entity.DanKnowledgeBaseTag;
+import com.etotem.cfc.entity.ProductDimensionConfig;
 import com.etotem.cfc.mapper.DanKnowledgeBaseDimensionMapper;
 import com.etotem.cfc.mapper.DanKnowledgeBaseMapper;
 import com.etotem.cfc.mapper.DanKnowledgeBaseTagMapper;
+import com.etotem.cfc.mapper.ProductDimensionConfigMapper;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -27,6 +29,9 @@ public class DanKnowledgeBaseService extends ServiceImpl<DanKnowledgeBaseMapper,
     @Resource
     private DanKnowledgeBaseTagMapper danKnowledgeBaseTagMapper;
 
+    @Resource
+    private ProductDimensionConfigMapper productDimensionConfigMapper;
+
     @Resource
     private DifySyncService difySyncService;
 
@@ -136,4 +141,27 @@ public class DanKnowledgeBaseService extends ServiceImpl<DanKnowledgeBaseMapper,
             danKnowledgeBaseTagMapper.insert(rel);
         }
     }
+
+    public List<String> getDimensionCodesForKnowledge(Long knowledgeId) {
+        List<DanKnowledgeBaseDimension> dims = danKnowledgeBaseDimensionMapper.selectList(
+            new LambdaQueryWrapper<DanKnowledgeBaseDimension>()
+                .eq(DanKnowledgeBaseDimension::getKnowledgeId, knowledgeId));
+        if (dims == null || dims.isEmpty()) return new java.util.ArrayList<>();
+        List<Long> dimIds = dims.stream().map(DanKnowledgeBaseDimension::getDimensionId).collect(Collectors.toList());
+        ProductDimensionConfig cfg = new ProductDimensionConfig();
+        cfg.setId(dimIds.get(0));
+        List<ProductDimensionConfig> configs = new java.util.ArrayList<>();
+        for (Long dimId : dimIds) {
+            ProductDimensionConfig c = new ProductDimensionConfig();
+            c.setId(dimId);
+            configs.add(c);
+        }
+        return configs.stream()
+            .map(c -> {
+                ProductDimensionConfig full = productDimensionConfigMapper.selectById(c.getId());
+                return full != null ? full.getDimensionCode() : null;
+            })
+            .filter(code -> code != null)
+            .collect(Collectors.toList());
+    }
 }

+ 406 - 12
cfc-backend/src/main/java/com/etotem/cfc/service/EnergyService.java

@@ -183,7 +183,16 @@ public class EnergyService {
         dto.setMindScore(calcParentMind(parent));
         dto.setWisdomScore(calcParentWisdom(parent));
         dto.setActionScore(calcParentAction(parent));
-        dto.setWealthScore(calcParentWealth(parent));
+        dto.setWealthScore(calcParentWealth(parent, dto));
+
+        // 心的子维度(家长暂无数据源,三方面均回0)
+        dto.setHeartEmotionStable(calcHeartEmotionStableForParent());
+        dto.setHeartUnderstanding(calcHeartUnderstandingForParent());
+        dto.setHeartLegacy(calcHeartLegacy());
+
+        // v2.0 三轮算法:克环 → 生环(链式调用,避免迭代收敛)
+        applyAllRestraints(dto);
+        applyAllBoosts(dto);
 
         int overall = (safeScore(dto.getBodyScore()) + safeScore(dto.getMindScore())
                 + safeScore(dto.getWisdomScore()) + safeScore(dto.getActionScore())
@@ -249,17 +258,36 @@ public class EnergyService {
                 Arrays.asList("习惯", "好习惯", "自律", "家务", "自理", "作息"), 365);
     }
 
-    /** 家长 富 — 积分积累效率 */
-    private int calcParentWealth(User parent) {
+    /** 家长 富 — 三子维度加权 */
+    private int calcParentWealth(User parent, MemberEnergyDTO dto) {
+        int income = calcParentWealthIncome(parent);
+        int achievement = calcParentWealthAchievement(parent);
+        int network = calcParentWealthNetwork(parent);
+        dto.setWealthIncome(income);
+        dto.setWealthAchievement(achievement);
+        dto.setWealthNetwork(network);
+        return (int) Math.round(income * 0.5 + achievement * 0.3 + network * 0.2);
+    }
+
+    /** 家长富-金钱收入:积分总量/500 */
+    private int calcParentWealthIncome(User parent) {
         Integer totalPoints = parent.getTotalPoints();
         if (totalPoints == null) totalPoints = 0;
-
-        // 参考上限:假设 500 积分为满分
         int refMax = 500;
         int score = Math.min(totalPoints * 100 / Math.max(refMax, 1), 100);
         return Math.max(score, 0);
     }
 
+    /** 家长富-社会成就:第一阶段返回0占位 */
+    private int calcParentWealthAchievement(User parent) {
+        return 0;
+    }
+
+    /** 家长富-资源网络:第一阶段返回0占位 */
+    private int calcParentWealthNetwork(User parent) {
+        return 0;
+    }
+
     // ==================== 孩子能量计算 ====================
 
     private MemberEnergyDTO calcChildEnergy(FamilyMember child) {
@@ -269,11 +297,22 @@ public class EnergyService {
         dto.setName(child.getNickname());
         dto.setAvatar(null);  // 孩子没有独立头像,用默认
 
-        dto.setBodyScore(calcChildBody(child));
+        int bodyScore = calcChildBody(child);
+        dto.setBodyScore(bodyScore);
         dto.setMindScore(calcChildMind(child));
         dto.setWisdomScore(calcChildWisdom(child));
         dto.setActionScore(calcChildAction(child));
-        dto.setWealthScore(calcChildWealth(child));
+        dto.setWealthScore(calcChildWealth(child, dto));
+
+        // 心的子维度(v2.0):用最近一次 DAN 结果填入情绪稳定/理解包容/传承传递
+        DanAssessmentResult danForHeart = findLatestDanResult(child.getId());
+        dto.setHeartEmotionStable(calcHeartEmotionStableForChild(child.getId(), danForHeart));
+        dto.setHeartUnderstanding(calcHeartUnderstandingForChild(child.getId(), danForHeart));
+        dto.setHeartLegacy(calcHeartLegacy());
+
+        // v2.0 三轮算法:克环 → 生环(链式调用,避免迭代收敛)
+        applyAllRestraints(dto);
+        applyAllBoosts(dto);
 
         int overall = (safeScore(dto.getBodyScore()) + safeScore(dto.getMindScore())
                 + safeScore(dto.getWisdomScore()) + safeScore(dto.getActionScore())
@@ -513,11 +552,56 @@ public class EnergyService {
         return Math.min((int) Math.round(streakScore * 0.4 + habitScore * 0.6), 100);
     }
 
-    /** 孩子 富 — 积分获取效率 */
-    private int calcChildWealth(FamilyMember child) {
+    /** 孩子 富 — 三子维度加权 */
+    private int calcChildWealth(FamilyMember child, MemberEnergyDTO dto) {
+        int edu = calcChildWealthEducation(child);
+        int social = calcChildWealthSocial(child);
+        int points = calcChildWealthPoints(child);
+        dto.setWealthEducation(edu);
+        dto.setWealthSocial(social);
+        dto.setWealthPoints(points);
+        return (int) Math.round(edu * 0.3 + social * 0.2 + points * 0.5);
+    }
+
+    /** 孩子富-学业成绩:DAN测评最近3次综合分+进步趋势 */
+    private int calcChildWealthEducation(FamilyMember child) {
         Long childId = child.getId();
+        LambdaQueryWrapper<DanAssessmentResult> qw = new LambdaQueryWrapper<DanAssessmentResult>()
+                .eq(DanAssessmentResult::getChildId, childId)
+                .eq(DanAssessmentResult::getStatus, "completed")
+                .orderByDesc(DanAssessmentResult::getAssessmentDate)
+                .last("LIMIT 3");
+        List<DanAssessmentResult> results = danAssessmentResultMapper.selectList(qw);
+        if (results.isEmpty()) return 0;
+
+        double avg = results.stream()
+                .filter(r -> r.getOverallScore() != null)
+                .mapToInt(DanAssessmentResult::getOverallScore)
+                .average()
+                .orElse(0);
+
+        // 进步趋势:最近一次 vs 前面平均
+        if (results.size() >= 2 && results.get(0).getOverallScore() != null && results.get(1).getOverallScore() != null) {
+            int latest = results.get(0).getOverallScore();
+            double prevAvg = results.subList(1, results.size()).stream()
+                    .filter(r -> r.getOverallScore() != null)
+                    .mapToInt(DanAssessmentResult::getOverallScore)
+                    .average()
+                    .orElse(latest);
+            double trend = (latest - prevAvg) / Math.max(prevAvg, 1) * 10;
+            avg += trend;
+        }
+        return clamp((int) Math.round(avg), 0, 100);
+    }
+
+    /** 孩子富-社交筹码:活动参与次数(第一阶段返回0) */
+    private int calcChildWealthSocial(FamilyMember child) {
+        return 0;
+    }
 
-        // 已获得积分: points_log 中正向入账
+    /** 孩子富-规则博弈:积分获取效率 */
+    private int calcChildWealthPoints(FamilyMember child) {
+        Long childId = child.getId();
         Calendar cal = Calendar.getInstance();
         cal.add(Calendar.YEAR, -1);
         Date oneYearAgo = cal.getTime();
@@ -531,7 +615,6 @@ public class EnergyService {
                 .mapToInt(pl -> pl.getAmount() != null ? pl.getAmount() : 0)
                 .sum();
 
-        // 参考上限: 同期可获得的积分上限
         LambdaQueryWrapper<Task> taskWrapper = new LambdaQueryWrapper<Task>()
                 .eq(Task::getChildId, childId)
                 .ne(Task::getIsTemplate, 1)
@@ -546,7 +629,318 @@ public class EnergyService {
         return Math.max(score, 0);
     }
 
-    // ==================== 通用方法 ====================
+    /**
+     * 身克富杠杆:身体底盘制约财富能量(ke-full-design §克② 重写版)
+     * - body=0 && wealth=0 → normal(无数据边界保护)
+     * - 身<50 且 富>身+20 → overdraw(透支预警,不下调)
+     * - 身<40 → penalty(wealth *= 0.5 + 0.5×身/100,文案"健康正在稀释财富能量")
+     * - 否则 → normal(身≥70 且 富≥60:双优;身≥70 且 富<40:创富时机;其他:空)
+     */
+    private int applyBodyWealthRestraint(Integer bodyScore, Integer wealthScore, MemberEnergyDTO dto) {
+        int body = safeScore(bodyScore);
+        int wealth = safeScore(wealthScore);
+
+        if (body == 0 && wealth == 0) {
+            dto.setBodyWealthStatus("normal");
+            dto.setBodyWealthMessage("");
+            return wealth;
+        }
+
+        if (body < 50 && wealth > body + 20) {
+            dto.setBodyWealthStatus("overdraw");
+            dto.setBodyWealthMessage("财富能量超出身体承受范围,注意劳逸结合");
+            return wealth;
+        }
+
+        if (body < 40) {
+            double factor = 0.5 + 0.5 * body / 100.0;
+            int adjusted = (int) Math.round(wealth * factor);
+            dto.setBodyWealthStatus("penalty");
+            dto.setBodyWealthMessage("健康正在稀释财富能量,建议优先关注身体健康");
+            return Math.max(adjusted, 0);
+        }
+
+        dto.setBodyWealthStatus("normal");
+        if (body >= 70 && wealth >= 60) {
+            dto.setBodyWealthMessage("身体是财富的底盘,您处于双优状态");
+        } else if (body >= 70 && wealth < 40) {
+            dto.setBodyWealthMessage("您的健康基础很好,是启动创富的好时机");
+        } else {
+            dto.setBodyWealthMessage("");
+        }
+        return wealth;
+    }
+
+    // ==================== 五行相克(v2.0 克环其余4个 + 统一入口) ====================
+
+    /**
+     * 行克身(木克土)—— 行动社交对身体健康的制约
+     * - 行>75 且 身<40 → overdraw(身 *= 0.5 + 0.5×身/100,透支)
+     * - 行>60 且 身>60 → balanced(不下调,身心兼修)
+     * - 身>70 且 行<30 → cautious(不下调,缺少行动提示)
+     * - 否则 → normal
+     */
+    private int applyActionBodyRestraint(Integer actionScore, Integer bodyScore, MemberEnergyDTO dto) {
+        int action = safeScore(actionScore);
+        int body = safeScore(bodyScore);
+
+        if (action > 75 && body < 40) {
+            double penalty = 0.5 + 0.5 * (body / 100.0);
+            int adjusted = (int) Math.round(body * penalty);
+            dto.setActionBodyStatus("overdraw");
+            dto.setActionBodyMessage("行动力很强,但身体在报警。适度暂停,是另一种前进");
+            return Math.min(adjusted, body);
+        }
+        if (action > 60 && body > 60) {
+            dto.setActionBodyStatus("balanced");
+            dto.setActionBodyMessage("身心兼修,动态平衡");
+            return body;
+        }
+        if (body > 70 && action < 30) {
+            dto.setActionBodyStatus("cautious");
+            dto.setActionBodyMessage("身体底子好,但缺少行动。出门走走,也是养生的一部分");
+            return body;
+        }
+        dto.setActionBodyStatus("normal");
+        dto.setActionBodyMessage("");
+        return body;
+    }
+
+    /**
+     * 富克心(水克火)—— 财富对心(情绪/包容/传承)三层面的侵蚀
+     * 检测优先级:情绪<40→eruption / 包容<40→judgmental / 传承<20→materialized / 富>70心>65→nourished / normal
+     */
+    private int applyWealthHeartRestraint(Integer wealthScore, Integer heartScore, MemberEnergyDTO dto) {
+        int wealth = safeScore(wealthScore);
+        int heart = safeScore(heartScore);
+
+        int emotionStable = dto.getHeartEmotionStable() != null ? dto.getHeartEmotionStable() : 50;
+        int understanding = dto.getHeartUnderstanding() != null ? dto.getHeartUnderstanding() : 50;
+        int legacy = dto.getHeartLegacy() != null ? dto.getHeartLegacy() : 50;
+
+        if (wealth > 70 && emotionStable < 40) {
+            double penalty = 0.4 + 0.6 * (emotionStable / 100.0);
+            int adjusted = (int) Math.round(heart * penalty);
+            dto.setWealthHeartStatus("eruption");
+            dto.setWealthHeartMessage("财富给了底气,也偷走了对家人的耐心。先稳住情绪,再谈其他");
+            return Math.min(adjusted, heart);
+        }
+
+        if (wealth > 60 && understanding < 40) {
+            dto.setWealthHeartStatus("judgmental");
+            dto.setWealthHeartMessage("站在高处久了,可能忘了普通人走路的难处");
+            return heart;
+        }
+
+        if (wealth > 60 && legacy < 20) {
+            dto.setWealthHeartStatus("materialized");
+            dto.setWealthHeartMessage("留给孩子的只有存款,价值观也需要传下去");
+            return heart;
+        }
+
+        if (wealth > 70 && heart > 65) {
+            dto.setWealthHeartStatus("nourished");
+            dto.setWealthHeartMessage("您的财富正在滋养家庭情感,这是最好的传承");
+            return heart;
+        }
+
+        dto.setWealthHeartStatus("normal");
+        dto.setWealthHeartMessage("");
+        return heart;
+    }
+
+    /**
+     * 心克智(火克金)—— 情感关爱对理性培养的制约
+     * - 心>70 且 智<40 → overprotect(智 *= 0.5+0.5×智/100,过度保护)
+     * - 智>70 且 心<40 → cold_wise(不下调,理性冷淡提示)
+     * - 心>60 且 智>60 → balanced(不下调)
+     * - 否则 → normal
+     */
+    private int applyMindWisdomRestraint(Integer mindScore, Integer wisdomScore, MemberEnergyDTO dto) {
+        int mind = safeScore(mindScore);
+        int wisdom = safeScore(wisdomScore);
+
+        if (mind > 70 && wisdom < 40) {
+            double penalty = 0.5 + 0.5 * (wisdom / 100.0);
+            int adjusted = (int) Math.round(wisdom * penalty);
+            dto.setMindWisdomStatus("overprotect");
+            dto.setMindWisdomMessage("放手让孩子试错,是培养智慧的第一步");
+            return Math.min(adjusted, wisdom);
+        }
+
+        if (wisdom > 70 && mind < 40) {
+            dto.setMindWisdomStatus("cold_wise");
+            dto.setMindWisdomMessage("智慧需要温度,不然长大了只会做事不会做人");
+            return wisdom;
+        }
+
+        if (mind > 60 && wisdom > 60) {
+            dto.setMindWisdomStatus("balanced");
+            dto.setMindWisdomMessage("爱与理性并行,是最好的教育");
+            return wisdom;
+        }
+
+        dto.setMindWisdomStatus("normal");
+        dto.setMindWisdomMessage("");
+        return wisdom;
+    }
+
+    /**
+     * 智克行(金克木)—— 认知分析对人际关系的制约
+     * - 智<30 且 行>50 → blind(行 *= 0.6+0.4×智/100,盲目社交下调)
+     * - 智>80 且 行<40 → paralysis(不下调,认知孤独预警)
+     * - 智>60 且 行>60 → balanced(不下调)
+     * - 否则 → normal
+     */
+    private int applyWisdomActionRestraint(Integer wisdomScore, Integer actionScore, MemberEnergyDTO dto) {
+        int wisdom = safeScore(wisdomScore);
+        int action = safeScore(actionScore);
+
+        if (wisdom < 30 && action > 50) {
+            double penalty = 0.6 + 0.4 * (wisdom / 100.0);
+            int adjusted = (int) Math.round(action * penalty);
+            dto.setWisdomActionStatus("blind");
+            dto.setWisdomActionMessage("行动力很强,但有方向会让努力更有效。建议先做一次测评");
+            return Math.min(adjusted, action);
+        }
+
+        if (wisdom > 80 && action < 40) {
+            dto.setWisdomActionStatus("paralysis");
+            dto.setWisdomActionMessage("聪明不等于会相处。朋友不是用逻辑交来的");
+            return action;
+        }
+
+        if (wisdom >= 60 && action >= 60) {
+            dto.setWisdomActionStatus("balanced");
+            dto.setWisdomActionMessage("知行合一状态,继续保持");
+            return action;
+        }
+
+        dto.setWisdomActionStatus("normal");
+        if (action >= 60 && wisdom < 50) {
+            dto.setWisdomActionMessage("行动力很好,建议增加测评了解方向");
+        } else {
+            dto.setWisdomActionMessage("");
+        }
+        return action;
+    }
+
+    /**
+     * 五行相克统一入口 — 按克环顺序:行克身→身克富→富克心→心克智→智克行。
+     * 关键:每个克方法使用前一个克方法调整后的分值(链式调用,避免迭代收敛问题)。
+     */
+    private void applyAllRestraints(MemberEnergyDTO dto) {
+        int bodyAdj = applyActionBodyRestraint(dto.getActionScore(), dto.getBodyScore(), dto);
+        dto.setBodyScore(bodyAdj);
+
+        int wealthAdj = applyBodyWealthRestraint(dto.getBodyScore(), dto.getWealthScore(), dto);
+        dto.setWealthScore(wealthAdj);
+
+        int mindAdj = applyWealthHeartRestraint(dto.getWealthScore(), dto.getMindScore(), dto);
+        dto.setMindScore(mindAdj);
+
+        int wisdomAdj = applyMindWisdomRestraint(dto.getMindScore(), dto.getWisdomScore(), dto);
+        dto.setWisdomScore(wisdomAdj);
+
+        int actionAdj = applyWisdomActionRestraint(dto.getWisdomScore(), dto.getActionScore(), dto);
+        dto.setActionScore(actionAdj);
+    }
+
+    // ==================== 五行相生(v2.0 生环5个 + 统一入口) ====================
+
+    /**
+     * 生增益统一公式:boost = (upstream-50)/100×10,仅当 upstream>60 且 downstream<80 触发,
+     * 上限 80-downstream(留成长空间)。
+     */
+    private int applyBoost(Integer upstreamScore, Integer downstreamScore) {
+        int up = safeScore(upstreamScore);
+        int down = safeScore(downstreamScore);
+        if (up > 60 && down < 80) {
+            double boost = (up - 50) / 100.0 * 10;
+            int room = 80 - down;
+            int gain = (int) Math.round(boost);
+            if (gain > room) gain = room;
+            if (gain < 0) gain = 0;
+            return down + gain;
+        }
+        return down;
+    }
+
+    /** 心生身(火生土) */
+    private int applyMindBodyBoost(Integer mindScore, Integer bodyScore, MemberEnergyDTO dto) {
+        return applyBoost(mindScore, bodyScore);
+    }
+
+    /** 身生智(土生金) */
+    private int applyBodyWisdomBoost(Integer bodyScore, Integer wisdomScore, MemberEnergyDTO dto) {
+        return applyBoost(bodyScore, wisdomScore);
+    }
+
+    /** 智生富(金生水) */
+    private int applyWisdomWealthBoost(Integer wisdomScore, Integer wealthScore, MemberEnergyDTO dto) {
+        return applyBoost(wisdomScore, wealthScore);
+    }
+
+    /** 富生行(水生木) */
+    private int applyWealthActionBoost(Integer wealthScore, Integer actionScore, MemberEnergyDTO dto) {
+        return applyBoost(wealthScore, actionScore);
+    }
+
+    /** 行生心(木生火) */
+    private int applyActionMindBoost(Integer actionScore, Integer mindScore, MemberEnergyDTO dto) {
+        return applyBoost(actionScore, mindScore);
+    }
+
+    /**
+     * 五行相生统一入口 — 按生环顺序:心生身→身生智→智生富→富生行→行生心。
+     * 链式调用,每个生方法使用前一个的输出作为下游输入。
+     */
+    private void applyAllBoosts(MemberEnergyDTO dto) {
+        dto.setBodyScore(applyMindBodyBoost(dto.getMindScore(), dto.getBodyScore(), dto));
+        dto.setWisdomScore(applyBodyWisdomBoost(dto.getBodyScore(), dto.getWisdomScore(), dto));
+        dto.setWealthScore(applyWisdomWealthBoost(dto.getWisdomScore(), dto.getWealthScore(), dto));
+        dto.setActionScore(applyWealthActionBoost(dto.getWealthScore(), dto.getActionScore(), dto));
+        dto.setMindScore(applyActionMindBoost(dto.getActionScore(), dto.getMindScore(), dto));
+    }
+
+    // ==================== 心的子维度(v2.0 心三层拆分) ====================
+
+    /** 心·情绪稳定 — 孩子:DAN EMI 三子分(emotionManagement+resilience+stressCoping)均值。
+     *  文档原 emotionCheckinService 在本项目不存在,用此现有 DAN 字段做代理实现。 */
+    private int calcHeartEmotionStableForChild(Long childId, DanAssessmentResult dan) {
+        if (dan == null) return 50;
+        Integer ems = dan.getEmotionManagementScore();
+        Integer resilience = dan.getResilienceScore();
+        Integer stress = dan.getStressCopingScore();
+        if (ems == null || resilience == null || stress == null) return 50;
+        return clamp((ems + resilience + stress) / 3, 0, 100);
+    }
+
+    /** 心·情绪稳定 — 家长:暂无数据,回0(ke-full-design 维度表显式标注) */
+    private int calcHeartEmotionStableForParent() {
+        return 0;
+    }
+
+    /** 心·理解包容 — 孩子:DAN (empathy+socialAdaptability)/2。
+     *  文档原家庭天盘 helper 不存在,按 doc §克③ line 295-296 用 100% DAN fallback。 */
+    private int calcHeartUnderstandingForChild(Long childId, DanAssessmentResult dan) {
+        if (dan == null) return 50;
+        Integer empathy = dan.getEmpathyScore();
+        Integer social = dan.getSocialAdaptabilityScore();
+        if (empathy == null || social == null) return 50;
+        return clamp((empathy + social) / 2, 0, 100);
+    }
+
+    /** 心·理解包容 — 家长:暂无数据,回0 */
+    private int calcHeartUnderstandingForParent() {
+        return 0;
+    }
+
+    /** 心·传承传递 — 第一阶段回0占位(孩子+家长通用) */
+    private int calcHeartLegacy() {
+        return 0;
+    }
+
 
     /**
      * 计算指定成员的任务完成率得分 (0-100)

+ 113 - 4
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyContextService.java

@@ -1,14 +1,16 @@
 package com.etotem.cfc.service;
 
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.dto.ChildInfoDTO;
-import com.etotem.cfc.entity.HealthReport;
-import com.etotem.cfc.entity.ReportSurvey;
-import com.etotem.cfc.entity.User;
-import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.*;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
 import java.util.*;
+import java.util.stream.Collectors;
 
 /**
  * 家庭上下文组装服务
@@ -17,6 +19,8 @@ import java.util.*;
 @Service
 public class FamilyContextService {
 
+    private static final Logger log = LoggerFactory.getLogger(FamilyContextService.class);
+
     @Resource
     private UserService userService;
 
@@ -32,6 +36,21 @@ public class FamilyContextService {
     @Resource
     private ReportSurveyService reportSurveyService;
 
+    @Resource
+    private FiveDimensionScoreService fiveDimensionScoreService;
+
+    @Resource
+    private DanAssessmentResultMapper danAssessmentResultMapper;
+
+    @Resource
+    private ProductOrderMapper productOrderMapper;
+
+    @Resource
+    private OrderItemMapper orderItemMapper;
+
+    @Resource
+    private ProductMapper productMapper;
+
     /**
      * 构建当前用户的家庭上下文
      *
@@ -102,6 +121,28 @@ public class FamilyContextService {
         }
         ctx.put("children", childList);
 
+        // 3. 家庭成员五维能量分数(用于 AI 回答关于能量状态的问题)
+        if (currentUser != null && currentUser.getFamilyId() != null) {
+            Map<String, Integer> dimensionScores = getDimensionScores(currentUser.getFamilyId());
+            if (dimensionScores != null && !dimensionScores.isEmpty()) {
+                ctx.put("dimensionScores", dimensionScores);
+            }
+        }
+
+        // 4. 最近认知测评结果(用于 AI 了解孩子认知发展水平)
+        Map<String, Object> cognitiveResult = getRecentCognitiveResult(userId);
+        if (cognitiveResult != null && !cognitiveResult.isEmpty()) {
+            ctx.put("recentCognitiveResult", cognitiveResult);
+        }
+
+        // 5. 已购商品标签(用于 AI 推荐个性化商品)
+        if (currentUser != null && currentUser.getFamilyId() != null) {
+            List<String> purchasedTags = getPurchasedProductTags(currentUser.getFamilyId());
+            if (purchasedTags != null && !purchasedTags.isEmpty()) {
+                ctx.put("purchasedProductTags", purchasedTags);
+            }
+        }
+
         return ctx;
     }
 
@@ -149,4 +190,72 @@ public class FamilyContextService {
 
         return ctx;
     }
+
+    /**
+     * 获取家庭成员的最新五维能量分数
+     */
+    private Map<String, Integer> getDimensionScores(Long familyId) {
+        try {
+            return fiveDimensionScoreService.getLatestByFamilyMap(familyId);
+        } catch (Exception e) {
+            log.warn("获取维度分数失败: {}", e.getMessage());
+            return Collections.emptyMap();
+        }
+    }
+
+    /**
+     * 获取最近认知测评结果(取第一个孩子的最新 completed 记录)
+     */
+    private Map<String, Object> getRecentCognitiveResult(Long userId) {
+        try {
+            List<ChildInfoDTO> children = userService.getChildren(userId);
+            if (children == null || children.isEmpty()) return null;
+            Long childId = children.get(0).getId();
+            DanAssessmentResult result = danAssessmentResultMapper.selectOne(
+                    new LambdaQueryWrapper<DanAssessmentResult>()
+                            .eq(DanAssessmentResult::getFamilyMemberId, childId)
+                            .eq(DanAssessmentResult::getStatus, "completed")
+                            .orderByDesc(DanAssessmentResult::getAssessmentDate)
+                            .last("LIMIT 1"));
+            if (result == null) return null;
+            Map<String, Object> map = new LinkedHashMap<>();
+            map.put("overallScore", result.getOverallScore());
+            map.put("assessmentDate", result.getAssessmentDate() != null ? result.getAssessmentDate().toString() : "");
+            map.put("source", result.getSource());
+            return map;
+        } catch (Exception e) {
+            log.warn("获取认知测评结果失败: {}", e.getMessage());
+            return null;
+        }
+    }
+
+    /**
+     * 获取已购商品标签(最近10个已完成订单的商品 domain)
+     */
+    private List<String> getPurchasedProductTags(Long familyId) {
+        try {
+            List<ProductOrder> orders = productOrderMapper.selectList(
+                    new LambdaQueryWrapper<ProductOrder>()
+                            .eq(ProductOrder::getFamilyId, familyId)
+                            .eq(ProductOrder::getStatus, "completed")
+                            .orderByDesc(ProductOrder::getCreatedAt)
+                            .last("LIMIT 10"));
+            if (orders == null || orders.isEmpty()) return Collections.emptyList();
+
+            Set<String> tags = new LinkedHashSet<>();
+            for (ProductOrder order : orders) {
+                List<OrderItem> items = orderItemMapper.findByOrderId(order.getId());
+                for (OrderItem item : items) {
+                    Product product = productMapper.selectById(item.getProductId());
+                    if (product != null && product.getDomain() != null) {
+                        tags.add(product.getDomain());
+                    }
+                }
+            }
+            return new ArrayList<>(tags);
+        } catch (Exception e) {
+            log.warn("获取已购商品标签失败: {}", e.getMessage());
+            return Collections.emptyList();
+        }
+    }
 }

+ 4 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java

@@ -129,6 +129,10 @@ public class FamilyMemberService {
                 throw new IllegalArgumentException("出生日期格式错误,应为yyyy-MM-dd");
             }
         }
+        member.setBirthHour(dto.getBirthHour());
+        member.setBirthWeight(dto.getBirthWeight());
+        member.setBirthPlace(dto.getBirthPlace());
+        member.setIsCesarean(dto.getIsCesarean());
         member.setRelationshipType(relationshipTypeKey);
         member.setGeneration(genLevel.getOffset());
         member.setIsSpouse(isSpouse);

+ 63 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthAnalysisService.java

@@ -1,10 +1,14 @@
 package com.etotem.cfc.service;
 
 import com.etotem.cfc.dto.HealthAnalysisResult;
+import com.etotem.cfc.dto.RecommendationQuery;
+import com.etotem.cfc.dto.RecommendationResult;
 import com.etotem.cfc.entity.HealthGutFlora;
 import com.etotem.cfc.entity.HealthIndicator;
 import com.etotem.cfc.entity.HealthReport;
 import com.etotem.cfc.entity.NutritionDeficiencyRecord;
+import com.etotem.cfc.entity.ProductRecommendationLog;
+import com.etotem.cfc.mapper.ProductRecommendationLogMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 
@@ -22,6 +26,12 @@ public class HealthAnalysisService {
     @Resource
     private NutritionDeficiencyService nutritionDeficiencyService;
 
+    @Resource
+    private RecommendationService recommendationService;
+
+    @Resource
+    private ProductRecommendationLogMapper productRecommendationLogMapper;
+
     public HealthAnalysisResult analyze(Long reportId) {
         Map<String, Object> detail = healthReportService.getReportDetail(reportId);
         HealthReport report = (HealthReport) detail.get("report");
@@ -223,6 +233,44 @@ public class HealthAnalysisService {
 
         log.info("健康分析完成: reportId={}, findings={}, tags={}, deficiencyRecords={}",
                 reportId, findings.size(), nutritionTags, dRecords.size());
+
+        final Long finalUserId = report.getUserId();
+        final HealthAnalysisResult finalResult = result;
+        try {
+            new Thread(() -> {
+                try {
+                    List<String> dimensionNeeds = new ArrayList<>();
+                    if (finalResult != null) {
+                        if (finalResult.getNutritionScore() != null && finalResult.getNutritionScore() < 60) {
+                            dimensionNeeds.add("body");
+                        }
+                        if (finalResult.getGutHealthScore() != null && finalResult.getGutHealthScore() < 60) {
+                            dimensionNeeds.add("body");
+                        }
+                    }
+                    for (String dim : dimensionNeeds) {
+                        List<RecommendationResult> recs = recommendationService.search(
+                            buildRecommendationQueryForDimension(dim, finalUserId, 3));
+                        for (RecommendationResult rec : recs) {
+                            ProductRecommendationLog logEntity = new ProductRecommendationLog();
+                            logEntity.setUserId(finalUserId);
+                            logEntity.setProductId(rec.getId());
+                            logEntity.setScene("report_upload");
+                            logEntity.setReason("健康报告分析触发:" + dim);
+                            logEntity.setMatchScore(null);
+                            logEntity.setCreatedAt(new Date());
+                            productRecommendationLogMapper.insert(logEntity);
+                        }
+                    }
+                    log.info("健康报告关联推荐已生成,userId={}, 维度数={}", finalUserId, dimensionNeeds.size());
+                } catch (Exception e) {
+                    log.warn("健康报告推荐生成失败: userId={}, error={}", finalUserId, e.getMessage());
+                }
+            }).start();
+        } catch (Exception e) {
+            log.warn("启动推荐线程失败: {}", e.getMessage());
+        }
+
         return result;
     }
 
@@ -231,4 +279,19 @@ public class HealthAnalysisService {
         if (report == null) return null;
         return analyze(report.getId());
     }
+
+    private RecommendationQuery buildRecommendationQueryForDimension(String dimensionCode, Long userId, int limit) {
+        RecommendationQuery query = new RecommendationQuery();
+        List<String> tags = new ArrayList<>();
+        if ("body".equals(dimensionCode)) {
+            tags.add("健康");
+            tags.add("营养");
+            tags.add("体质");
+        }
+        query.setNutritionTags(tags);
+        query.setTypes(Arrays.asList("product"));
+        query.setLimit(limit);
+        query.setUserId(userId);
+        return query;
+    }
 }

+ 23 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/PendingRefundService.java

@@ -3,7 +3,9 @@ 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.PendingRefund;
+import com.etotem.cfc.entity.ProductOrder;
 import com.etotem.cfc.mapper.PendingRefundMapper;
+import com.etotem.cfc.mapper.ProductOrderMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
@@ -27,6 +29,9 @@ public class PendingRefundService {
     @Resource
     private CommissionService commissionService;
 
+    @Resource
+    private ProductOrderMapper productOrderMapper;
+
     /**
      * 创建待退款记录
      * @param userId 用户 ID
@@ -133,6 +138,20 @@ public class PendingRefundService {
             refund.setRemark("管理员中止退款");
             refund.setUpdatedAt(new Date());
             pendingRefundMapper.updateById(refund);
+
+            // 恢复对应订单的退款状态
+            if ("product".equals(refund.getOrderType()) && refund.getOrderNo() != null) {
+                LambdaQueryWrapper<ProductOrder> wrapper = new LambdaQueryWrapper<>();
+                wrapper.eq(ProductOrder::getOrderNo, refund.getOrderNo());
+                ProductOrder order = productOrderMapper.selectOne(wrapper);
+                if (order != null) {
+                    order.setRefundStatus(1);
+                    order.setStatus("refunding");
+                    order.setUpdatedAt(new Date());
+                    productOrderMapper.updateById(order);
+                    log.info("已恢复订单退款状态: orderNo={}", refund.getOrderNo());
+                }
+            }
         }
     }
 
@@ -150,7 +169,7 @@ public class PendingRefundService {
     /**
      * 获取所有待退款列表(管理员)
      */
-    public Page<PendingRefund> getAllPendingRefunds(String status, int page, int size) {
+    public Page<PendingRefund> getAllPendingRefunds(String status, String orderType, int page, int size) {
         Page<PendingRefund> pageParam = new Page<>(page, size);
         LambdaQueryWrapper<PendingRefund> wrapper = new LambdaQueryWrapper<PendingRefund>()
                 .orderByDesc(PendingRefund::getCreatedAt);
@@ -158,6 +177,9 @@ public class PendingRefundService {
         if (status != null && !status.isEmpty()) {
             wrapper.eq(PendingRefund::getStatus, status);
         }
+        if (orderType != null && !orderType.isEmpty()) {
+            wrapper.eq(PendingRefund::getOrderType, orderType);
+        }
         
         return pendingRefundMapper.selectPage(pageParam, wrapper);
     }

+ 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;
+    }
 }

+ 24 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java

@@ -23,6 +23,7 @@ import org.springframework.stereotype.Service;
 import javax.annotation.Resource;
 import java.text.SimpleDateFormat;
 import java.util.ArrayList;
+import java.util.Calendar;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
@@ -413,11 +414,12 @@ public class ProductOrderService {
         } catch (Exception e) {
             if (e.getMessage() != null && e.getMessage().contains("NOT_ENOUGH")) {
                 pendingRefundService.createPendingRefund(order.getBuyerId(), null, order.getTotalAmount(), "product", orderNo);
-                order.setStatus("refunding");
+                order.setStatus("refund_pending");
+                order.setRefundStatus(4);
                 order.setUpdatedAt(new Date());
                 orderMapper.updateById(order);
                 log.info("微信退款余额不足,已加入待退款队列: orderNo={}", orderNo);
-                return Result.success("余额不足,退款已加入等待队列,到账后将自动退款");
+                return Result.success("⚠️ 商户余额不足,退款已加入等待队列,到账后将自动退款");
             }
             log.error("微信退款失败: orderNo={}, error={}", orderNo, e.getMessage());
             return Result.error("退款失败: " + e.getMessage());
@@ -555,13 +557,32 @@ public class ProductOrderService {
         return Result.success(list.stream().map(ProductOrderDTO::from).collect(Collectors.toList()));
     }
 
-    public Result<Map<String, Object>> adminOrderPage(int page, int size, String status) {
+    public Result<Map<String, Object>> adminOrderPage(int page, int size, String status, String keyword, String startDate, String endDate) {
         Page<ProductOrder> pageParam = new Page<>(page, size);
         LambdaQueryWrapper<ProductOrder> wrapper = new LambdaQueryWrapper<ProductOrder>()
             .orderByDesc(ProductOrder::getCreatedAt);
         if (status != null && !status.isEmpty()) {
             wrapper.eq(ProductOrder::getStatus, status);
         }
+        if (keyword != null && !keyword.trim().isEmpty()) {
+            String kw = keyword.trim();
+            wrapper.and(w -> w.like(ProductOrder::getProductName, kw).or().like(ProductOrder::getOrderNo, kw));
+        }
+        if (startDate != null && !startDate.isEmpty()) {
+            try {
+                SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+                wrapper.ge(ProductOrder::getCreatedAt, sdf.parse(startDate));
+            } catch (Exception ignored) {}
+        }
+        if (endDate != null && !endDate.isEmpty()) {
+            try {
+                SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+                Calendar cal = Calendar.getInstance();
+                cal.setTime(sdf.parse(endDate));
+                cal.add(Calendar.DAY_OF_MONTH, 1);
+                wrapper.lt(ProductOrder::getCreatedAt, cal.getTime());
+            } catch (Exception ignored) {}
+        }
         Page<ProductOrder> result = orderMapper.selectPage(pageParam, wrapper);
         List<ProductOrderDTO> records = result.getRecords().stream()
             .map(ProductOrderDTO::from)

+ 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;
+    }
+}

+ 5 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java

@@ -36,6 +36,9 @@ public class ProductService {
     @Resource
     private AssessmentProductService assessmentProductService;
 
+    @Resource
+    private PpointService ppointService;
+
     public Result<Map<String, Object>> list(ProductListQueryDTO query, Long userId) {
         Page<Product> page = new Page<>(query.getPage(), query.getSize());
         LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<Product>()
@@ -80,7 +83,8 @@ public class ProductService {
         if (!"on_shelf".equals(product.getStatus()) && !"approved".equals(product.getStatus())) {
             return Result.error("商品未上架");
         }
-        return Result.success(ProductDTO.from(product));
+        int effectivePpoint = ppointService.getEffectivePpoint(id);
+        return Result.success(ProductDTO.from(product, false, null, effectivePpoint, effectivePpoint > 0 ? "effective" : "default"));
     }
 
     public Result<ProductDTO> create(Product product, Long vendorId) {

+ 176 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/RepurchaseReminderService.java

@@ -0,0 +1,176 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.ProductOrder;
+import com.etotem.cfc.entity.RepurchaseReminderConfig;
+import com.etotem.cfc.entity.RepurchaseReminderRecord;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.ProductOrderMapper;
+import com.etotem.cfc.mapper.RepurchaseReminderConfigMapper;
+import com.etotem.cfc.mapper.RepurchaseReminderRecordMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+@Service
+public class RepurchaseReminderService {
+
+    private static final Logger log = LoggerFactory.getLogger(RepurchaseReminderService.class);
+
+    @Resource
+    private ProductOrderMapper productOrderMapper;
+
+    @Resource
+    private ProductMapper productMapper;
+
+    @Resource
+    private RepurchaseReminderConfigMapper reminderConfigMapper;
+
+    @Resource
+    private RepurchaseReminderRecordMapper reminderRecordMapper;
+
+    /**
+     * 每日 09:00 扫描到期复购提醒
+     */
+    @Scheduled(cron = "0 0 9 * * ?")
+    public void scanAndCreateReminders() {
+        log.info("开始扫描复购提醒...");
+
+        // 1. 查询 30~60 天前已完成的订单
+        Calendar cal = Calendar.getInstance();
+        cal.add(Calendar.DAY_OF_YEAR, -60);
+        Date fromDate = cal.getTime();
+        cal.add(Calendar.DAY_OF_YEAR, 30);
+        Date toDate = cal.getTime();
+
+        List<ProductOrder> orders = productOrderMapper.selectList(
+                new LambdaQueryWrapper<ProductOrder>()
+                        .eq(ProductOrder::getStatus, "completed")
+                        .ge(ProductOrder::getCreatedAt, fromDate)
+                        .lt(ProductOrder::getCreatedAt, toDate));
+
+        if (orders.isEmpty()) {
+            log.info("无到期复购订单");
+            return;
+        }
+
+        // 2. 加载所有启用的 config
+        List<RepurchaseReminderConfig> configs = reminderConfigMapper.selectList(
+                new LambdaQueryWrapper<RepurchaseReminderConfig>()
+                        .eq(RepurchaseReminderConfig::getEnabled, 1));
+
+        if (configs.isEmpty()) {
+            log.info("无复购提醒配置");
+            return;
+        }
+
+        // 3. 为每个订单匹配 config 并创建提醒
+        int created = 0;
+        for (ProductOrder order : orders) {
+            for (RepurchaseReminderConfig config : configs) {
+                if (!matchesConfig(order, config)) continue;
+
+                // 检查是否已发送过 reminder 且未超出最大次数
+                Long count = reminderRecordMapper.selectCount(
+                        new LambdaQueryWrapper<RepurchaseReminderRecord>()
+                                .eq(RepurchaseReminderRecord::getUserId, order.getBuyerId())
+                                .eq(RepurchaseReminderRecord::getProductId, order.getProductId()));
+
+                int maxReminders = config.getMaxReminders() != null ? config.getMaxReminders() : 3;
+                if (count != null && count >= maxReminders) {
+                    continue;
+                }
+
+                RepurchaseReminderRecord record = new RepurchaseReminderRecord();
+                record.setUserId(order.getBuyerId());
+                record.setProductId(order.getProductId());
+                record.setOrderId(order.getId());
+                record.setReminderDays(config.getReminderDays() != null ? config.getReminderDays() : 30);
+                record.setSentAt(new Date());
+                record.setClicked(0);
+                record.setPurchased(0);
+                reminderRecordMapper.insert(record);
+                created++;
+                log.info("已创建复购提醒: userId={}, productId={}", order.getBuyerId(), order.getProductId());
+            }
+        }
+
+        log.info("复购提醒扫描完成,共创建 {} 条", created);
+    }
+
+    /**
+     * 检查订单商品是否匹配提醒配置
+     */
+    private boolean matchesConfig(ProductOrder order, RepurchaseReminderConfig config) {
+        if (config.getProductId() != null) {
+            return config.getProductId().equals(order.getProductId());
+        }
+        if (config.getProductCategory() != null) {
+            Product product = productMapper.selectById(order.getProductId());
+            return product != null && config.getProductCategory().equals(product.getDomain());
+        }
+        return false;
+    }
+
+    /**
+     * 获取用户待处理的复购提醒
+     */
+    public List<Map<String, Object>> getPendingReminders(Long userId) {
+        List<RepurchaseReminderRecord> records = reminderRecordMapper.selectList(
+                new LambdaQueryWrapper<RepurchaseReminderRecord>()
+                        .eq(RepurchaseReminderRecord::getUserId, userId)
+                        .eq(RepurchaseReminderRecord::getPurchased, 0)
+                        .orderByDesc(RepurchaseReminderRecord::getSentAt));
+
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (RepurchaseReminderRecord record : records) {
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("id", record.getId());
+            item.put("productId", record.getProductId());
+            item.put("reminderDays", record.getReminderDays());
+            item.put("sentAt", record.getSentAt());
+            item.put("clicked", record.getClicked());
+
+            Product product = productMapper.selectById(record.getProductId());
+            if (product != null) {
+                item.put("productName", product.getName());
+                item.put("coverImage", product.getCoverImage());
+                item.put("price", product.getPrice());
+            }
+            result.add(item);
+        }
+        return result;
+    }
+
+    /**
+     * 标记提醒为已点击
+     */
+    @Transactional
+    public void onReminderClicked(Long reminderId) {
+        RepurchaseReminderRecord record = reminderRecordMapper.selectById(reminderId);
+        if (record != null) {
+            record.setClicked(1);
+            reminderRecordMapper.updateById(record);
+            log.info("复购提醒已点击: id={}", reminderId);
+        }
+    }
+
+    /**
+     * 标记提醒为已购买
+     */
+    @Transactional
+    public void onReminderPurchased(Long reminderId, Long orderId) {
+        RepurchaseReminderRecord record = reminderRecordMapper.selectById(reminderId);
+        if (record != null) {
+            record.setPurchased(1);
+            reminderRecordMapper.updateById(record);
+            log.info("复购提醒已购买: id={}, orderId={}", reminderId, orderId);
+        }
+    }
+}

+ 37 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java

@@ -24,6 +24,9 @@ import org.springframework.util.DigestUtils;
 import java.nio.charset.StandardCharsets;
 import java.util.*;
 
+import com.etotem.cfc.entity.UserProfileHistory;
+import com.etotem.cfc.mapper.UserProfileHistoryMapper;
+
 
 @Slf4j
 @Service
@@ -59,6 +62,9 @@ private OnboardingService onboardingService;
 @Resource
 private FamilyInvitationService familyInvitationService;
 
+    @Resource
+    private UserProfileHistoryMapper userProfileHistoryMapper;
+
     public LoginResultDTO wechatLogin(WechatLoginDTO dto) {
         // 1. 通过code获取openid
         Map<String, String> sessionData = wechatService.code2Session(dto.getCode());
@@ -1020,11 +1026,42 @@ private FamilyInvitationService familyInvitationService;
         if (dto.getMascot() != null) {
             user.setMascot(dto.getMascot());
         }
+        // 更新兴趣爱好
+        if (dto.getHobbies() != null) {
+            String oldHobbies = user.getHobbies();
+            user.setHobbies(dto.getHobbies());
+            recordProfileChange(userId, "hobbies", oldHobbies, dto.getHobbies(), "user_edit");
+        }
+        // 更新饮食偏好
+        if (dto.getDietPreferences() != null) {
+            String oldDiet = user.getDietPreferences();
+            user.setDietPreferences(dto.getDietPreferences());
+            recordProfileChange(userId, "dietPreferences", oldDiet, dto.getDietPreferences(), "user_edit");
+        }
         user.setUpdatedAt(new Date());
         userMapper.updateById(user);
         return true;
     }
 
+    /**
+     * 记录用户信息变更历史(仅当新旧值不同时写入)
+     */
+    private void recordProfileChange(Long userId, String fieldName,
+                                      String oldValue, String newValue, String source) {
+        if (oldValue == null && newValue == null) return;
+        if (oldValue != null && oldValue.equals(newValue)) return;
+        if (newValue != null && newValue.equals(oldValue)) return;
+        UserProfileHistory history = new UserProfileHistory();
+        history.setUserId(userId);
+        history.setFieldName(fieldName);
+        history.setOldValue(oldValue);
+        history.setNewValue(newValue);
+        history.setSource(source);
+        history.setChangedAt(new Date());
+        history.setCreatedAt(new Date());
+        userProfileHistoryMapper.insert(history);
+    }
+
     /**
      * 从身份证号提取生日
      * 15位:1985年07月08日 -> 1985-07-08

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

@@ -1720,6 +1720,12 @@ CREATE TABLE IF NOT EXISTS family_members (
     avatar           VARCHAR(500) COMMENT '头像',
     gender           VARCHAR(10)  COMMENT '性别: male/female',
     birthday         DATE         COMMENT '出生日期',
+    birth_hour       VARCHAR(16)  COMMENT '出生时辰: 子丑寅卯辰巳午未申酉戌亥',
+    birth_weight     INT          COMMENT '出生体重(克)',
+    birth_place      VARCHAR(128) COMMENT '出生地',
+    is_cesarean      TINYINT      COMMENT '是否剖腹产: 0=顺产, 1=剖腹产',
+    generation       INT          COMMENT '辈分值(0=家庭创建者,+n=向上n代,-n=向下n代)',
+    is_spouse        TINYINT(1)   COMMENT '是否配偶(同辈中)',
     relationship_type VARCHAR(50) NOT NULL COMMENT '关系类型key',
     role_override VARCHAR(20) DEFAULT 'auto' COMMENT '角色覆盖: auto/parent/child/elderly,空=auto',
     show_to_family INT DEFAULT 1 COMMENT '对家庭成员可见: 1=可见, 0=不可见',
@@ -2671,3 +2677,86 @@ CREATE TABLE IF NOT EXISTS tianpan_member_annual_energy (
     INDEX idx_member_id (member_id),
     INDEX idx_year (year)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成员年度能量表';
+
+-- 商品推荐日志表(迁移 80)
+CREATE TABLE IF NOT EXISTS product_recommendation_log (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    user_id BIGINT COMMENT '用户 ID',
+    product_id BIGINT COMMENT '商品 ID',
+    scene VARCHAR(32) COMMENT '触发场景:report_upload/manual/chat',
+    reason VARCHAR(255) COMMENT '推荐理由',
+    match_score DOUBLE COMMENT '匹配分',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_user_id (user_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品推荐日志';
+
+-- 商品维度关联表(迁移 81)
+CREATE TABLE IF NOT EXISTS product_dimension_mapping (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    product_id BIGINT NOT NULL COMMENT '商品 ID',
+    dimension_code VARCHAR(32) NOT NULL COMMENT '维度: body/wisdom/mind/action/wealth',
+    match_score INT DEFAULT 100 COMMENT '匹配度 0-100',
+    match_reason VARCHAR(200) COMMENT '匹配原因',
+    tags VARCHAR(500) COMMENT '推荐标签 JSON',
+    enabled TINYINT DEFAULT 1,
+    created_at DATETIME,
+    updated_at DATETIME,
+    INDEX idx_product (product_id),
+    INDEX idx_dimension (dimension_code)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品维度关联表';
+
+-- 复购提醒发送记录表(迁移 82)
+CREATE TABLE IF NOT EXISTS repurchase_reminder_record (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    user_id BIGINT NOT NULL,
+    product_id BIGINT NOT NULL,
+    order_id BIGINT COMMENT '关联订单 ID',
+    reminder_days INT DEFAULT 30,
+    sent_at DATETIME,
+    clicked TINYINT DEFAULT 0,
+    purchased TINYINT DEFAULT 0,
+    INDEX idx_user_pending (user_id, purchased)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='复购提醒发送记录';
+
+-- 复购提醒配置表(迁移 83)
+CREATE TABLE IF NOT EXISTS repurchase_reminder_config (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    product_category VARCHAR(100),
+    product_id BIGINT COMMENT '特定商品 ID(优先于 category)',
+    reminder_days INT DEFAULT 30,
+    reminder_template VARCHAR(500) COMMENT '提醒话术模板',
+    max_reminders INT DEFAULT 3,
+    enabled TINYINT DEFAULT 1,
+    created_at DATETIME
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='复购提醒配置表';
+
+-- products 表添加推荐相关字段(迁移 81-83)
+ALTER TABLE products ADD COLUMN IF NOT EXISTS recommendation_tags VARCHAR(500) COMMENT '推荐标签 JSON' AFTER domain;
+ALTER TABLE products ADD COLUMN IF NOT EXISTS purchase_count_threshold INT DEFAULT 0;
+ALTER TABLE products ADD COLUMN IF NOT EXISTS repurchase_interval_days INT DEFAULT 30;
+
+-- 社交圈/珍珠表(迁移 85)
+CREATE TABLE IF NOT EXISTS social_circle (
+  id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  name VARCHAR(100) NOT NULL COMMENT '圈子名称',
+  type VARCHAR(50) NOT NULL COMMENT '类型: topic/activity/hobby/ability/product/health/provider',
+  match_source VARCHAR(50) NOT NULL COMMENT '匹配源: article/activity/game/assessment/product/health_report/teacher',
+  source_id BIGINT COMMENT '匹配源ID',
+  member_count INT DEFAULT 0 COMMENT '成员数',
+  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+  INDEX idx_type (type),
+  INDEX idx_match_source (match_source),
+  INDEX idx_source_id (source_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='社交圈/珍珠';
+
+-- 社交圈成员表(迁移 85)
+CREATE TABLE IF NOT EXISTS social_circle_member (
+  id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  circle_id BIGINT NOT NULL,
+  member_id BIGINT NOT NULL COMMENT '用户ID',
+  member_type VARCHAR(20) NOT NULL COMMENT 'parent/child',
+  joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+  UNIQUE KEY uk_circle_member (circle_id, member_id),
+  INDEX idx_member (member_id, member_type),
+  INDEX idx_circle_id (circle_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='圈子成员';

+ 222 - 0
cfc-frontend/components/ActionArticleRecommend.vue

@@ -0,0 +1,222 @@
+<template>
+  <view class="rec-section">
+    <view class="rec-header">
+      <view class="rec-header-left">
+        <text class="rec-icon">&#x1F4D6;</text>
+        <text class="rec-title">推荐阅读</text>
+      </view>
+      <text class="rec-more" @click="$emit('moreArticles')">更多 ›</text>
+    </view>
+
+    <!-- 无数据 -->
+    <view v-if="!displayArticles || displayArticles.length === 0" class="rec-empty">
+      <text class="rec-empty-text">{{ isLoggedIn ? '暂无阅读推荐' : '登录后查看更多文章' }}</text>
+    </view>
+
+    <!-- 卡片列表 -->
+    <view v-else class="rec-list">
+      <view
+        class="rec-card"
+        v-for="(article, idx) in displayArticles"
+        :key="article.id || idx"
+        @click="$emit('articleClick', article)"
+      >
+        <!-- 封面图(可选) -->
+        <view class="rec-card-cover" v-if="article.coverImage">
+          <image class="rec-cover-img" :src="article.coverImage" mode="aspectFill" />
+          <view class="rec-cover-dim" :style="{ background: article.categoryColor || gradient(idx) }">{{ article.category || '推荐文章' }}</view>
+        </view>
+
+        <!-- 文字区 -->
+        <view class="rec-card-body" :class="{ 'rec-card-body-no-cover': !article.coverImage }">
+          <view class="rec-category-row" v-if="!article.coverImage">
+            <text class="rec-category-tag" :style="{ background: article.categoryColor || gradient(idx) }">{{ article.category || '推荐文章' }}</text>
+          </view>
+          <text class="rec-card-title">{{ article.title }}</text>
+          <text class="rec-card-desc" v-if="article.summary">{{ article.summary }}</text>
+          <view class="rec-card-foot">
+            <text class="rec-card-date" v-if="article.date">{{ article.date }}</text>
+            <view class="rec-card-stats">
+              <text class="rec-stat">&#x1F441; {{ article.views || '0' }}</text>
+              <text class="rec-stat">&#x2764; {{ article.likes || '0' }}</text>
+            </view>
+          </view>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+var defaultGradients = [
+  'linear-gradient(135deg, #10B981, #34D399)',
+  'linear-gradient(135deg, #059669, #10B981)',
+  'linear-gradient(135deg, #047857, #059669)',
+  'linear-gradient(135deg, #34D399, #6EE7B7)',
+  'linear-gradient(135deg, #10B981, #A7F3D0)'
+]
+
+export default {
+  name: 'ActionArticleRecommend',
+  props: {
+    articles: { type: Array, default: function() { return [] } },
+    isLoggedIn: { type: Boolean, default: false }
+  },
+  computed: {
+    displayArticles: function() {
+      return this.articles && this.articles.length ? this.articles : []
+    }
+  },
+  methods: {
+    gradient: function(idx) {
+      return defaultGradients[idx % defaultGradients.length]
+    }
+  }
+}
+</script>
+
+<style scoped>
+.rec-section {
+  margin: 20rpx 20rpx 12rpx;
+}
+.rec-header {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 16rpx;
+  padding: 0 4rpx;
+}
+.rec-header-left {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+}
+.rec-icon {
+  font-size: 34rpx;
+  margin-right: 10rpx;
+}
+.rec-title {
+  font-size: 32rpx;
+  font-weight: 700;
+  color: #065F46;
+  letter-spacing: 1rpx;
+}
+.rec-more {
+  font-size: 24rpx;
+  color: #10B981;
+  font-weight: 500;
+}
+
+/* 空状态 */
+.rec-empty {
+  display: flex;
+  justify-content: center;
+  padding: 60rpx 0;
+}
+.rec-empty-text {
+  font-size: 24rpx;
+  color: #9CA3AF;
+}
+
+/* 卡片列表 */
+.rec-list {
+  display: flex;
+  flex-direction: column;
+}
+.rec-card {
+  background: #FFFFFF;
+  border-radius: 24rpx;
+  overflow: hidden;
+  box-shadow: 0 4rpx 16rpx rgba(16, 185, 129, 0.08);
+  margin-bottom: 20rpx;
+}
+.rec-card:active {
+  opacity: 0.92;
+  transform: scale(0.985);
+}
+
+/* 封面图 */
+.rec-card-cover {
+  position: relative;
+  width: 100%;
+  height: 260rpx;
+  background: #f0f0f0;
+}
+.rec-cover-img {
+  width: 100%;
+  height: 100%;
+}
+.rec-cover-dim {
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  padding: 10rpx 20rpx;
+  background: rgba(0,0,0,0.45);
+}
+.rec-cover-dim text {
+  font-size: 20rpx;
+  color: #fff;
+  font-weight: 500;
+}
+
+/* 文字内容区 */
+.rec-card-body {
+  padding: 24rpx 24rpx 20rpx;
+}
+.rec-card-body-no-cover {
+  padding-top: 28rpx;
+}
+.rec-category-row {
+  margin-bottom: 12rpx;
+}
+.rec-category-tag {
+  display: inline-block;
+  font-size: 20rpx;
+  color: #FFFFFF;
+  font-weight: 500;
+  padding: 4rpx 20rpx;
+  border-radius: 20rpx;
+}
+.rec-card-title {
+  font-size: 28rpx;
+  font-weight: 700;
+  color: #1F2937;
+  line-height: 1.45;
+  display: block;
+  margin-bottom: 8rpx;
+}
+.rec-card-desc {
+  font-size: 24rpx;
+  color: #6B7280;
+  line-height: 1.55;
+  display: block;
+  margin-bottom: 16rpx;
+  display: -webkit-box;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 2;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.rec-card-foot {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+}
+.rec-card-date {
+  font-size: 22rpx;
+  color: #9CA3AF;
+}
+.rec-card-stats {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+}
+.rec-stat {
+  font-size: 22rpx;
+  color: #9CA3AF;
+  margin-left: 20rpx;
+}
+</style>

+ 140 - 0
cfc-frontend/components/CircleCard.vue

@@ -0,0 +1,140 @@
+<template>
+  <view class="circle-card" :class="{ compact: compact }" @click="$emit('click')">
+    <view class="circle-card-left">
+      <view class="circle-icon-wrap" :style="{ background: iconBg(type) }">
+        <text class="circle-icon">{{ typeIcon(type) }}</text>
+      </view>
+    </view>
+    <view class="circle-card-body">
+      <text class="circle-name">{{ circle.name }}</text>
+      <text class="circle-source">{{ sourceLabel(matchSource) }}</text>
+    </view>
+    <view class="circle-card-right">
+      <text class="circle-count">{{ memberCount }}人</text>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  name: 'CircleCard',
+  props: {
+    circle: {
+      type: Object,
+      required: true
+    },
+    compact: {
+      type: Boolean,
+      default: false
+    }
+  },
+  computed: {
+    type: function() {
+      return this.circle && this.circle.type || 'topic'
+    },
+    matchSource: function() {
+      return this.circle && this.circle.matchSource || ''
+    },
+    memberCount: function() {
+      return this.circle && this.circle.memberCount || 0
+    }
+  },
+  methods: {
+    typeIcon: function(type) {
+      var map = {
+        activity: '\u{1F3AF}',
+        ability: '\u{1F9E0}',
+        health: '\u{1F4AA}',
+        product: '\u{1F6CD}',
+        provider: '\u{1F468}\u200D\u{1F3EB}',
+        topic: '\u{1F4AC}',
+        hobby: '\u{1F3A8}'
+      }
+      return map[type] || '\u{1F30D}'
+    },
+    iconBg: function(type) {
+      var map = {
+        activity: '#E0F7FA',
+        ability: '#F3E5F5',
+        health: '#FFF3E0',
+        product: '#E8F5E9',
+        provider: '#E3F2FD',
+        topic: '#FCE4EC',
+        hobby: '#FFF8E1'
+      }
+      return map[type] || '#F5F5F5'
+    },
+    sourceLabel: function(source) {
+      var map = {
+        activity: '源于共同活动',
+        assessment: '源于共同测评',
+        health_report: '源于健康状况',
+        product: '源于共同商品',
+        teacher: '源于同一规划师',
+        article: '源于共同阅读',
+        game: '源于共同游戏'
+      }
+      return map[source] || '源于共同兴趣'
+    }
+  }
+}
+</script>
+
+<style scoped>
+.circle-card {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 20rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+  margin-bottom: 12rpx;
+}
+.circle-card:active {
+  opacity: 0.8;
+}
+.circle-card.compact {
+  padding: 12rpx;
+  margin-bottom: 0;
+}
+.circle-card-left {
+  margin-right: 16rpx;
+}
+.circle-icon-wrap {
+  width: 64rpx;
+  height: 64rpx;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.circle-icon {
+  font-size: 32rpx;
+}
+.circle-card-body {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  gap: 4rpx;
+}
+.circle-name {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #333;
+}
+.circle-source {
+  font-size: 22rpx;
+  color: #999;
+}
+.circle-card-right {
+  margin-left: 12rpx;
+}
+.circle-count {
+  font-size: 22rpx;
+  color: #F97316;
+  background: #FFF7ED;
+  padding: 4rpx 12rpx;
+  border-radius: 20rpx;
+}
+</style>

+ 210 - 0
cfc-frontend/components/CircleDetail.vue

@@ -0,0 +1,210 @@
+<template>
+  <view class="circle-overlay" v-if="visible" @click="$emit('close')">
+    <view class="circle-modal" @click.stop>
+      <view class="circle-modal-header">
+        <view class="circle-modal-icon-wrap">
+          <text class="circle-modal-icon">{{ typeIcon(circleType) }}</text>
+        </view>
+        <text class="circle-modal-name">{{ circleName }}</text>
+        <text class="circle-modal-source">{{ sourceLabel(circleSource) }}</text>
+      </view>
+
+      <view class="circle-modal-body">
+        <view class="circle-modal-section">
+          <text class="circle-modal-section-title">圈子成员 ({{ memberCount }}人)</text>
+          <view class="circle-modal-members">
+            <text class="circle-modal-members-placeholder">成员列表加载中...</text>
+          </view>
+        </view>
+      </view>
+
+      <view class="circle-modal-footer">
+        <button
+          v-if="!isMember"
+          class="circle-btn circle-btn-join"
+          @click="$emit('join', circleId)">加入圈子</button>
+        <button
+          v-else
+          class="circle-btn circle-btn-leave"
+          @click="$emit('leave', circleId)">退出圈子</button>
+        <button
+          class="circle-btn circle-btn-close"
+          @click="$emit('close')">关闭</button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  name: 'CircleDetail',
+  props: {
+    visible: {
+      type: Boolean,
+      default: false
+    },
+    circle: {
+      type: Object,
+      default: function() { return {} }
+    },
+    isMember: {
+      type: Boolean,
+      default: false
+    }
+  },
+  computed: {
+    circleId: function() {
+      return this.circle && this.circle.id || null
+    },
+    circleName: function() {
+      return this.circle && this.circle.name || '未知圈子'
+    },
+    circleType: function() {
+      return this.circle && this.circle.type || 'topic'
+    },
+    circleSource: function() {
+      return this.circle && this.circle.matchSource || ''
+    },
+    memberCount: function() {
+      return this.circle && this.circle.memberCount || 0
+    }
+  },
+  methods: {
+    typeIcon: function(type) {
+      var map = {
+        activity: '\u{1F3AF}',
+        ability: '\u{1F9E0}',
+        health: '\u{1F4AA}',
+        product: '\u{1F6CD}',
+        provider: '\u{1F468}\u200D\u{1F3EB}',
+        topic: '\u{1F4AC}',
+        hobby: '\u{1F3A8}'
+      }
+      return map[type] || '\u{1F30D}'
+    },
+    sourceLabel: function(source) {
+      var map = {
+        activity: '源于共同活动',
+        assessment: '源于共同测评',
+        health_report: '源于健康状况',
+        product: '源于共同商品',
+        teacher: '源于同一规划师',
+        article: '源于共同阅读',
+        game: '源于共同游戏'
+      }
+      return map[source] || '源于共同兴趣'
+    }
+  }
+}
+</script>
+
+<style scoped>
+.circle-overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0,0,0,0.5);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 1000;
+}
+.circle-modal {
+  width: 600rpx;
+  max-height: 80vh;
+  background: #fff;
+  border-radius: 24rpx;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+.circle-modal-header {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 40rpx 30rpx 20rpx;
+  border-bottom: 1rpx solid #f0f0f0;
+}
+.circle-modal-icon-wrap {
+  width: 88rpx;
+  height: 88rpx;
+  border-radius: 50%;
+  background: #FFF7ED;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-bottom: 16rpx;
+}
+.circle-modal-icon {
+  font-size: 44rpx;
+}
+.circle-modal-name {
+  font-size: 34rpx;
+  font-weight: 600;
+  color: #333;
+  margin-bottom: 8rpx;
+}
+.circle-modal-source {
+  font-size: 24rpx;
+  color: #999;
+}
+.circle-modal-body {
+  flex: 1;
+  overflow-y: auto;
+  padding: 20rpx 30rpx;
+}
+.circle-modal-section {
+  margin-bottom: 20rpx;
+}
+.circle-modal-section-title {
+  font-size: 26rpx;
+  font-weight: 600;
+  color: #666;
+  margin-bottom: 12rpx;
+  display: block;
+}
+.circle-modal-members {
+  min-height: 60rpx;
+}
+.circle-modal-members-placeholder {
+  font-size: 24rpx;
+  color: #ccc;
+}
+.circle-modal-footer {
+  display: flex;
+  flex-direction: column;
+  gap: 12rpx;
+  padding: 20rpx 30rpx 30rpx;
+  border-top: 1rpx solid #f0f0f0;
+}
+.circle-btn {
+  width: 100%;
+  height: 80rpx;
+  border-radius: 40rpx;
+  font-size: 28rpx;
+  font-weight: 500;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border: none;
+  padding: 0;
+}
+.circle-btn:active {
+  opacity: 0.8;
+}
+.circle-btn-join {
+  background: #F97316;
+  color: #fff;
+}
+.circle-btn-leave {
+  background: #fff;
+  color: #FF4444;
+  border: 2rpx solid #FF4444;
+}
+.circle-btn-close {
+  background: #f5f5f5;
+  color: #999;
+}
+</style>

+ 195 - 0
cfc-frontend/components/DimensionProductList.vue

@@ -0,0 +1,195 @@
+<template>
+  <view class="dimension-product-list">
+    <view class="section-header">
+      <text class="section-title">{{ title }}</text>
+    </view>
+    <view class="product-grid" v-if="products.length > 0">
+      <view class="product-card" v-for="product in products" :key="product.id" @click="goProduct(product)">
+        <image class="product-cover" :src="product.coverImage || '/static/default-product.png'" mode="aspectFill"></image>
+        <view class="product-info">
+          <text class="product-name">{{ product.name }}</text>
+          <view class="product-price-row">
+            <text class="product-price">¥{{ (product.price / 100).toFixed(2) }}</text>
+            <text class="product-reason" v-if="product.reason">{{ product.reason }}</text>
+          </view>
+          <view class="match-badge" v-if="product.matchScore">
+            <text class="match-score">{{ product.matchScore }}分</text>
+          </view>
+        </view>
+      </view>
+    </view>
+    <view class="empty-state" v-else-if="!loading">
+      <text class="empty-text">暂无推荐商品</text>
+    </view>
+    <view class="loading-state" v-if="loading">
+      <text class="loading-text">加载中...</text>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getDimensionProducts, clickRepurchaseReminder } from '../../utils/api.js'
+
+export default {
+  props: {
+    dimensionCode: { type: String, required: true },
+    familyId: { type: [Number, String], required: true },
+    memberScores: { type: Object, default: function() { return {} } },
+    title: { type: String, default: '为你推荐' }
+  },
+  data: function() {
+    return {
+      products: [],
+      loading: false
+    }
+  },
+  attached: function() {
+    this.loadProducts()
+  },
+  methods: {
+    loadProducts: function() {
+      var self = this
+      self.loading = true
+      var params = {
+        dimensionCode: self.dimensionCode,
+        familyId: self.familyId,
+        limit: 6
+      }
+      if (self.memberScores && Object.keys(self.memberScores).length > 0) {
+        params.memberScores = self.memberScores
+      }
+      
+      getDimensionProducts(params).then(function(res) {
+        self.loading = false
+        if (res.code === 200 && res.data) {
+          self.products = Array.isArray(res.data) ? res.data : (res.data.records || [])
+        }
+      }).catch(function() {
+        self.loading = false
+      })
+    },
+    goProduct: function(product) {
+      if (!product || !product.id) return
+      
+      // 如果有点击追踪 API,先调用
+      // 这里简化处理,直接跳转
+      uni.navigateTo({
+        url: '/pages/discover/product-detail/product-detail?id=' + product.id + '&from=dimension'
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.dimension-product-list {
+  margin: 20rpx 20rpx;
+}
+
+.section-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 20rpx;
+}
+
+.section-title {
+  font-size: 30rpx;
+  font-weight: bold;
+  color: #333;
+}
+
+.product-grid {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 20rpx;
+}
+
+.product-card {
+  width: calc(50% - 10rpx);
+  background: #fff;
+  border-radius: 16rpx;
+  overflow: hidden;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+}
+
+.product-cover {
+  width: 100%;
+  height: 240rpx;
+  background: #f0f0f0;
+}
+
+.product-info {
+  padding: 16rpx;
+}
+
+.product-name {
+  font-size: 26rpx;
+  color: #333;
+  display: block;
+  margin-bottom: 12rpx;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+}
+
+.product-price-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 8rpx;
+}
+
+.product-price {
+  font-size: 28rpx;
+  color: #F97316;
+  font-weight: bold;
+}
+
+.product-reason {
+  font-size: 22rpx;
+  color: #999;
+  max-width: 60%;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.match-badge {
+  display: inline-block;
+  background: linear-gradient(135deg, #6366F1, #818CF8);
+  padding: 4rpx 16rpx;
+  border-radius: 20rpx;
+  margin-top: 8rpx;
+}
+
+.match-score {
+  font-size: 22rpx;
+  color: #fff;
+  font-weight: 500;
+}
+
+.empty-state {
+  display: flex;
+  justify-content: center;
+  padding: 60rpx 0;
+}
+
+.empty-text {
+  font-size: 26rpx;
+  color: #999;
+}
+
+.loading-state {
+  display: flex;
+  justify-content: center;
+  padding: 40rpx 0;
+}
+
+.loading-text {
+  font-size: 24rpx;
+  color: #999;
+}
+</style>

+ 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>

+ 193 - 0
cfc-frontend/components/PearlDiagram.vue

@@ -0,0 +1,193 @@
+<template>
+  <view class="pearl-section">
+    <view class="pearl-header">
+      <text class="pearl-title">我的珍珠圈</text>
+      <text class="pearl-discover-btn" @click="$emit('discover')">发现新圈子</text>
+    </view>
+
+    <view v-if="circles && circles.length > 0" class="pearl-scroll-wrap">
+      <scroll-view class="pearl-scroll" scroll-x enable-flex>
+        <view class="pearl-scroll-inner">
+          <view
+            class="pearl-item"
+            v-for="item in circles"
+            :key="item.id"
+            @click="$emit('circleClick', item)">
+            <view class="pearl-icon-wrap">
+              <text class="pearl-icon">{{ typeIcon(item.type) }}</text>
+            </view>
+            <text class="pearl-name">{{ item.name }}</text>
+            <text class="pearl-count">{{ item.memberCount || 0 }}人</text>
+          </view>
+          <view class="pearl-add-item" @click="$emit('discover')">
+            <view class="pearl-add-icon-wrap">
+              <text class="pearl-add-icon">+</text>
+            </view>
+            <text class="pearl-add-label">发现更多</text>
+          </view>
+        </view>
+      </scroll-view>
+    </view>
+
+    <view v-else class="pearl-empty">
+      <text class="pearl-empty-text">暂无圈子</text>
+      <text class="pearl-empty-action" @click="$emit('discover')">去发现新圈子</text>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  name: 'PearlDiagram',
+  props: {
+    circles: {
+      type: Array,
+      default: function() { return [] }
+    },
+    isLoggedIn: {
+      type: Boolean,
+      default: false
+    }
+  },
+  methods: {
+    typeIcon: function(type) {
+      var map = {
+        activity: '\u{1F3AF}',
+        ability: '\u{1F9E0}',
+        health: '\u{1F4AA}',
+        product: '\u{1F6CD}',
+        provider: '\u{1F468}\u200D\u{1F3EB}',
+        topic: '\u{1F4AC}',
+        hobby: '\u{1F3A8}'
+      }
+      return map[type] || '\u{1F30D}'
+    }
+  }
+}
+</script>
+
+<style scoped>
+.pearl-section {
+  margin: 20rpx 20rpx 0;
+}
+.pearl-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16rpx;
+}
+.pearl-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #333;
+}
+.pearl-discover-btn {
+  font-size: 24rpx;
+  color: #F97316;
+  font-weight: 500;
+}
+.pearl-scroll {
+  white-space: nowrap;
+  overflow: hidden;
+}
+.pearl-scroll-inner {
+  display: flex;
+  flex-direction: row;
+  gap: 16rpx;
+  padding: 8rpx 0 16rpx;
+}
+.pearl-item {
+  flex-shrink: 0;
+  width: 160rpx;
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 20rpx 12rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+}
+.pearl-item:active {
+  opacity: 0.8;
+}
+.pearl-icon-wrap {
+  width: 72rpx;
+  height: 72rpx;
+  border-radius: 50%;
+  background: #FFF7ED;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-bottom: 10rpx;
+}
+.pearl-icon {
+  font-size: 36rpx;
+}
+.pearl-name {
+  font-size: 24rpx;
+  color: #333;
+  font-weight: 500;
+  text-align: center;
+  white-space: normal;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+  max-width: 140rpx;
+}
+.pearl-count {
+  font-size: 20rpx;
+  color: #999;
+  margin-top: 4rpx;
+}
+.pearl-add-item {
+  flex-shrink: 0;
+  width: 120rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 20rpx 8rpx;
+}
+.pearl-add-item:active {
+  opacity: 0.7;
+}
+.pearl-add-icon-wrap {
+  width: 72rpx;
+  height: 72rpx;
+  border-radius: 50%;
+  border: 2rpx dashed #ddd;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-bottom: 10rpx;
+}
+.pearl-add-icon {
+  font-size: 36rpx;
+  color: #ccc;
+}
+.pearl-add-label {
+  font-size: 22rpx;
+  color: #999;
+}
+.pearl-empty {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 40rpx 0;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 12rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+}
+.pearl-empty-text {
+  font-size: 26rpx;
+  color: #ccc;
+}
+.pearl-empty-action {
+  font-size: 26rpx;
+  color: #F97316;
+  font-weight: 500;
+}
+</style>

+ 120 - 0
cfc-frontend/components/RepurchaseReminder.vue

@@ -0,0 +1,120 @@
+<template>
+  <view class="repurchase-container" v-if="reminders.length > 0">
+    <view class="rr-header">
+      <text class="rr-title">复购提醒</text>
+    </view>
+    <view
+      class="rr-item"
+      v-for="item in reminders"
+      :key="item.id"
+      @click="goProduct(item)">
+      <image class="rr-image" :src="item.coverImage" mode="aspectFill" />
+      <view class="rr-info">
+        <text class="rr-name">{{ item.productName }}</text>
+        <text class="rr-price" v-if="item.price">¥{{ (item.price / 100).toFixed(2) }}</text>
+        <text class="rr-hint">上次购买已过 {{ item.reminderDays }} 天,需要补货吗?</text>
+      </view>
+      <view class="rr-action">
+        <text class="rr-btn">去看看</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getRepurchaseReminders, clickRepurchaseReminder } from '../utils/api.js'
+
+export default {
+  data() {
+    return {
+      reminders: []
+    }
+  },
+  mounted() {
+    this.loadReminders()
+  },
+  methods: {
+    loadReminders: function() {
+      var self = this
+      getRepurchaseReminders().then(function(res) {
+        if (res.code === 200 && res.data) {
+          self.reminders = res.data
+        }
+      }).catch(function() {})
+    },
+    goProduct: function(item) {
+      clickRepurchaseReminder({ id: item.id })
+      uni.navigateTo({
+        url: '/pages/shop/detail?id=' + item.productId + '&from=repurchase'
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.repurchase-container {
+  background: #fff;
+  border-radius: 20rpx;
+  margin: 20rpx 30rpx;
+  padding: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+}
+.rr-header {
+  margin-bottom: 16rpx;
+}
+.rr-title {
+  font-size: 30rpx;
+  font-weight: 700;
+  color: #333;
+}
+.rr-item {
+  display: flex;
+  align-items: center;
+  padding: 16rpx 0;
+  border-top: 1rpx solid #f5f5f5;
+}
+.rr-image {
+  width: 120rpx;
+  height: 120rpx;
+  border-radius: 12rpx;
+  flex-shrink: 0;
+}
+.rr-info {
+  flex: 1;
+  margin-left: 16rpx;
+  min-width: 0;
+}
+.rr-name {
+  font-size: 26rpx;
+  font-weight: 600;
+  color: #333;
+  display: block;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.rr-price {
+  font-size: 24rpx;
+  color: #F97316;
+  margin-top: 4rpx;
+  display: block;
+}
+.rr-hint {
+  font-size: 22rpx;
+  color: #999;
+  margin-top: 4rpx;
+  display: block;
+}
+.rr-action {
+  flex-shrink: 0;
+  margin-left: 12rpx;
+}
+.rr-btn {
+  background: #F97316;
+  color: #fff;
+  font-size: 22rpx;
+  padding: 8rpx 20rpx;
+  border-radius: 24rpx;
+}
+</style>

+ 0 - 92
cfc-frontend/components/UserQuickEntry.vue

@@ -1,92 +0,0 @@
-<template>
-  <view class="user-quick-entry">
-    <view class="user-info">
-      <text class="user-info-text">
-        👤 当前用户:{{ userName }}({{ roleName }})
-      </text>
-      <view class="child-switcher" v-if="children && children.length > 0" @click="showChildPicker">
-        <text class="child-switcher-text">
-          切换孩子:{{ currentChildName || '选择' }} ▼
-        </text>
-      </view>
-    </view>
-    <view class="quick-actions">
-      <view class="quick-btn" @click="$emit('scrollTo', 'tasks')">
-        <text class="quick-btn-icon">📋</text>
-        <text class="quick-btn-label">今日任务</text>
-      </view>
-      <view class="quick-btn" @click="$emit('scrollTo', 'activities')">
-        <text class="quick-btn-icon">🔥</text>
-        <text class="quick-btn-label">相关活动</text>
-      </view>
-      <view class="quick-btn" @click="$emit('scrollTo', 'products')">
-        <text class="quick-btn-icon">🛍️</text>
-        <text class="quick-btn-label">推荐商品</text>
-      </view>
-    </view>
-  </view>
-</template>
-
-<script>
-export default {
-  props: {
-    userName: { type: String, default: '' },
-    roleName: { type: String, default: '' },
-    currentChildName: { type: String, default: '' },
-    children: { type: Array, default: function() { return [] } }
-  },
-  methods: {
-    showChildPicker: function() {
-      var self = this
-      var items = this.children.map(function(c) { return c.childName })
-      uni.showActionSheet({
-        itemList: items,
-        success: function(res) {
-          var child = self.children[res.tapIndex]
-          if (child) {
-            uni.setStorageSync('currentChildId', child.childId)
-            self.$emit('childChanged', child)
-          }
-        }
-      })
-    }
-  }
-}
-</script>
-
-<style scoped>
-.user-quick-entry {
-  margin: 0 30rpx 20rpx;
-  background: #fff;
-  border-radius: 20rpx;
-  padding: 20rpx 24rpx;
-  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04);
-}
-.user-info {
-  display: flex;
-  flex-direction: row;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 16rpx;
-}
-.user-info-text { font-size: 26rpx; color: #666; }
-.child-switcher-text { font-size: 24rpx; color: #F97316; }
-.quick-actions {
-  display: flex;
-  flex-direction: row;
-}
-.quick-btn {
-  flex: 1;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  padding: 16rpx 0;
-  background: #FFF7ED;
-  border-radius: 16rpx;
-  margin-right: 16rpx;
-}
-.quick-btn:last-child { margin-right: 0; }
-.quick-btn:active { opacity: 0.7; }
-.quick-btn-icon { font-size: 36rpx; margin-bottom: 6rpx; }
-.quick-btn-label { font-size: 22rpx; color: #666; }
-</style>

+ 13 - 7
cfc-frontend/pages.json

@@ -1,5 +1,17 @@
 {
   "pages": [
+    {
+      "path": "pages/wealth/index",
+      "style": {
+        "navigationBarTitleText": "富"
+      }
+    },
+    {
+      "path": "pages/profile/index",
+      "style": {
+        "navigationBarTitleText": "我的"
+      }
+    },
     {
       "path": "pages/index/index",
       "style": {
@@ -823,12 +835,6 @@
     {
       "root": "pages/wealth",
       "pages": [
-        {
-          "path": "index",
-          "style": {
-            "navigationBarTitleText": "财富"
-          }
-        },
         {
           "path": "checkin",
           "style": {
@@ -1282,7 +1288,7 @@
         "selectedIconPath": "static/tab-wealth-active.png"
       },
       {
-        "pagePath": "pages/profile/profile",
+        "pagePath": "pages/wealth/index",
         "text": "富",
         "iconPath": "static/tab-profile.png",
         "selectedIconPath": "static/tab-profile-active.png"

+ 130 - 79
cfc-frontend/pages/body/index.vue

@@ -1,4 +1,4 @@
-<template>
+<template>
   <view class="body-container">
     <!-- Tabbar 切换过渡页 -->
     <tab-transition v-if="showTabTransition" dimCode="body" />
@@ -37,6 +37,21 @@
       :intimacyMap="intimacyMapForGraph"
       @memberTap="goMemberDetail" />
 
+    <!-- 登录后:用户快捷入口 -->
+    <UserQuickEntry
+      v-if="isLoggedIn"
+      :userName="userName"
+      roleName="家长"
+      :currentChildName="activeChildName"
+      :children="children"
+      @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">
@@ -77,22 +92,67 @@
       </view>
     </view>
 
-    <!-- 快捷操作按钮 -->
-    <view class="section quick-actions" v-if="isLoggedIn">
-      <view class="action-btn" @click="goToHealthReport">
-        <text class="action-icon">📋</text>
-        <text class="action-text">报告详情</text>
+    <!-- 健康报告摘要(登录后可见) -->
+    <view class="section" v-if="sectionVisible('health_report') && isLoggedIn">
+      <view class="section-header">
+        <text class="section-title">健康报告</text>
+        <text class="section-more" @click="goToHealthReport">查看详情 ›</text>
       </view>
-      <view class="action-btn" @click="goToCheckin">
-        <text class="action-icon">🏃</text>
-        <text class="action-text">健康打卡</text>
+      <view class="report-card" v-if="latestReport">
+        <view class="report-type-tag">{{ reportTypeLabel }}</view>
+        <view class="report-score-row">
+          <view class="score-circle">
+            <text class="score-value">{{ latestReport.overallScore || '--' }}</text>
+            <text class="score-label">综合评分</text>
+          </view>
+          <view class="report-meta">
+            <view class="meta-item" v-if="latestReport.gutHealthScore">
+              <text class="meta-label">肠道健康</text>
+              <text class="meta-value">{{ latestReport.gutHealthScore }}</text>
+            </view>
+            <view class="meta-item" v-if="latestReport.nutritionScore">
+              <text class="meta-label">营养状况</text>
+              <text class="meta-value">{{ latestReport.nutritionScore }}</text>
+            </view>
+            <view class="meta-item" v-if="latestReport.gutAge">
+              <text class="meta-label">肠道年龄</text>
+              <text class="meta-value">{{ latestReport.gutAge }}</text>
+            </view>
+          </view>
+        </view>
+        <text class="report-date">报告日期:{{ formatDate(latestReport.reportDate) }}</text>
       </view>
-      <view class="action-btn" @click="goToNutritionProfile">
-        <text class="action-icon">🥗</text>
-        <text class="action-text">营养档案</text>
+      <view class="report-empty" v-else>
+        <text class="empty-tip">暂无健康报告</text>
+        <text class="empty-sub">规划师录入后将自动显示</text>
       </view>
     </view>
 
+    <!-- ===== 精准营养(上传报告+产品+文章) ===== -->
+    <view class="section nutrition-section" v-if="isLoggedIn">
+      <view class="section-header">
+        <text class="section-title">🥗 精准营养</text>
+      </view>
+
+      <!-- 上传报告入口 -->
+      <view class="nutrition-report-card" @click="goToUploadReport">
+        <view class="nutrition-report-left">
+          <text class="nutrition-icon-large">📋</text>
+        </view>
+        <view class="nutrition-report-right">
+          <text class="nutrition-title">上传健康报告</text>
+          <text class="nutrition-desc">上传体检报告或肠道检测报告,AI智能解读分析,获取个性化营养方案</text>
+          <view class="nutrition-report-tags">
+            <text class="tag">体检报告</text>
+            <text class="tag">肠道检测</text>
+            <text class="tag">AI解读</text>
+          </view>
+        </view>
+        <text class="nutrition-arrow">›</text>
+      </view>
+
+      </view>
+
     <!-- 维度任务 -->
     <DimensionTasks
       v-if="isLoggedIn"
@@ -109,12 +169,12 @@
       @activityClick="goActivityDetail"
       @moreActivities="goMoreActivities" />
 
-    <!-- 维度商品 -->
-    <DimensionProducts
+    <!-- 商品推荐 -->
+    <DimensionProductList
+      v-if="isLoggedIn"
       dimensionCode="body"
-      :products="dimensionProducts"
-      @productClick="goProductDetail"
-      @moreProducts="goMoreProducts" />
+      :familyId="activeChildId"
+      title="为你推荐" />
 
     <!-- 推荐阅读 -->
     <DimensionArticles
@@ -124,6 +184,24 @@
       @articleClick="goArticleDetail"
       @moreArticles="goMoreArticles" />
 
+    <!-- 快捷操作按钮 -->
+    <view class="section quick-actions" v-if="isLoggedIn">
+      <view class="action-btn" @click="goToHealthReport">
+        <text class="action-icon">📋</text>
+        <text class="action-text">报告详情</text>
+      </view>
+      <view class="action-btn" @click="goToCheckin">
+        <text class="action-icon">🏃</text>
+        <text class="action-text">健康打卡</text>
+      </view>
+      <view class="action-btn" @click="goToNutritionProfile">
+        <text class="action-icon">🥗</text>
+        <text class="action-text">营养档案</text>
+      </view>
+    </view>
+
+    
+
     <!-- 底部占位 -->
     <view class="bottom-spacer"></view>
     <AIFloatingAvatar />
@@ -135,6 +213,7 @@ import TabTransition from '../../components/tab-transition.vue'
 import PageBanner from '../../components/PageBanner.vue'
 import LoginGuideCard from '../../components/LoginGuideCard.vue'
 import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
+import UserQuickEntry from '../../components/UserQuickEntry.vue'
 import DimensionTasks from '../../components/DimensionTasks.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
@@ -142,8 +221,10 @@ 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, getContactList, getHealthAlerts, getMilestones } from '../../utils/api.js'
+
+import { getVisibleSections, getEnergyOverview, getChildren, getTodayTasksByCategory, getActivityList, getProductsByDomain, getFamilyEnergySandbox, getVisibleFamilyMembers, getDimensionOverview, getFeaturedArticles } from '../../utils/api.js'
 import config from '../../config.js'
 
 var BASE_URL = config.api('')
@@ -174,7 +255,7 @@ const healthRequest = function(url, data) {
 }
 
 export default {
-  components: { TabTransition, PageBanner, LoginGuideCard, FamilyEnergyBar, 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,
@@ -200,12 +281,9 @@ export default {
         { icon: '\u{1F3C3}', label: '运动', needLogin: true, page: '/pages/body/exercise-record' },
         { icon: '\u{1F957}', label: '饮食', needLogin: true, page: '/pages/body/meal-record' },
         { icon: '\u{1F634}', label: '作息', needLogin: true, page: '/pages/body/sleep-record' },
-        { icon: '\u{1F9D8}', label: '冥想', needLogin: true, page: '/pages/health/meditation-index' }
-      ],
-      contactList: [],
-      healthAlerts: [],
-      milestones: []
-    }
+        { icon: '\u{1F9D8}', label: '冥想', needLogin: true, page: '/pages/health/meditation-index' },
+        { icon: '\u{1F4CA}', label: '舌诊', needLogin: true, page: '/pages/health/report-upload?reportType=tongue' }
+      }
   },
   computed: {
     reportTypeLabel: function() {
@@ -243,7 +321,6 @@ export default {
     if (this.isLoggedIn) {
       this.loadChildren()
       this.loadFamilyMembersVisible()
-      this.loadRelationshipHealth()
     }
     // 游客也能浏览活动和商品
     this.loadDimensionActivities()
@@ -287,12 +364,14 @@ export default {
               break
             }
           }
-          // FIX: 不再自动选择第一个孩子。仅在用户通过 UserQuickEntry 切换时设置 activeChildId
-          // 默认情况下,页面显示当前登录用户自己的数据(由 API 根据 token 推断)
-          if (found) {
+          if (!found && self.children.length > 0) {
+            self.activeChildId = self.children[0].childId
+            self.activeChildName = self.children[0].name || self.children[0].nickname || '孩子'
+            uni.setStorageSync('currentChildId', self.activeChildId)
+          }
+          if (self.activeChildId) {
             self.loadDashboardData()
           }
-          self.loadContacts()
         }
       }).catch(function(e) {
         console.log('获取孩子列表失败', e)
@@ -392,7 +471,7 @@ export default {
     },
     loadFeaturedArticles: function() {
       var self = this
-      getFeaturedArticles({ size: 5, dimensionCode: 'body' }).then(function(res) {
+      getFeaturedArticles({ size: 5 }).then(function(res) {
         if (res.code === 200 && res.data) {
           var gradientColors = [
             'linear-gradient(135deg, #FF8C42, #FFB074)',
@@ -481,74 +560,46 @@ export default {
         return
       }
       if (item.page === 'health-dimensions') {
-        uni.navigateTo({ url: '/pages/body-detail/health-dimensions?childId=' + (this.activeChildId || '') })
+        if (!this.activeChildId) {
+          uni.showToast({ title: '请先选择孩子', icon: 'none' })
+          return
+        }
+        uni.navigateTo({ url: '/pages/body-detail/health-dimensions?childId=' + this.activeChildId })
         return
       }
       if (item.page) uni.navigateTo({ url: item.page })
     },
     goToHealthReport: function() {
-      var id = this.activeChildId || uni.getStorageSync('currentChildId') || ''
+      var id = this.activeChildId || uni.getStorageSync('currentChildId')
+      if (!id) {
+        uni.showToast({ title: '请先选择成员', icon: 'none' })
+        return
+      }
       uni.navigateTo({ url: '/pages/body-detail/health-report?childId=' + id })
     },
     goDimensionDetail: function(dimKey) {
       uni.navigateTo({ url: '/pages/body/dimension-detail?dimension=' + dimKey + '&childId=' + this.activeChildId })
     },
     goToCheckin: function() {
-      var id = this.activeChildId || uni.getStorageSync('currentChildId') || ''
+      var id = this.activeChildId || uni.getStorageSync('currentChildId')
+      if (!id) {
+        uni.showToast({ title: '请先选择成员', icon: 'none' })
+        return
+      }
       uni.navigateTo({ url: '/pages/body-detail/checkin?childId=' + id })
     },
     goToNutritionProfile: function() {
-      var id = this.activeChildId || uni.getStorageSync('currentChildId') || ''
+      var id = this.activeChildId || uni.getStorageSync('currentChildId')
+      if (!id) {
+        uni.showToast({ title: '请先选择成员', icon: 'none' })
+        return
+      }
       uni.navigateTo({ url: '/pages/health/nutrition-profile?childId=' + id })
     },
     goToUploadReport: function() {
       var id = this.activeChildId || uni.getStorageSync('currentChildId') || ''
       uni.navigateTo({ url: '/pages/health/report-upload?childId=' + id + '&from=body' })
     },
-    // ===== 重要关系 =====
-    loadContacts: function() {
-      var self = this
-      getContactList({}).then(function(res) {
-        if (res.code === 200 && res.data) {
-          self.contactList = Array.isArray(res.data) ? res.data : []
-        }
-      }).catch(function(e) {
-        console.log('获取联系人失败', e)
-      })
-    },
-    loadRelationshipHealth: function() {
-      var self = this
-      getHealthAlerts({}).then(function(res) {
-        if (res.code === 200 && res.data) {
-          self.healthAlerts = Array.isArray(res.data) ? res.data : []
-        }
-      }).catch(function() {
-        self.healthAlerts = []
-      })
-      getMilestones({}, 30).then(function(res) {
-        if (res.code === 200 && res.data) {
-          self.milestones = Array.isArray(res.data) ? res.data : []
-        }
-      }).catch(function() {
-        self.milestones = []
-      })
-    },
-    onContactClick: function(item) {
-      uni.navigateTo({ url: '/pages/body-detail/contact-detail?id=' + item.id })
-    },
-    onShowImport: function() {
-      uni.showToast({ title: '导入通讯录', icon: 'none' })
-    },
-    goInteractionLog: function(memberId) {
-      if (memberId) {
-        uni.navigateTo({ url: '/pages/body-detail/interaction-log?memberId=' + memberId })
-      } else {
-        uni.navigateTo({ url: '/pages/body-detail/interaction-log' })
-      }
-    },
-    goRelationshipQuestionnaire: function() {
-      uni.navigateTo({ url: '/pages/body-detail/relationship-questionnaire' })
-    },
     formatDate: function(dateStr) {
       if (!dateStr) return '--'
       try {

+ 177 - 10
cfc-frontend/pages/discover/product-detail/product-detail.vue

@@ -14,7 +14,7 @@
           <text class="product-name">{{ product.name }}</text>
         </view>
         <view class="price-row">
-          <text class="price">{{ formatPriceWithSymbol(product.price) }}</text>
+          <text class="price">{{ formatPriceWithSymbol(displayPrice) }}</text>
           <text v-if="product.memberPrice" class="member-price">会员价 {{ formatPriceWithSymbol(product.memberPrice) }}</text>
         </view>
         <view class="meta-row">
@@ -22,10 +22,32 @@
           <text class="meta-item" v-if="product.domain">{{ domainLabel }}</text>
           <text class="meta-item">库存 {{ product.stock || 0 }}</text>
         </view>
+
+        <!-- Spec Selector -->
+        <view v-if="specGroups.length > 0" class="spec-section">
+          <view v-for="group in specGroups" :key="group.id" class="spec-group">
+            <text class="spec-group-name">{{ group.name }}</text>
+            <view class="spec-options">
+              <text
+                v-for="opt in specMap[group.id] || []"
+                :key="opt.id"
+                :class="['spec-chip', selectedSpecs[group.id] === opt.id ? 'spec-chip-active' : '']"
+                @click="selectSpec(group.id, opt.id)"
+              >{{ opt.name }}</text>
+            </view>
+          </view>
+        </view>
+
+        <!-- Supplier Info -->
         <view class="vendor-row">
           <text class="vendor-label">供应商:</text>
           <text class="vendor-name">{{ product.vendorName || '平台官方' }}</text>
         </view>
+        <view v-if="suppliers.length > 0" class="supplier-row">
+          <view v-for="s in suppliers" :key="s.supplierId" class="supplier-tag">
+            <text class="supplier-name">{{ s.supplierName }}</text>
+          </view>
+        </view>
       </view>
 
       <view class="desc-section">
@@ -68,12 +90,16 @@ export default {
     return {
       product: {},
       loading: false,
-      cartCount: 0
+      cartCount: 0,
+      specGroups: [],
+      specMap: {},
+      selectedSpecs: {},
+      suppliers: []
     }
   },
   computed: {
     typeLabel() {
-      const map = {
+      var map = {
         activity: '活动',
         course: '课程',
         physical: '实物',
@@ -83,7 +109,7 @@ export default {
       return map[this.product.productType] || this.product.productType
     },
     domainLabel() {
-      const map = {
+      var map = {
         action: '行动',
         mind: '心智',
         body: '身体',
@@ -91,6 +117,23 @@ export default {
         wealth: '财富'
       }
       return map[this.product.domain] || this.product.domain
+    },
+    displayPrice() {
+      return this.product.price || 0
+    },
+    selectedSpecDesc() {
+      var parts = []
+      for (var gid in this.selectedSpecs) {
+        var oid = this.selectedSpecs[gid]
+        var group = this.specGroups.find(function(g) { return g.id === Number(gid) })
+        if (!group) continue
+        var opts = this.specMap[gid] || []
+        var opt = opts.find(function(o) { return o.id === oid })
+        if (opt) {
+          parts.push(group.name + ':' + opt.name)
+        }
+      }
+      return parts.join(', ')
     }
   },
   onLoad(options) {
@@ -104,15 +147,76 @@ export default {
   methods: {
     loadDetail(id) {
       this.loading = true
-      productDetail({ id: parseInt(id) }).then(res => {
-        this.loading = false
+      var that = this
+      productDetail({ id: parseInt(id) }).then(function(res) {
+        that.loading = false
         if (res.code === 200 && res.data) {
-          this.product = res.data
+          that.product = res.data
+          that.loadSpecs(id)
+          that.loadSuppliers(id)
+        }
+      }).catch(function() {
+        that.loading = false
+      })
+    },
+    loadSpecs(productId) {
+      var that = this
+      uni.request({
+        url: config.api('/api/product/spec/map'),
+        method: 'POST',
+        data: { productId: productId },
+        header: {
+          'Content-Type': 'application/json',
+          'Authorization': 'Bearer ' + uni.getStorageSync('token')
+        },
+        success: function(res) {
+          if (res.data && res.data.code === 200 && res.data.data) {
+            that.specMap = res.data.data
+            var allGroups = []
+            for (var gid in res.data.data) {
+              var options = res.data.data[gid]
+              if (options && options.length > 0) {
+                allGroups.push({
+                  id: Number(gid),
+                  productId: productId,
+                  name: options[0].groupId ? '' : ''
+                })
+              }
+            }
+            that.specGroups = allGroups
+          }
+        }
+      })
+    },
+    loadSuppliers(productId) {
+      var that = this
+      uni.request({
+        url: config.api('/api/admin/product/supplier/list'),
+        method: 'POST',
+        data: { productId: productId },
+        header: {
+          'Content-Type': 'application/json',
+          'Authorization': 'Bearer ' + uni.getStorageSync('token')
+        },
+        success: function(res) {
+          if (res.data && res.data.code === 200) {
+            that.suppliers = res.data.data || []
+          }
         }
-      }).catch(() => {
-        this.loading = false
       })
     },
+    selectSpec(groupId, optionId) {
+      var specs = {}
+      for (var k in this.selectedSpecs) {
+        specs[k] = this.selectedSpecs[k]
+      }
+      if (specs[groupId] === optionId) {
+        delete specs[groupId]
+      } else {
+        specs[groupId] = optionId
+      }
+      this.selectedSpecs = specs
+    },
     loadCartCount() {
       var that = this
       uni.request({
@@ -132,10 +236,18 @@ export default {
     },
     onAddToCart() {
       var that = this
+      var specOptionIds = []
+      for (var k in this.selectedSpecs) {
+        specOptionIds.push(this.selectedSpecs[k])
+      }
       uni.request({
         url: config.api('/api/cart/add'),
         method: 'POST',
-        data: { productId: this.product.id, quantity: 1 },
+        data: {
+          productId: this.product.id,
+          quantity: 1,
+          specOptionIds: specOptionIds.length > 0 ? JSON.stringify(specOptionIds) : ''
+        },
         header: {
           'Content-Type': 'application/json',
           'Authorization': 'Bearer ' + uni.getStorageSync('token')
@@ -155,11 +267,16 @@ export default {
     },
     onBuy() {
       if (!this.product.id) return
+      var specOptionIds = []
+      for (var k in this.selectedSpecs) {
+        specOptionIds.push(this.selectedSpecs[k])
+      }
       var url = '/pages/shop/checkout/checkout?productId=' + this.product.id
         + '&productName=' + encodeURIComponent(this.product.name || '')
         + '&price=' + (this.product.price || 0)
         + '&coverImage=' + encodeURIComponent(this.product.coverImage || '')
         + '&quantity=1'
+        + '&specOptionIds=' + (specOptionIds.length > 0 ? encodeURIComponent(JSON.stringify(specOptionIds)) : '')
       uni.navigateTo({ url: url })
     },
     formatPriceWithSymbol(price) {
@@ -249,6 +366,56 @@ export default {
   font-size: 24rpx;
   color: #666;
 }
+.spec-section {
+  padding: 20rpx 0;
+  border-top: 2rpx solid #f5f5f5;
+  margin-top: 16rpx;
+}
+.spec-group {
+  margin-bottom: 16rpx;
+}
+.spec-group:last-child {
+  margin-bottom: 0;
+}
+.spec-group-name {
+  font-size: 26rpx;
+  color: #666;
+  margin-bottom: 12rpx;
+  display: block;
+}
+.spec-options {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12rpx;
+}
+.spec-chip {
+  padding: 10rpx 24rpx;
+  border-radius: 8rpx;
+  font-size: 24rpx;
+  color: #666;
+  background: #f5f5f5;
+  border: 2rpx solid #eee;
+}
+.spec-chip-active {
+  color: #F97316;
+  background: #FFF7ED;
+  border-color: #F97316;
+}
+.supplier-row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8rpx;
+  margin-top: 8rpx;
+}
+.supplier-tag {
+  background: #f0f9ff;
+  padding: 4rpx 16rpx;
+  border-radius: 4rpx;
+}
+.supplier-name {
+  font-size: 22rpx;
+  color: #0EA5E9;
+}
 .desc-section,
 .intro-section {
   background: #fff;

+ 108 - 1
cfc-frontend/pages/family/add-member.vue

@@ -41,6 +41,38 @@
         <view class="age-display">{{ calculatedAge || '根据出生日期自动计算' }}</view>
       </view>
 
+      <!-- 出生时辰(选填) -->
+      <view class="form-item">
+        <text class="label">出生时辰(选填)</text>
+        <picker :range="birthHourOptions" @change="onBirthHourChange">
+          <view class="picker">
+            {{ form.birthHourName || '请选择出生时辰' }}
+          </view>
+        </picker>
+      </view>
+
+      <!-- 出生体重(选填) -->
+      <view class="form-item">
+        <text class="label">出生体重(选填,克)</text>
+        <input class="input" type="number" v-model="form.birthWeight" placeholder="如: 3500" maxlength="5" />
+      </view>
+
+      <!-- 出生地(选填) -->
+      <view class="form-item">
+        <text class="label">出生地(选填)</text>
+        <input class="input" v-model="form.birthPlace" placeholder="如: 北京市" maxlength="30" />
+      </view>
+
+      <!-- 是否剖腹产(选填) -->
+      <view class="form-item">
+        <text class="label">是否剖腹产(选填)</text>
+        <picker :range="cesareanOptions" range-key="label" @change="onCesareanChange">
+          <view class="picker">
+            {{ form.cesareanLabel || '请选择' }}
+          </view>
+        </picker>
+      </view>
+
       <!-- 手机号(选填) -->
       <view class="form-item">
         <text class="label">手机号(选填)</text>
@@ -67,12 +99,23 @@ export default {
         relationshipType: '',
         gender: '',
         birthday: '',
-        phone: ''
+        phone: '',
+        birthHour: '',
+        birthHourName: '',
+        birthWeight: '',
+        birthPlace: '',
+        isCesarean: null,
+        cesareanLabel: ''
       },
       genderOptions: [
         { value: 'male', label: '男' },
         { value: 'female', label: '女' }
       ],
+      birthHourOptions: ['', '子时(23-1点)', '丑时(1-3点)', '寅时(3-5点)', '卯时(5-7点)', '辰时(7-9点)', '巳时(9-11点)', '午时(11-13点)', '未时(13-15点)', '申时(15-17点)', '酉时(17-19点)', '戌时(19-21点)', '亥时(21-23点)'],
+      cesareanOptions: [
+        { value: 0, label: '顺产' },
+        { value: 1, label: '剖腹产' }
+      ],
       todayDate: ''
     }
   },
@@ -104,6 +147,21 @@ export default {
     onBirthdayChange(e) {
       this.form.birthday = e.detail.value
     },
+    onBirthHourChange(e) {
+      var idx = e.detail.value
+      if (idx === 0) {
+        this.form.birthHour = ''
+        this.form.birthHourName = ''
+      } else {
+        this.form.birthHour = this.birthHourOptions[idx]
+        this.form.birthHourName = this.birthHourOptions[idx]
+      }
+    },
+    onCesareanChange(e) {
+      var idx = e.detail.value
+      this.form.isCesarean = this.cesareanOptions[idx].value
+      this.form.cesareanLabel = this.cesareanOptions[idx].label
+    },
     async submit() {
       if (!this.form.nickname || !this.form.nickname.trim()) {
         uni.showToast({ title: '请输入成员昵称', icon: 'none' })
@@ -126,17 +184,66 @@ export default {
         return
       }
 
+      // 根据 relationshipType 推导 generationLevel 和 peerType
+      var generationLevel = ''
+      var peerType = null
+      switch (this.form.relationshipType) {
+        case 'spouse':
+          generationLevel = 'peer'
+          peerType = 'spouse'
+          break
+        case 'sibling':
+          generationLevel = 'peer'
+          peerType = 'sibling'
+          break
+        case 'parent':
+          generationLevel = 'parent'
+          break
+        case 'child':
+          generationLevel = 'child'
+          break
+        case 'grandparent':
+          generationLevel = 'grandparent'
+          break
+        case 'grandchild':
+          generationLevel = 'grandchild'
+          break
+        case 'great_grandparent':
+          generationLevel = 'great_grandparent'
+          break
+        case 'great_grandchild':
+          generationLevel = 'great_grandchild'
+          break
+        default:
+          uni.showToast({ title: '无效的关系类型: ' + this.form.relationshipType, icon: 'none' })
+          return
+      }
+
       try {
         this.submitting = true
         var payload = {
           nickname: this.form.nickname.trim(),
           relationshipType: this.form.relationshipType,
+          generationLevel: generationLevel,
+          peerType: peerType,
           gender: this.form.gender,
           birthday: this.form.birthday
         }
         if (this.form.phone) {
           payload.phone = this.form.phone
         }
+        if (this.form.birthHour) {
+          payload.birthHour = this.form.birthHour
+        }
+        if (this.form.birthWeight) {
+          payload.birthWeight = parseInt(this.form.birthWeight)
+        }
+        if (this.form.birthPlace) {
+          payload.birthPlace = this.form.birthPlace
+        }
+        if (this.form.isCesarean !== null && this.form.isCesarean !== undefined) {
+          payload.isCesarean = this.form.isCesarean
+        }
         var res = await addFamilyMember(payload)
         if (res.code === 200) {
           uni.showToast({ title: '添加成功', icon: 'success' })

+ 7 - 0
cfc-frontend/pages/profile/components/ProfileMenu.vue

@@ -4,6 +4,10 @@
       <!-- ===== 个人 ===== -->
       <view class="menu-group">
         <view class="menu-group-title">── 个人 ──</view>
+        <view class="menu-item" v-if="role === 'parent'" @click="goToFamilyMembers">
+          <text>👨‍👩‍👧‍👦 家庭成员</text>
+          <text class="arrow">›</text>
+        </view>
         <view class="menu-item" @click="goToDailyTasks">
           <text>📋 每日任务</text>
           <text class="arrow">›</text>
@@ -143,6 +147,9 @@ export default {
     goToOnboarding() {
       uni.navigateTo({ url: '/pages/profile/onboarding' })
     },
+    goToFamilyMembers() {
+      uni.navigateTo({ url: '/pages/profile/family-members' })
+    },
     goToDailyTasks() {
       uni.navigateTo({ url: '/pages/tasks/daily-tasks' })
     },

+ 123 - 0
cfc-frontend/pages/profile/index.vue

@@ -0,0 +1,123 @@
+<template>
+  <view class="container">
+    <view v-if="!isLoggedIn" class="login-prompt">
+      <view class="prompt-icon">👤</view>
+      <text class="prompt-title">登录浠艾福</text>
+      <text class="prompt-desc">登录后可查看个人资料</text>
+      <button class="login-btn" @click="goLogin">登录 / 注册</button>
+    </view>
+    <template v-else>
+      <PageBanner theme="wealth" tagline="个人中心" quote="" />
+      <ProfileHeader @avatar-click="goToEditInfo" />
+      <ProfileMenu :role="role" @invite-generate="onInviteGenerate" />
+    </template>
+  </view>
+</template>
+
+<script>
+import PageBanner from '../components/PageBanner.vue'
+import ProfileHeader from './components/ProfileHeader.vue'
+import ProfileMenu from './components/ProfileMenu.vue'
+
+export default {
+  components: {
+    PageBanner,
+    ProfileHeader,
+    ProfileMenu
+  },
+  data() {
+    return {
+      shareData: null
+    }
+  },
+  computed: {
+    isLoggedIn() {
+      return !!uni.getStorageSync('token')
+    },
+    role() {
+      return uni.getStorageSync('currentRole') || uni.getStorageSync('role') || 'parent'
+    }
+  },
+  onShareAppMessage() {
+    if (this.shareData) {
+      return {
+        title: this.shareData.title,
+        path: this.shareData.path,
+        imageUrl: '/static/invite-card.png'
+      }
+    }
+  },
+  methods: {
+    goLogin() {
+      uni.navigateTo({
+        url: '/pages/login/login'
+      })
+    },
+    goToEditInfo() {
+      uni.navigateTo({
+        url: '/pages/user-edit/user-edit'
+      })
+    },
+    onInviteGenerate(data) {
+      this.shareData = data
+      uni.showToast({
+        title: '点击右上角转发给TA',
+        icon: 'none'
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  min-height: 100vh;
+  background: #f5f7fa;
+  padding-bottom: 120rpx;
+}
+.login-prompt {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  min-height: 80vh;
+  padding: 60rpx;
+}
+.prompt-icon {
+  font-size: 120rpx;
+  margin-bottom: 30rpx;
+  width: 160rpx;
+  height: 160rpx;
+  line-height: 160rpx;
+  text-align: center;
+  background: #f5f5f5;
+  border-radius: 50%;
+}
+.prompt-title {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #333;
+  margin-bottom: 16rpx;
+}
+.prompt-desc {
+  font-size: 26rpx;
+  color: #999;
+  text-align: center;
+  margin-bottom: 40rpx;
+}
+.login-prompt .login-btn {
+  width: 60%;
+  height: 80rpx;
+  line-height: 80rpx;
+  background: linear-gradient(135deg, #5B9BD5, #3A7CC4);
+  color: #fff;
+  font-size: 30rpx;
+  font-weight: bold;
+  border-radius: 40rpx;
+  text-align: center;
+  border: none;
+}
+.login-prompt .login-btn::after {
+  border: none;
+}
+</style>

+ 1 - 1
cfc-frontend/pages/shop/after-sales/after-sales.vue

@@ -96,7 +96,7 @@ export default {
       uni.request({
         url: config.api('/api/shop/after-sales/list'),
         method: 'POST',
-        data: {},
+        data: { status: this.currentTab || undefined },
         header: {
           'Content-Type': 'application/json',
           'Authorization': 'Bearer ' + uni.getStorageSync('token')

+ 16 - 0
cfc-frontend/pages/shop/cart/cart.vue

@@ -43,6 +43,8 @@
                 </text>
               </view>
               <text class="item-name">{{ item.productName }}</text>
+              <text v-if="item.specDesc" class="item-spec">{{ item.specDesc }}</text>
+              <text v-if="item.supplierName" class="item-supplier">供应商: {{ item.supplierName }}</text>
               <text class="item-delete" @click="onRemove(item.productId)">🗑</text>
             </view>
             <view class="item-bottom">
@@ -346,6 +348,20 @@ export default {
   white-space: nowrap;
   margin-left: 4rpx;
 }
+.item-spec {
+  font-size: 22rpx;
+  color: #999;
+  display: block;
+  margin-top: 4rpx;
+  flex-basis: 100%;
+}
+.item-supplier {
+  font-size: 20rpx;
+  color: #0EA5E9;
+  display: block;
+  margin-top: 2rpx;
+  flex-basis: 100%;
+}
 .item-delete {
   font-size: 32rpx;
   padding: 4rpx 8rpx;

+ 14 - 0
cfc-frontend/pages/shop/checkout/checkout.vue

@@ -62,6 +62,8 @@
           />
           <view class="item-info">
             <text class="item-name">{{ item.productName }}</text>
+            <text v-if="item.specDesc" class="item-spec-desc">{{ item.specDesc }}</text>
+            <text v-if="item.supplierName" class="item-supplier-name">供应商: {{ item.supplierName }}</text>
             <text class="item-qty">x{{ item.quantity }}</text>
           </view>
           <text class="item-price">{{ formatPriceWithSymbol(item.unitPrice) }}</text>
@@ -600,6 +602,18 @@ export default {
   white-space: nowrap;
   margin-bottom: 6rpx;
 }
+.item-spec-desc {
+  font-size: 22rpx;
+  color: #999;
+  display: block;
+  margin-top: 2rpx;
+}
+.item-supplier-name {
+  font-size: 20rpx;
+  color: #0EA5E9;
+  display: block;
+  margin-top: 2rpx;
+}
 .item-qty {
   font-size: 22rpx;
   color: #999;

+ 3 - 1
cfc-frontend/pages/tianpan/daily-fortune.vue

@@ -64,7 +64,9 @@ export default {
     loadDailyFortune: function() {
       var self = this
       self.loading = true; self.error = null
-      self.$store.dispatch('tianpan/loadDailyFortune', { date: self.todayStr })
+      var familyId = uni.getStorageSync('familyId')
+      if (!familyId) { self.error = '请先选择家庭'; return }
+      self.$store.dispatch('tianpan/loadDailyFortune', { familyId: familyId, date: self.todayStr })
         .then(function(data) { self.fortune = data })
         .catch(function(e) { self.error = e.message || '加载失败' })
         .finally(function() { self.loading = false })

+ 3 - 1
cfc-frontend/pages/tianpan/related-items.vue

@@ -90,7 +90,9 @@ export default {
     loadRelatedItems: function() {
       var self = this
       self.loading = true; self.error = null
-      self.$store.dispatch('tianpan/loadRelatedItems', {})
+      var familyId = uni.getStorageSync('familyId')
+      if (!familyId) { self.error = '请先选择家庭'; return }
+      self.$store.dispatch('tianpan/loadRelatedItems', { familyId: familyId, daysAhead: 7 })
         .then(function(data) { self.items = data || self.items })
         .catch(function(e) { self.error = e.message || '加载失败' })
         .finally(function() { self.loading = false })

+ 3 - 1
cfc-frontend/pages/tianpan/relation-detail.vue

@@ -174,6 +174,7 @@ export default {
   onLoad: function(options) {
     if (options && options.memberId) {
       this.memberId = options.memberId
+      this.memberId2 = options.memberId2 || null
       this.loadData()
     }
   },
@@ -195,7 +196,8 @@ export default {
         this.loading = false
         return
       }
-      this.loadCompatibility({ familyId: familyId, memberId: this.memberId })
+      var memberId2 = this.memberId2 || parseInt(this.memberId)
+      this.loadCompatibility({ member1Id: parseInt(this.memberId), member2Id: parseInt(memberId2) })
         .then(this.setRelationData)
         .catch(this.handleError)
         .finally(function() {

+ 20 - 2
cfc-frontend/pages/user-edit/user-edit.vue

@@ -136,6 +136,18 @@
 				</picker>
 			</view>
 
+			<!-- 兴趣爱好 -->
+			<view class="form-item">
+				<text class="label">兴趣爱好</text>
+				<input type="text" v-model="form.hobbies" placeholder="如: 阅读、运动、音乐" class="input" maxlength="200" />
+			</view>
+
+			<!-- 饮食偏好 -->
+			<view class="form-item">
+				<text class="label">饮食偏好</text>
+				<input type="text" v-model="form.dietPreferences" placeholder="如: 清淡、无辣、低糖" class="input" maxlength="200" />
+			</view>
+
 			<!-- 成长规划师额外字段 -->
 			<view class="form-section-title" v-if="form.role === 'teacher'">成长规划师信息</view>
 			
@@ -261,7 +273,9 @@ export default {
 				ethnicity: '',
 				bloodType: '',
 				highestEducation: '',
-				maritalStatus: ''
+				maritalStatus: '',
+				hobbies: '',
+				dietPreferences: ''
 			},
 			ethnicityOptions: ['汉族','蒙古族','回族','藏族','维吾尔族','苗族','彝族','壮族','布依族','朝鲜族','满族','侗族','瑶族','白族','土家族','哈尼族','哈萨克族','傣族','黎族','其他'],
 			bloodTypeOptions: ['A','B','AB','O','未知'],
@@ -413,6 +427,8 @@ export default {
 					this.form.bloodType = userData.bloodType || ''
 					this.form.highestEducation = userData.highestEducation || ''
 					this.form.maritalStatus = userData.maritalStatus || ''
+					this.form.hobbies = userData.hobbies || ''
+					this.form.dietPreferences = userData.dietPreferences || ''
 					this.form.mascot = userData.mascot || ''
 				}
 			} catch (e) {
@@ -439,7 +455,9 @@ export default {
 					ethnicity: this.form.ethnicity,
 					bloodType: this.form.bloodType,
 					highestEducation: this.form.highestEducation,
-					maritalStatus: this.form.maritalStatus
+					maritalStatus: this.form.maritalStatus,
+					hobbies: this.form.hobbies,
+					dietPreferences: this.form.dietPreferences
 				})
 				uni.showToast({ title: '保存成功', icon: 'success' })
 				setTimeout(() => {

Різницю між файлами не показано, бо вона завелика
+ 274 - 636
cfc-frontend/pages/wealth/index.vue


+ 6 - 6
cfc-frontend/store/modules/tianpan.js

@@ -36,7 +36,7 @@ export default {
       }
       return tianpanDashboard({ familyId: familyId, year: state.currentYear, daysAhead: state.daysAhead })
         .then(function(res) {
-          if (res && res.code === 0) {
+          if (res && res.code === 200) {
             commit('SET_DASHBOARD', res.data)
           }
         })
@@ -54,7 +54,7 @@ export default {
     loadMemberDetail({ commit }, payload) {
       return tianpanMemberDetail(payload.memberId, { memberType: payload.memberType || 'family_member' })
         .then(function(res) {
-          if (res && res.code === 0) {
+          if (res && res.code === 200) {
             commit('SET_MEMBER_DETAIL', res.data)
             return res.data
           }
@@ -63,7 +63,7 @@ export default {
     },
     loadCompatibility({ commit }, payload) {
       return tianpanCompatibility(payload).then(function(res) {
-        if (res && res.code === 0) {
+        if (res && res.code === 200) {
           commit('SET_COMPATIBILITY', res.data)
           return res.data
         }
@@ -72,7 +72,7 @@ export default {
     },
     loadAnnualEnergy({ commit }, payload) {
       return tianpanAnnualEnergy(payload).then(function(res) {
-        if (res && res.code === 0) {
+        if (res && res.code === 200) {
           commit('SET_ANNUAL_ENERGY', res.data)
           return res.data
         }
@@ -81,7 +81,7 @@ export default {
     },
     loadDailyFortune({ commit }, payload) {
       return tianpanDailyFortune(payload).then(function(res) {
-        if (res && res.code === 0) {
+        if (res && res.code === 200) {
           commit('SET_DAILY_FORTUNE', res.data)
           return res.data
         }
@@ -90,7 +90,7 @@ export default {
     },
     loadRelatedItems({ commit }, payload) {
       return tianpanRelatedItems(payload).then(function(res) {
-        if (res && res.code === 0) {
+        if (res && res.code === 200) {
           commit('SET_RELATED_ITEMS', res.data)
           return res.data
         }

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-60657d22049b0aad859d2511cd20bb4405136cf8
+a39b48ee4f99c443023d9e56bded82acd25fd508

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.228",
+  "version": "1.0.248",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.228",
+      "version": "1.0.248",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.229",
+  "version": "1.0.249",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 182 - 0
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,188 @@
 # 更新日志 (Changelog)
 
 此文件记录所有构建版本的变更。
+## v1.0.249 (2026-07-13)
+
+### 文档
+- ISSUE-012~015 family member fixes tracking
+
+### 其他
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+### Bug 修复
+- restore family member menu + auto-derive generationLevel + add birth/preference form fields
+
+### 新功能
+- wire FamilyMember birth fields + User hobbies/dietPreferences + profile history through service/controller/migration
+- UserProfileHistory entity + mapper + schema.sql for tracking profile changes
+- add hobbies + dietPreferences to User entity + UpdateUserDTO
+- add birthHour/birthWeight/birthPlace/isCesarean to FamilyMember entity + DTO
+
+
+## v1.0.248 (2026-07-12)
+
+
+## v1.0.247 (2026-07-12)
+
+### 新功能
+- 商品推荐维度映射 + 复购提醒 + 供应商/规格迁移补全
+- 电商体系升级 — 供应商/规格/推荐日志 + 五维能量富维度详情 + 产品推荐
+
+### 其他
+- - 迁移80: 创建 product_dimension_mapping 表 + 索引
+- - 迁移81: products 表添加 recommendation_tags/purchase_count_threshold/repurchase_interval_days
+- - 迁移82: 创建 repurchase_reminder_record 表
+- - 迁移83: 创建 repurchase_reminder_config 表
+- - 新增 ProductDimensionMapping/RepurchaseReminderConfig/RepurchaseReminderRecord 实体+Mapper
+- - schema.sql 同步新表DDL
+- - 小程序: DimensionProductList 组件 + api.js 推荐/复购接口
+- - 修复 RecommendationQuery DTO
+- 
+- - 新增 Supplier/ProductSpecGroup/ProductSpecOption/ProductSupplier/OrderItemSku 实体+Mapper+Service+Controller
+- - 新增 ProductRecommendationLog 实体+Mapper(推荐记录打点)
+- - EnergyController: /wealth-detail 富维度详情接口
+- - EnergyService: 身克富杠杆逻辑 + calcParentWealth 传参修复
+- - AssessmentService + HealthAnalysisService: 推荐系统相关逻辑
+- - schema.sql: 同步新表DDL
+- - 小程序: 商品详情页规格选择器; 购物车/结算页展示SKU与供应商; 富维度页重构; 个人主页新增
+- - 管理端: EcomSupplierManage.vue 供应商CRUD页面 + admin.js API + 路由
+- - 前端 api.js: 新增供应商/规格/推荐相关接口
+- 
+
+
+## v1.0.246 (2026-07-12)
+
+
+## v1.0.245 (2026-07-12)
+
+### 文档
+- 五维功能-能量映射文档 + 缺失功能补全实施计划
+
+### 其他
+- - 新增 2026-07-12-wuxing-ke-full-design.md(五克完整设计)
+- - 新增 2026-07-12-wuxing-homepage-and-conversion-algorithm.md(首页+转化算法)
+- - 新增 2026-07-12-dimension-pages-gap-fix.md(分阶段实施计划)
+- - 更新 2026-06-08-five-dimension-wuxing-philosophy.md(克哲学对齐新理解)
+- 
+
+
+## v1.0.244 (2026-07-12)
+
+
+## v1.0.243 (2026-07-12)
+
+### Bug 修复
+- 退款流程5项修复 (ISSUE-007~011)
+
+### 其他
+- - ISSUE-008: 中止退款后订单状态未恢复 — cancelPendingRefund增加ProductOrder状态回滚
+- - ISSUE-009: 订单管理搜索条件增强 — 后端支持keyword+日期范围 + 前端增加日期选择器和搜索按钮
+- - ISSUE-010: Dashboard缺失退款审核计数 — StatsController增加pendingRefundCount + Dashboard新增卡片
+- - ISSUE-011: 退款余额不足提示优化 — approveRefund返回明确提示 + 前端使用后端message
+- 
+
+
+## v1.0.242 (2026-07-12)
+
+
+## v1.0.241 (2026-07-12)
+
+### 文档
+- 商品推荐功能实施计划
+
+### 重构
+- 删除 UserQuickEntry 组件及全站引用
+
+### 其他
+- - 移除 wisdom/mind/body 三个页面中的 import、components 注册、模板使用
+- - 清理 body/index.vue 中的残留注释
+- 
+
+
+## v1.0.240 (2026-07-12)
+
+
+## v1.0.239 (2026-07-12)
+
+### 新功能
+- clean dimension tabs + styled action articles
+
+### 其他
+- - action 统一化:补 @manage="goFamilyMembers" + goFamilyMembers method
+- - action 推荐阅读 -> 独立 ActionArticleRecommend 组件,行绿色调卡片造型
+- - 移除未用导入 getContactList/getHealthAlerts/getMilestones
+- 
+
+
+## v1.0.238 (2026-07-12)
+
+
+## v1.0.237 (2026-07-12)
+
+### 文档
+- 添加富维度体系重设计划(子维度拆分+身克富杠杆+小程序富页)
+
+
+## v1.0.236 (2026-07-12)
+
+
+## v1.0.235 (2026-07-12)
+
+### Bug 修复
+- 拆分 v-if 条件提高可读性
+- ArticleManage table 添加 max-height 防止溢出
+- 修复前后端数据流5个关键bug
+
+### 其他
+- - daily-fortune.vue: 请求载荷添加 familyId
+- - related-items.vue: 请求载荷从 {} 改为 { familyId, daysAhead: 7 }
+- - relation-detail.vue: 传参从 { familyId, memberId } 改为 { member1Id, member2Id }
+- - TianpanController: 兼容性接口从参数读取 memberType (不再硬编码 child)
+- 
+
+
+## v1.0.234 (2026-07-12)
+
+
+## v1.0.233 (2026-07-12)
+
+### Bug 修复
+- 待退款队列可见性 + 退款到帐中状态 + 防重复审核
+
+### 其他
+- - ProductOrder实体: refundStatus注释补充 4=退款到帐中
+- - PendingRefund实体: status注释补充 cancelled(已中止)
+- - AdminCommissionController + PendingRefundService: getAllPendingRefunds 支持 orderType 过滤
+- - OrderManage.vue: 新增 refund_pending 状态(退款到帐中)文案/样式/筛选选项,隐藏退款审核按钮
+- - PendingRefund.vue: loadList 传 orderType 到后端
+- - router/index.js: 注册 pending-refund 路由
+- - Layout.vue: 商城营销菜单加'待退款管理'入口
+- 
+
+
+## v1.0.232 (2026-07-11)
+
+
+## v1.0.231 (2026-07-11)
+
+### Bug 修复
+- body index simplify childId checks use || '' fallback
+
+
+## v1.0.230 (2026-07-11)
+
+
 ## v1.0.229 (2026-07-11)
 
 ### Bug 修复

+ 33 - 0
cfc-web/src/api/admin.js

@@ -1381,3 +1381,36 @@ export function updateCommissionConfig(data) {
     data
   })
 }
+
+// 电商供应商管理
+export function getEcomSupplierList(params) {
+  return request({
+    url: '/api/admin/supplier/list',
+    method: 'post',
+    data: params
+  })
+}
+
+export function saveEcomSupplier(data) {
+  return request({
+    url: '/api/admin/supplier/save',
+    method: 'post',
+    data
+  })
+}
+
+export function updateEcomSupplier(data) {
+  return request({
+    url: '/api/admin/supplier/update',
+    method: 'post',
+    data
+  })
+}
+
+export function deleteEcomSupplier(id) {
+  return request({
+    url: '/api/admin/supplier/delete',
+    method: 'post',
+    data: { id }
+  })
+}

+ 19 - 0
cfc-web/src/api/dimension.js

@@ -89,6 +89,25 @@ export function bindKnowledgeTags(data) {
   })
 }
 
+export function uploadKnowledgeFile(file) {
+  const formData = new FormData()
+  formData.append('file', file)
+  return request({
+    url: '/api/admin/knowledge-base/upload-file',
+    method: 'post',
+    data: formData,
+    headers: { 'Content-Type': 'multipart/form-data' }
+  })
+}
+
+export function fetchUrlContent(url) {
+  return request({
+    url: '/api/admin/knowledge-base/fetch-url',
+    method: 'post',
+    data: { url }
+  })
+}
+
 // 标签 API
 export function getTagList() {
   return request({

+ 151 - 21
cfc-web/src/components/KnowledgeDialog/index.vue

@@ -2,23 +2,68 @@
   <el-dialog
     :title="isEdit ? '编辑知识' : '新增知识'"
     :visible="visible"
-    width="700px"
+    width="760px"
     @close="handleClose"
   >
     <el-form ref="form" :model="form" :rules="rules" label-width="80px">
-      <el-form-item label="标题" prop="title">
-        <el-input v-model="form.title" placeholder="知识标题" maxlength="200" />
-      </el-form-item>
-      <el-form-item label="内容" prop="content">
-        <el-input
-          v-model="form.content"
-          type="textarea"
-          :rows="6"
-          placeholder="知识内容"
-          maxlength="5000"
-          show-word-limit
-        />
+      <el-form-item label="来源方式">
+        <el-radio-group v-model="form.sourceType" @change="onSourceTypeChange">
+          <el-radio label="manual">手动输入</el-radio>
+          <el-radio label="file">上传文件</el-radio>
+          <el-radio label="url">网址获取</el-radio>
+        </el-radio-group>
       </el-form-item>
+
+      <template v-if="form.sourceType === 'manual'">
+        <el-form-item label="标题" prop="title">
+          <el-input v-model="form.title" placeholder="知识标题" maxlength="200" />
+        </el-form-item>
+        <el-form-item label="内容" prop="content">
+          <el-input
+            v-model="form.content"
+            type="textarea"
+            :rows="6"
+            placeholder="知识内容"
+            maxlength="5000"
+            show-word-limit
+          />
+        </el-form-item>
+      </template>
+
+      <template v-else-if="form.sourceType === 'file'">
+        <el-form-item label="文件">
+          <el-upload
+            ref="upload"
+            :auto-upload="false"
+            :limit="1"
+            accept=".txt,.pdf,.doc,.docx,.md"
+            :file-list="fileList"
+            :on-change="handleFileChange"
+            :on-remove="handleFileRemove"
+          >
+            <el-button size="small" type="primary">选择文件</el-button>
+            <div slot="tip" style="color:#999;font-size:12px;margin-top:4px">支持 .txt .pdf .doc .docx .md 文件</div>
+          </el-upload>
+        </el-form-item>
+        <el-form-item v-if="form.fileName" label="已选文件">
+          <span>{{ form.fileName }}</span>
+        </el-form-item>
+        <el-form-item label="标题" prop="title">
+          <el-input v-model="form.title" placeholder="知识标题" maxlength="200" />
+        </el-form-item>
+      </template>
+
+      <template v-else-if="form.sourceType === 'url'">
+        <el-form-item label="网址">
+          <el-input v-model="form.sourceUrl" placeholder="请输入网址,如 https://..." style="margin-bottom:8px">
+            <el-button slot="append" :loading="fetching" @click="handleFetchUrl">获取内容</el-button>
+          </el-input>
+        </el-form-item>
+        <el-form-item v-if="form.title || form.content" label="标题">
+          <span>{{ form.title }}</span>
+        </el-form-item>
+      </template>
+
       <el-form-item label="维度">
         <dimension-selector v-model="form.dimensionIds" :multiple="true" />
       </el-form-item>
@@ -48,6 +93,7 @@
 <script>
 import DimensionSelector from '@/components/DimensionSelector'
 import TagSelector from '@/components/TagSelector'
+import { uploadKnowledgeFile, fetchUrlContent } from '@/api/dimension'
 
 export default {
   name: 'KnowledgeDialog',
@@ -59,6 +105,8 @@ export default {
   data() {
     return {
       submitting: false,
+      fetching: false,
+      fileList: [],
       form: this.initForm(),
       rules: {
         title: [{ required: true, message: '请输入知识标题', trigger: 'blur' }],
@@ -76,13 +124,20 @@ export default {
       if (val) {
         this.form = {
           id: val.id,
-          title: val.title,
-          content: val.content,
+          title: val.title || '',
+          content: val.content || '',
           dimensionIds: val.dimensionIds || [],
           tagIds: val.tagIds || [],
           sort: val.sort || 0,
           status: val.status !== undefined ? val.status : 1,
-          remark: val.remark || ''
+          remark: val.remark || '',
+          fileUrl: val.fileUrl || '',
+          fileName: val.fileName || '',
+          sourceUrl: val.sourceUrl || '',
+          sourceType: val.sourceType || 'manual'
+        }
+        if (this.form.fileUrl) {
+          this.fileList = [{ name: this.form.fileName || '已上传文件' }]
         }
       } else {
         this.form = this.initForm()
@@ -99,18 +154,93 @@ export default {
         tagIds: [],
         sort: 0,
         status: 1,
-        remark: ''
+        remark: '',
+        fileUrl: '',
+        fileName: '',
+        sourceUrl: '',
+        sourceType: 'manual'
+      }
+    },
+    onSourceTypeChange() {
+      if (this.form.sourceType !== 'file') {
+        this.fileList = []
+      }
+    },
+    handleFileChange(file, files) {
+      this.fileList = files.slice(-1)
+    },
+    handleFileRemove() {
+      this.fileList = []
+      this.form.fileUrl = ''
+      this.form.fileName = ''
+    },
+    async handleUploadFile() {
+      if (this.fileList.length === 0) {
+        this.$message.warning('请先选择文件')
+        return
+      }
+      const file = this.fileList[0].raw
+      try {
+        const res = await uploadKnowledgeFile(file)
+        if (res.code === 200 && res.data) {
+          this.form.fileUrl = res.data.fileUrl
+          this.form.fileName = res.data.fileName
+          this.$message.success('文件上传成功')
+        } else {
+          this.$message.error(res.message || '上传失败')
+        }
+      } catch (e) {
+        this.$message.error(e.message || '上传失败')
+      }
+    },
+    async handleFetchUrl() {
+      if (!this.form.sourceUrl) {
+        this.$message.warning('请输入网址')
+        return
+      }
+      this.fetching = true
+      try {
+        const res = await fetchUrlContent(this.form.sourceUrl)
+        if (res.code === 200 && res.data) {
+          this.form.title = res.data.title || '未获取到标题'
+          this.form.content = res.data.content || ''
+          this.form.sourceType = 'url'
+          this.$message.success('内容获取成功')
+        } else {
+          this.$message.error(res.message || '获取失败')
+        }
+      } catch (e) {
+        this.$message.error(e.message || '获取失败')
+      } finally {
+        this.fetching = false
       }
     },
     handleClose() {
       this.$refs.form.resetFields()
+      this.fileList = []
       this.$emit('update:visible', false)
     },
     async handleSubmit() {
-      try {
-        await this.$refs.form.validate()
-      } catch {
-        return
+      if (this.form.sourceType === 'manual') {
+        try {
+          await this.$refs.form.validateField(['title', 'content'])
+        } catch {
+          return
+        }
+      } else if (this.form.sourceType === 'file') {
+        if (!this.form.title) {
+          this.$message.warning('请填写标题')
+          return
+        }
+      } else if (this.form.sourceType === 'url') {
+        if (!this.form.sourceUrl) {
+          this.$message.warning('请输入网址')
+          return
+        }
+        if (!this.form.content) {
+          this.$message.warning('请先点击"获取内容"')
+          return
+        }
       }
       this.submitting = true
       try {

+ 13 - 0
cfc-web/src/router/index.js

@@ -258,6 +258,12 @@ const routes = [
         component: () => import('@/views/admin/OrderManage.vue'),
         meta: { title: '订单管理', perm: 'commerce:orders' }
       },
+      {
+        path: 'pending-refund',
+        name: 'PendingRefund',
+        component: () => import('@/views/admin/PendingRefund.vue'),
+        meta: { title: '待退款管理', perm: 'commerce:orders' }
+      },
       {
         path: 'order-detail/:orderNo',
         name: 'OrderDetail',
@@ -564,6 +570,13 @@ const routes = [
         component: () => import('@/views/admin/VirtualTeamDetail'),
         meta: { title: '团队成员管理', perm: 'system:config' },
         props: true
+      },
+      // ========== 电商供应商管理 ==========
+      {
+        path: 'ecom-supplier',
+        name: 'EcomSupplierManage',
+        component: () => import('@/views/admin/EcomSupplierManage'),
+        meta: { title: '电商供应商管理', perm: 'ecom:supplier' }
       }
     ]
   }

+ 1 - 0
cfc-web/src/views/Layout.vue

@@ -192,6 +192,7 @@ export default {
           children: [
             { path: '/product-manage', label: '商品管理', icon: 'el-icon-s-goods', perm: 'commerce:products' },
             { path: '/order-manage', label: '订单管理', icon: 'el-icon-s-order', perm: 'commerce:orders' },
+            { path: '/pending-refund', label: '待退款管理', icon: 'el-icon-warning', perm: 'commerce:orders' },
             { path: '/product-profit-rate', label: '产品利润率', icon: 'el-icon-data-line', perm: 'commerce:profit' },
             { path: '/coupon', label: '优惠券管理', icon: 'el-icon-ticket', perm: 'marketing:coupon' },
             { path: '/promotion', label: '推广管理', icon: 'el-icon-s-marketing', perm: 'marketing:promotion' },

+ 5 - 11
cfc-web/src/views/admin/ArticleManage.vue

@@ -56,21 +56,15 @@
       </div>
 
       <div class="table-scroll-wrap-lg">
-        <el-table :data="list" v-loading="loading" border stripe>
-        <el-table-column prop="id" label="ID" width="70" />
+        <el-table :data="list" v-loading="loading" border stripe style="max-height:calc(100vh - 300px);" :max-height="600">
+          <el-table-column prop="id" label="ID" width="70" />
         <el-table-column label="标题" min-width="220">
           <template slot-scope="{ row }">
             <span>{{ row.title }}</span>
             <el-tag v-if="row.isFeatured === 1 || row.isFeatured === true" size="mini" type="warning" style="margin-left: 6px;">精选</el-tag>
           </template>
         </el-table-column>
-        <div style="overflow:auto;max-height:calc(100vh - 300px);">
-        <el-table style="max-height:calc(100vh - 300px);"-column label="分类" width="100">
-          <template slot-scope="{ row }">
-            {{ row.categoryName || '-' }}
-          </template>
-        </el-table-column>
-        <el-table-column label="可见性" width="90">
+          <el-table-column label="可见性" width="90">
           <template slot-scope="{ row }">
             <el-tag v-if="row.visibility === 'public'" size="small" type="success">公开</el-tag>
             <el-tag v-else-if="row.visibility === 'login'" size="small" type="primary">登录</el-tag>
@@ -207,8 +201,8 @@
               </el-dropdown>
             </template>
           </template>
-        </el-table-column>
-        </el-table></div>
+        </el-table>
+        </div>
       </div>
 
       <div class="pagination-wrap">

+ 3 - 1
cfc-web/src/views/admin/Dashboard.vue

@@ -96,7 +96,8 @@ export default {
         { label: '孩子总数', value: '-', icon: 'el-icon-s-custom', color: 'cyan' },
         { label: '规划师总数', value: '-', icon: 'el-icon-school', color: 'green' },
         { label: '待审核规划师', value: '-', icon: 'el-icon-time', color: 'red' },
-        { label: '待审核套餐', value: '-', icon: 'el-icon-document', color: 'purple' }
+        { label: '待审核套餐', value: '-', icon: 'el-icon-document', color: 'purple' },
+        { label: '待退款审核', value: '-', icon: 'el-icon-warning', color: 'orange' }
       ],
       revenueCards: [
         { label: '本月佣金', value: '-', icon: 'el-icon-money', color: 'orange', sub: '' },
@@ -140,6 +141,7 @@ export default {
           this.statCards[3].value = d.totalTeachers || 0
           this.statCards[4].value = d.pendingGuideCount || 0
           this.statCards[5].value = d.pendingPackageCount || 0
+          this.statCards[6].value = d.pendingRefundCount || 0
           this.recentTasks = d.recentTasks || []
         }
       } catch (e) {

+ 199 - 0
cfc-web/src/views/admin/EcomSupplierManage.vue

@@ -0,0 +1,199 @@
+<template>
+  <div class="ecom-supplier-manage admin-page">
+    <div class="header admin-page-header">
+      <h2 class="admin-page-title">电商供应商管理</h2>
+      <div class="flex items-center gap-sm">
+        <el-input v-model="keyword" placeholder="搜索供应商名称..." prefix-icon="el-icon-search" clearable style="width: 220px;" @keyup.enter.native="handleSearch" @clear="handleSearch" />
+        <el-button type="primary" icon="el-icon-plus" @click="handleCreate">新增供应商</el-button>
+      </div>
+    </div>
+
+    <div class="table-scroll-wrap">
+    <el-table :data="list" v-loading="loading" border stripe>
+      <el-table-column prop="id" label="ID" width="60" />
+      <el-table-column prop="name" label="供应商名称" min-width="160" />
+      <el-table-column prop="contactName" label="联系人" width="120" />
+      <el-table-column prop="contactPhone" label="联系电话" width="140" />
+      <el-table-column label="启用状态" width="100">
+        <template slot-scope="{ row }">
+          <el-switch :value="row.status === 1" @change="handleToggle(row)" />
+        </template>
+      </el-table-column>
+      <el-table-column prop="remark" label="备注" min-width="200" show-overflow-tooltip />
+      <el-table-column label="操作" width="160" fixed="right">
+        <template slot-scope="{ row }">
+          <el-button size="mini" type="primary" @click="handleEdit(row)">编辑</el-button>
+          <el-button size="mini" type="danger" @click="handleDelete(row)">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table></div>
+
+    <el-pagination
+      @size-change="onPageChange"
+      @current-change="onPageChange"
+      :current-page="page"
+      :page-size="size"
+      :total="total"
+      layout="total, prev, pager, next"
+      class="pagination-wrap"
+    />
+
+    <el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="500px" @closed="resetForm">
+      <el-form ref="form" :model="form" :rules="rules" label-width="100px" size="small">
+        <el-form-item label="供应商名称" prop="name">
+          <el-input v-model="form.name" placeholder="请输入供应商名称" maxlength="100" />
+        </el-form-item>
+        <el-form-item label="联系人" prop="contactName">
+          <el-input v-model="form.contactName" placeholder="请输入联系人姓名" maxlength="50" />
+        </el-form-item>
+        <el-form-item label="联系电话" prop="contactPhone">
+          <el-input v-model="form.contactPhone" placeholder="请输入联系电话" maxlength="20" />
+        </el-form-item>
+        <el-form-item label="启用状态">
+          <el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
+        </el-form-item>
+        <el-form-item label="备注">
+          <el-input v-model="form.remark" type="textarea" :rows="3" placeholder="备注信息" maxlength="500" />
+        </el-form-item>
+      </el-form>
+      <span slot="footer">
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
+      </span>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getEcomSupplierList, saveEcomSupplier, updateEcomSupplier, deleteEcomSupplier } from '@/api/admin'
+
+export default {
+  data() {
+    return {
+      list: [],
+      loading: false,
+      page: 1,
+      size: 20,
+      total: 0,
+      keyword: '',
+      dialogVisible: false,
+      saving: false,
+      editId: null,
+      form: {
+        name: '',
+        contactName: '',
+        contactPhone: '',
+        status: 1,
+        remark: ''
+      },
+      rules: {
+        name: [{ required: true, message: '请输入供应商名称', trigger: 'blur' }],
+        contactName: [{ required: true, message: '请输入联系人', trigger: 'blur' }],
+        contactPhone: [{ required: true, message: '请输入联系电话', trigger: 'blur' }]
+      }
+    }
+  },
+  computed: {
+    dialogTitle() {
+      return this.editId ? '编辑供应商' : '新增供应商'
+    }
+  },
+  mounted() {
+    this.loadList()
+  },
+  methods: {
+    async loadList() {
+      this.loading = true
+      try {
+        const res = await getEcomSupplierList({ page: this.page, size: this.size, keyword: this.keyword })
+        this.list = res.data.records || res.data || []
+        this.total = res.data.total || 0
+      } catch (e) {
+        this.$message.error('加载失败')
+      } finally {
+        this.loading = false
+      }
+    },
+    handleSearch() {
+      this.page = 1
+      this.loadList()
+    },
+    onPageChange(val) {
+      if (typeof val === 'number') {
+        this.page = val
+      } else {
+        if (val.page) this.page = val.page
+        if (val.limit) this.size = val.limit
+      }
+      this.loadList()
+    },
+    handleCreate() {
+      this.editId = null
+      this.form = { name: '', contactName: '', contactPhone: '', status: 1, remark: '' }
+      this.dialogVisible = true
+    },
+    handleEdit(row) {
+      this.editId = row.id
+      this.form = { name: row.name, contactName: row.contactName, contactPhone: row.contactPhone, status: row.status, remark: row.remark }
+      this.dialogVisible = true
+    },
+    async handleSave() {
+      try {
+        await this.$refs.form.validate()
+      } catch {
+        return
+      }
+      this.saving = true
+      try {
+        if (this.editId) {
+          await updateEcomSupplier({ ...this.form, id: this.editId })
+          this.$message.success('更新成功')
+        } else {
+          await saveEcomSupplier(this.form)
+          this.$message.success('创建成功')
+        }
+        this.dialogVisible = false
+        this.loadList()
+      } catch (e) {
+        this.$message.error('操作失败')
+      } finally {
+        this.saving = false
+      }
+    },
+    async handleDelete(row) {
+      try {
+        await this.$confirm(`确定删除供应商「${row.name}」?`, '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
+      } catch {
+        return
+      }
+      try {
+        await deleteEcomSupplier(row.id)
+        this.$message.success('删除成功')
+        this.loadList()
+      } catch (e) {
+        this.$message.error('删除失败')
+      }
+    },
+    async handleToggle(row) {
+      try {
+        await updateEcomSupplier({ id: row.id, status: row.status === 1 ? 0 : 1 })
+        this.$message.success('状态已更新')
+        this.loadList()
+      } catch (e) {
+        this.$message.error('状态更新失败')
+      }
+    },
+    resetForm() {
+      if (this.$refs.form) {
+        this.$refs.form.clearValidate()
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.ecom-supplier-manage {
+  padding: 20px;
+}
+</style>

+ 26 - 7
cfc-web/src/views/admin/OrderManage.vue

@@ -3,8 +3,18 @@
     <div class="header">
       <h2 class="admin-page-title">订单管理</h2>
       <div class="filters filter-bar">
-        <el-input v-model="keyword" placeholder="搜索..." prefix-icon="el-icon-search" clearable @keyup.enter.native="handleSearch" @clear="handleSearch" />
-        <el-select v-model="statusFilter" placeholder="订单状态" @change="loadList" clearable>
+        <el-input v-model="keyword" placeholder="搜索商品/订单号..." prefix-icon="el-icon-search" clearable @keyup.enter.native="handleSearch" @clear="handleSearch" style="width:220px;" />
+        <el-date-picker
+          v-model="dateRange"
+          type="daterange"
+          range-separator="至"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          value-format="yyyy-MM-dd"
+          style="width:260px;"
+          @change="handleSearch"
+        />
+        <el-select v-model="statusFilter" placeholder="订单状态" @change="handleSearch" clearable>
           <el-option label="全部状态" value="" />
           <el-option label="待支付" value="pending" />
           <el-option label="已支付(待发货)" value="paid" />
@@ -12,9 +22,11 @@
           <el-option label="已完成" value="completed" />
           <el-option label="已取消" value="cancelled" />
           <el-option label="退款中" value="refunding" />
+          <el-option label="退款到帐中" value="refund_pending" />
           <el-option label="已退款" value="refunded" />
           <el-option label="已关闭" value="closed" />
         </el-select>
+        <el-button type="primary" icon="el-icon-search" @click="handleSearch">搜索</el-button>
       </div>
     </div>
 
@@ -62,6 +74,7 @@
               <el-dropdown-item v-if="row.status === 'paid'" command="ship" icon="el-icon-s-cooperation">发货</el-dropdown-item>
               <el-dropdown-item v-if="row.status === 'paid' || row.status === 'refunding'" command="close" icon="el-icon-circle-close">关闭</el-dropdown-item>
               <el-dropdown-item v-if="row.status === 'refunding'" command="refund" icon="el-icon-warning-outline">退款审核</el-dropdown-item>
+              <el-dropdown-item v-if="row.status === 'refund_pending'" command="view-pending-refund" icon="el-icon-warning">退款到帐中</el-dropdown-item>
             </el-dropdown-menu>
           </el-dropdown>
         </template>
@@ -159,6 +172,7 @@ export default {
       total: 0,
       statusFilter: '',
       keyword: '',
+      dateRange: null,
       detailDialogVisible: false,
       detail: null,
       shipDialogVisible: false,
@@ -189,6 +203,10 @@ export default {
         if (this.keyword) {
           params.keyword = this.keyword
         }
+        if (this.dateRange && this.dateRange.length === 2) {
+          params.startDate = this.dateRange[0]
+          params.endDate = this.dateRange[1]
+        }
         var res = await getOrderList(params)
         this.list = res.data.records || []
         this.total = res.data.total || 0
@@ -255,8 +273,8 @@ export default {
       this.refundLoading = true
       try {
         await this.$confirm('确认同意退款?', '提示', { type: 'warning' })
-        await approveRefund(this.refundTarget.orderNo)
-        this.$message.success('已同意退款')
+        var res = await approveRefund(this.refundTarget.orderNo)
+        this.$message.success(res.message || '已同意退款')
         this.refundDialogVisible = false
         this.loadList()
       } catch (e) {
@@ -284,14 +302,14 @@ export default {
       }
     },
     canClose(status) {
-      return ['pending', 'paid', 'pending_receipt', 'refunding'].includes(status)
+      return ['pending', 'paid', 'pending_receipt', 'refunding'].includes(status);
     },
     statusType(status) {
-      var map = { pending: 'warning', paid: 'primary', pending_receipt: '', completed: 'success', cancelled: 'info', refunding: 'danger', refunded: 'danger', closed: 'info' }
+      var map = { pending: 'warning', paid: 'primary', pending_receipt: '', completed: 'success', cancelled: 'info', refunding: 'danger', refunded: 'danger', closed: 'info', refund_pending: 'warning' }
       return map[status] || 'info'
     },
     statusLabel(status) {
-      var map = { pending: '待支付', paid: '待发货', pending_receipt: '待收货', completed: '已完成', cancelled: '已取消', refunding: '退款中', refunded: '已退款', closed: '已关闭' }
+      var map = { pending: '待支付', paid: '待发货', pending_receipt: '待收货', completed: '已完成', cancelled: '已取消', refunding: '退款中', refunded: '已退款', closed: '已关闭', refund_pending: '退款到帐中' }
       return map[status] || status
     },
     payMethodLabel(method) {
@@ -315,6 +333,7 @@ export default {
         case 'ship': this.showShipDialog(row); break;
         case 'close': this.handleClose(row); break;
         case 'refund': this.showRefundDialog(row); break;
+        case 'view-pending-refund': this.$router.push('/pending-refund'); break;
       }
     }
   }

+ 1 - 0
cfc-web/src/views/admin/PendingRefund.vue

@@ -150,6 +150,7 @@ export default {
       try {
         const res = await getPendingRefundList({
           status: this.statusFilter || undefined,
+          orderType: this.typeFilter || undefined,
           page: this.currentPage,
           size: this.pageSize
         })

+ 420 - 0
docs/product-recommendation/PLAN.md

@@ -0,0 +1,420 @@
+# 商品推荐功能实施计划
+
+## 目标
+
+在小程序中实现三类商品推荐场景:
+
+1. **维度页推荐组件**:在身/智/心/行/富页面底部展示维度关联商品,按匹配分排序
+2. **AI对话推荐**:对话过程中根据上下文推荐相关商品/服务
+3. **报告关联推荐**:上传健康报告或认知测评后,关联推荐相关商品
+
+---
+
+## 第一阶段:基础设施(P0)
+
+### 1.1 新建数据库表
+
+**步骤 1:`product_dimension_mapping` 表**
+
+路径:`cfc-backend/src/main/resources/schema.sql`
+在 `products` 表定义之后添加:
+
+```sql
+CREATE TABLE product_dimension_mapping (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    product_id BIGINT NOT NULL COMMENT '商品ID',
+    dimension_code VARCHAR(32) NOT NULL COMMENT '维度: body/wisdom/mind/action/wealth',
+    match_score INT DEFAULT 100 COMMENT '匹配度 0-100',
+    match_reason VARCHAR(200) COMMENT '匹配原因,如"专注力提升"',
+    tags VARCHAR(500) COMMENT '推荐标签 JSON',
+    enabled TINYINT DEFAULT 1,
+    created_at DATETIME,
+    updated_at DATETIME,
+    INDEX idx_product (product_id),
+    INDEX idx_dimension (dimension_code)
+) ENGINE=InnoDB COMMENT='商品维度关联表';
+```
+
+**步骤 2:`product_recommendation_log` 表**
+
+```sql
+CREATE TABLE product_recommendation_log (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    user_id BIGINT,
+    family_id BIGINT,
+    product_id BIGINT NOT NULL,
+    scene VARCHAR(32) NOT NULL COMMENT 'dimension_page/ai_chat/report_upload/repurchase',
+    reason VARCHAR(200),
+    match_score INT,
+    was_clicked TINYINT DEFAULT 0,
+    was_purchased TINYINT DEFAULT 0,
+    clicked_at DATETIME,
+    purchased_at DATETIME,
+    created_at DATETIME,
+    INDEX idx_user_scene (user_id, scene),
+    INDEX idx_product_purchased (product_id, was_purchased)
+) ENGINE=InnoDB COMMENT='推荐曝光日志';
+```
+
+**步骤 3:`repurchase_reminder_record` 表**
+
+```sql
+CREATE TABLE repurchase_reminder_record (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    user_id BIGINT NOT NULL,
+    product_id BIGINT NOT NULL,
+    order_id BIGINT COMMENT '关联订单ID',
+    reminder_days INT DEFAULT 30,
+    sent_at DATETIME,
+    clicked TINYINT DEFAULT 0,
+    purchased TINYINT DEFAULT 0,
+    INDEX idx_user_pending (user_id, purchased)
+) ENGINE=InnoDB COMMENT='复购提醒发送记录';
+```
+
+**步骤 4:`repurchase_reminder_config` 表**
+
+```sql
+CREATE TABLE repurchase_reminder_config (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    product_category VARCHAR(100),
+    product_id BIGINT COMMENT '特定商品ID(优先于category)',
+    reminder_days INT DEFAULT 30,
+    reminder_template VARCHAR(500) COMMENT '提醒话术模板',
+    max_reminders INT DEFAULT 3,
+    enabled TINYINT DEFAULT 1,
+    created_at DATETIME
+) ENGINE=InnoDB COMMENT='复购提醒配置表';
+```
+
+**步骤 5:`products` 表新增字段**
+
+```sql
+ALTER TABLE products ADD COLUMN recommendation_tags VARCHAR(500) COMMENT '推荐标签 JSON';
+ALTER TABLE products ADD COLUMN purchase_count_threshold INT DEFAULT 0;
+ALTER TABLE products ADD COLUMN repurchase_interval_days INT DEFAULT 30;
+```
+
+### 1.2 数据库迁移脚本
+
+路径:`cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`
+
+在 `runMigrations()` 方法末尾添加迁移N(编号递增),使用 `ensureColumn` 和 `jdbcTemplate.execute` 创建新表。
+
+### 1.3 创建实体类
+
+| 类 | 路径 | 说明 |
+|---|------|------|
+| `ProductDimensionMapping` | `entity/ProductDimensionMapping.java` | 商品-维度关联 |
+| `ProductRecommendationLog` | `entity/ProductRecommendationLog.java` | 推荐曝光日志 |
+| `RepurchaseReminderRecord` | `entity/RepurchaseReminderRecord.java` | 复购提醒记录 |
+| `RepurchaseReminderConfig` | `entity/RepurchaseReminderConfig.java` | 复购提醒配置 |
+
+### 1.4 创建 Mapper
+
+| 类 | 路径 | 说明 |
+|---|------|------|
+| `ProductDimensionMappingMapper` | `mapper/ProductDimensionMappingMapper.java` | 继承 BaseMapper |
+| `ProductRecommendationLogMapper` | `mapper/ProductRecommendationLogMapper.java` | 继承 BaseMapper |
+| `RepurchaseReminderRecordMapper` | `mapper/RepurchaseReminderRecordMapper.java` | 继承 BaseMapper |
+| `RepurchaseReminderConfigMapper` | `mapper/RepurchaseReminderConfigMapper.java` | 继承 BaseMapper |
+
+---
+
+## 第二阶段:维度页推荐(P0)
+
+### 2.1 后端接口
+
+**接口:`POST /api/recommend/dimension-products`**
+
+路径:`cfc-backend/src/main/java/com/etotem/cfc/controller/recommendation/ProductRecommendationController.java`
+
+**请求体:**
+```json
+{
+  "dimensionCode": "wisdom",
+  "familyId": 1,
+  "memberId": 5,
+  "excludeProductIds": [3, 7],
+  "limit": 6
+}
+```
+
+**响应:**
+```json
+{
+  "code": 200,
+  "data": [
+    {
+      "id": 10,
+      "name": "认知能力测评套餐",
+      "coverImage": "https://...",
+      "price": 29900,
+      "memberPrice": 19900,
+      "reason": "专注力得分偏低,推荐优先提升",
+      "matchScore": 85,
+      "productType": "assessment",
+      "url": "/pages/shop/detail?id=10"
+    }
+  ]
+}
+```
+
+### 2.2 推荐算法(`ProductRecommendationService`)
+
+路径:`cfc-backend/src/main/java/com/etotem/cfc/service/ProductRecommendationService.java`
+
+**算法逻辑:**
+
+```
+1. 查询所有上架且有库存的商品(status='上架', stock > 0)
+2. 匹配维度:
+   a. Product.domain == dimensionCode(权重1.0)
+   b. 或 product_dimension_mapping.dimension_code == dimensionCode(权重1.5,从mapping表读取)
+3. 查询 members 的 five_dimension_scores,按 dimension_code 排序
+   → 得分低的维度 → 对应商品推荐权重 × 1.2
+4. 查询 ProductOrder,确认排除已购商品(buyerId 或 familyId 在 90 天内购买过)
+5. 按 match_score = 基础分 × 维度缺口加权 × 已购惩罚 排序
+6. 取前 limit 条返回
+```
+
+**新增方法:**
+- `getDimensionRecommendations(String dimensionCode, Long familyId, Long memberId, List<Long> excludeProductIds, int limit)`
+- `getPurchasedProductIds(Long userId, Long familyId, int daysAgo)`
+- `getMemberDimensionScores(Long familyId, Long memberId)`
+
+### 2.3 前端组件
+
+路径:`cfc-frontend/components/DimensionProductList.vue`
+
+**功能:**
+- Props: `dimensionCode`, `familyId`, `memberScores`, `excludeProductIds`, `limit`
+- 加载时调用 `POST /api/recommend/dimension-products`
+- 显示:商品封面图、名称、价格、推荐理由、匹配分 badge
+- 点击跳转商品详情页
+
+**嵌入位置:**
+- `cfc-frontend/pages/wisdom/index.vue`:在认知雷达图下方添加 `<DimensionProductList dimensionCode="wisdom" ... />`
+- 其他维度页(body/mind/action/wealth)同步添加
+
+### 2.4 推荐日志写入
+
+每次返回推荐结果前,写入 `product_recommendation_log`(scene=`dimension_page`),记录 product_id / user_id / match_score / created_at。
+
+---
+
+## 第三阶段:AI对话推荐(P1)
+
+### 3.1 扩展 FamilyContextService
+
+路径:`cfc-backend/src/main/java/com/etotem/cfc/service/FamilyContextService.java`
+
+**修改 `buildContext(Long userId)` 方法:**
+
+在返回的 inputs Map 中新增3个字段:
+
+```java
+// 新增:成员维度得分(供 Dify 理解家庭短板)
+inputs.put("dimensionScores", buildDimensionScoresContext(userId));
+
+// 新增:最近认知测评摘要
+inputs.put("recentCognitiveResult", buildCognitiveContext(userId));
+
+// 新增:已购买商品标签(避免重复推荐)
+inputs.put("purchasedProductTags", buildPurchasedTagsContext(userId));
+```
+
+**新增私有方法:**
+- `buildDimensionScoresContext(Long userId)` → 查询 `five_dimension_scores` 返回 `[{dimension, score, memberName}]`
+- `buildCognitiveContext(Long userId)` → 查询 `dan_assessment_results` 最新一条,返回 `{weakDimensions: [...], overallScore}`
+- `buildPurchasedTagsContext(Long userId)` → 查询 `product_orders` 中用户已购商品的 `recommendation_tags`
+
+### 3.2 修改 AIChatController 解析逻辑
+
+路径:`cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java`
+
+**扩展 `[RECOMMEND]` 解析:**
+
+在 `sendNutritionMessage()` 的 `[RECOMMEND:]` 解析块中新增:
+
+```java
+// 新增:从 RecommendationQuery 中取 dimensionCode 和 userId,过滤已购
+if (tags != null && !tags.isEmpty()) {
+    RecommendationQuery rq = new RecommendationQuery();
+    rq.setNutritionTags(tags);
+    rq.setTypes(types != null && !types.isEmpty() ? types : null);
+    rq.setLimit(limit);
+    rq.setUserId(userId);  // 新增:传入userId用于过滤已购
+    rq.setFamilyId(familyId);  // 新增:传入familyId用于过滤已购
+    recommendations = recommendationService.search(rq);
+}
+```
+
+### 3.3 扩展 RecommendationService
+
+路径:`cfc-backend/src/main/java/com/etotem/cfc/service/RecommendationService.java`
+
+**修改 `search(RecommendationQuery query)` 方法:**
+
+```java
+// 在 searchProducts() 中新增过滤逻辑:
+// 1. 如果 query.userId 或 query.familyId 存在,排除 90 天内已购商品
+// 2. 如果 query.dimensionCode 存在,按 match_score 排序时加权
+```
+
+**新增字段到 `RecommendationQuery` DTO:**
+```java
+private Long userId;
+private Long familyId;
+private String dimensionCode;  // 用于维度加权
+```
+
+### 3.4 扩展前端聊天页展示
+
+路径:`cfc-frontend/pages/ai/chat.vue`
+
+在现有的 recommendation 卡片展示逻辑中,新增:
+- 推荐理由展示(从返回结果的 `reason` 字段读取)
+- 已购商品标记(接口返回时已过滤,前端无需额外处理)
+
+---
+
+## 第四阶段:报告关联推荐(P1)
+
+### 4.1 健康报告上传后触发
+
+**触发点:** `HealthReportService.analyze(reportId)` 执行完成后
+
+在 `cfc-backend/src/main/java/com/etotem/cfc/service/HealthAnalysisService.java` 的 `analyze()` 方法末尾添加:
+
+```java
+// 触发维度推荐
+try {
+    Map<String, Object> analysisResult = parseAnalysisResult(reportId);
+    List<String> dimensionNeeds = extractDimensionNeeds(analysisResult);
+    List<RecommendationResult> products =
+        productRecommendationService.getReportRelatedProducts(
+            "health_report", analysisResult, userId, 3);
+    // 记录推荐日志,scene = 'report_upload'
+    for (RecommendationResult r : products) {
+        productRecommendationLogService.log(userId, r, "report_upload",
+            "健康报告分析触发:" + String.join(",", dimensionNeeds));
+    }
+} catch (Exception e) {
+    log.warn("报告关联推荐生成失败: {}", e.getMessage());
+}
+```
+
+### 4.2 认知测评上传后触发
+
+**触发点:** `DanAssessmentResult` 写入完成(source=parent_upload)
+
+在 `cfc-backend/src/main/java/com/etotem/cfc/service/CognitiveService.java` 的 `saveAssessmentResult()` 或相关写入方法末尾添加类似逻辑:
+
+```java
+// 提取6维得分中最低的2个维度
+List<String> weakDims = findWeakDimensions(result);  // e.g. ["focusScore", "processingSpeedScore"]
+// 映射到 dimensionCode:focusScore/processingSpeedScore → "wisdom"
+// 查询对应维度商品
+List<RecommendationResult> products =
+    productRecommendationService.getReportRelatedProducts(
+        "cognitive_assessment", weakDims, userId, 3);
+// 记录推荐日志
+```
+
+### 4.3 新增 ProductRecommendationService 方法
+
+```java
+public List<RecommendationResult> getReportRelatedProducts(
+    String reportType, Object analysis, Long userId, int limit) {
+    // reportType = "health_report" 或 "cognitive_assessment"
+    // 根据 reportType 提取关联维度码和标签
+    // 调用 search() 时设置 dimensionCode 和已购过滤
+}
+```
+
+---
+
+## 第五阶段:复购提醒(P2)
+
+### 5.1 定时任务
+
+路径:`cfc-backend/src/main/java/com/etotem/cfc/service/RepurchaseReminderService.java`
+
+**定时扫描(每天 09:00):**
+
+```java
+@Scheduled(cron = "0 0 9 * * ?")
+public void scanAndCreateReminders() {
+    // 1. 查询过去 30~60 天内有已支付订单的用户
+    // 2. 对每个订单商品,匹配 repurchase_reminder_config
+    // 3. 检查是否已发送过 reminder 且未过期
+    // 4. 创建 repurchase_reminder_record(sent_at = now)
+    // 5. 发送小程序订阅消息(调用现有消息通知机制)
+}
+```
+
+### 5.2 记录点击/购买行为
+
+```java
+public void onReminderClicked(Long reminderId) { ... }
+public void onReminderPurchased(Long reminderId, Long orderId) { ... }
+```
+
+### 5.3 前端复购提醒组件
+
+路径:`cfc-frontend/components/RepurchaseReminder.vue`
+
+- 在首页或消息 Tab 展示待处理复购提醒卡片
+- 点击跳商品详情页(携带 `from=repurchase` 参数)
+- 前端 API:`POST /api/recommend/repurchase-reminders` → `GET /api/recommend/repurchase-reminders`
+
+---
+
+## 验证步骤
+
+| 步骤 | 操作 | 预期结果 |
+|------|------|---------|
+| 1 | `mvn clean compile` | 编译通过,无错误 |
+| 2 | `curl -X POST /api/recommend/dimension-products` | 返回维度关联商品列表 |
+| 3 | 小程序打开智页 | 底部显示推荐商品(DimensionProductList) |
+| 4 | 上传健康报告 | 推荐日志写入,scene=report_upload |
+| 5 | AI营养对话触发 [RECOMMEND] | 返回过滤已购后的商品 |
+| 6 | 查看 `product_recommendation_log` 表 | 有 dimension_page 和 report_upload 记录 |
+
+---
+
+## 文件清单
+
+| 操作 | 文件路径 |
+|------|---------|
+| 新增表 | `resources/schema.sql` 中 4 个 CREATE TABLE + ALTER TABLE |
+| 新增迁移 | `config/DatabaseInitializer.java` runMigrations() |
+| 新增实体 ×4 | `entity/ProductDimensionMapping.java` 等 |
+| 新增 Mapper ×4 | `mapper/ProductDimensionMappingMapper.java` 等 |
+| 新增 Service | `service/ProductRecommendationService.java` |
+| 新增 Service | `service/RepurchaseReminderService.java` |
+| 新增 Controller | `controller/recommendation/ProductRecommendationController.java` |
+| 修改 Service | `service/FamilyContextService.java` — buildContext() |
+| 修改 DTO | `dto/RecommendationQuery.java` — 新增 userId/familyId/dimensionCode |
+| 修改 Service | `service/RecommendationService.java` — 过滤已购 |
+| 修改 Controller | `controller/ai/AIChatController.java` — 传入 userId/familyId |
+| 修改 Service | `service/HealthAnalysisService.java` — 报告上传触发推荐 |
+| 修改 Service | `service/CognitiveService.java` — 测评上传触发推荐 |
+| 修改 Service | `service/ProductRecommendationLogService.java`(新建) |
+| 新增前端组件 | `components/DimensionProductList.vue` |
+| 新增前端组件 | `components/RepurchaseReminder.vue` |
+| 修改前端页面 | `pages/wisdom/index.vue` 等 — 嵌入 DimensionProductList |
+| 修改前端页面 | `pages/ai/chat.vue` — 推荐理由展示 |
+| 修改前端 API | `utils/api.js` — 新增推荐相关接口 |
+
+---
+
+## 风险与依赖
+
+| 风险 | 缓解 |
+|------|------|
+| Dify 幻觉推荐 | 后端兜底过滤(status=上架,stock>0),仅在 nutrition/send 接口触发 |
+| 冷启动(mapping 表空) | 初期用 `Product.domain` 隐式匹配;mapping 表由运营后台手动标注或导入 |
+| 推荐效果未验证 | `product_recommendation_log` 记录曝光,后续可做转化率统计 |
+| 复购周期判断不准 | `repurchase_interval_days` 可按商品类别配置,默认 30 天 |

Деякі файли не було показано, через те що забагато файлів було змінено