Przeglądaj źródła

Merge remote-tracking branch 'origin/cfclub' into cfclub

Sisyphus Agent 2 tygodni temu
rodzic
commit
e3c2c01b58
100 zmienionych plików z 4346 dodań i 876 usunięć
  1. 3 0
      .gitignore
  2. 1 1
      cfc-backend/config/application-prod.yml
  3. 385 337
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  4. 63 0
      cfc-backend/src/main/java/com/etotem/cfc/config/GlobalExceptionHandler.java
  5. 16 2
      cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java
  6. 12 3
      cfc-backend/src/main/java/com/etotem/cfc/controller/DanAssessmentController.java
  7. 42 25
      cfc-backend/src/main/java/com/etotem/cfc/controller/EnergyController.java
  8. 17 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthPlanController.java
  9. 81 15
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java
  10. 28 7
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthStatusController.java
  11. 21 11
      cfc-backend/src/main/java/com/etotem/cfc/controller/MembershipController.java
  12. 32 2
      cfc-backend/src/main/java/com/etotem/cfc/controller/PurchaseController.java
  13. 33 44
      cfc-backend/src/main/java/com/etotem/cfc/controller/UserController.java
  14. 41 6
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java
  15. 126 2
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminController.java
  16. 59 14
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCouponController.java
  17. 62 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminHealthPlanController.java
  18. 51 5
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminSupplierController.java
  19. 50 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/InnatePortraitConfigController.java
  20. 14 6
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/KnowledgeBaseController.java
  21. 51 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/NumSoulDetailConfigController.java
  22. 60 5
      cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java
  23. 54 6
      cfc-backend/src/main/java/com/etotem/cfc/controller/assessment/AssessmentReportController.java
  24. 1 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleController.java
  25. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/diet/DietIngredientController.java
  26. 89 28
      cfc-backend/src/main/java/com/etotem/cfc/controller/family/FamilyInviteController.java
  27. 2 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/family/FamilyMembersController.java
  28. 70 3
      cfc-backend/src/main/java/com/etotem/cfc/controller/family/FiveDimensionSelfCheckController.java
  29. 68 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/growth/GrowthPlanController.java
  30. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideFamilyTaskController.java
  31. 11 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideManagementController.java
  32. 3 2
      cfc-backend/src/main/java/com/etotem/cfc/controller/guide/PaymentController.java
  33. 15 3
      cfc-backend/src/main/java/com/etotem/cfc/controller/guide/PlanReviewController.java
  34. 89 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/mind/InnatePortraitController.java
  35. 6 4
      cfc-backend/src/main/java/com/etotem/cfc/controller/notice/NoticeController.java
  36. 13 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/task/TaskController.java
  37. 40 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/user/PortraitController.java
  38. 2 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/DanReportUploadVO.java
  39. 2 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyJoinRequestDTO.java
  40. 21 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/InnatePortraitReportVO.java
  41. 39 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/InnatePortraitVO.java
  42. 26 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/InnateTrajectoryVO.java
  43. 36 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/NumSoulDetailVO.java
  44. 13 45
      cfc-backend/src/main/java/com/etotem/cfc/dto/WuxingSourcingAdviceVO.java
  45. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/Coupon.java
  46. 2 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/DanAssessmentResult.java
  47. 30 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyCoupon.java
  48. 34 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyCouponGrantLog.java
  49. 4 1
      cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyJoinRequest.java
  50. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/FiveDimensionSelfCheck.java
  51. 1 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthGutFlora.java
  52. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthPlan.java
  53. 36 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthSleepRecord.java
  54. 4 1
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthStatus.java
  55. 39 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/InnatePortraitConfig.java
  56. 34 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/InnatePortraitReport.java
  57. 36 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/NumSoulDetailConfig.java
  58. 23 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/SelfCheckIgnore.java
  59. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/User.java
  60. 39 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ArticleReadingRecordMapper.java
  61. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/FamilyCouponGrantLogMapper.java
  62. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/FamilyCouponMapper.java
  63. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/InnatePortraitConfigMapper.java
  64. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/InnatePortraitReportMapper.java
  65. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/NumSoulDetailConfigMapper.java
  66. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/SelfCheckIgnoreMapper.java
  67. 12 10
      cfc-backend/src/main/java/com/etotem/cfc/service/ActivityAdminService.java
  68. 84 0
      cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java
  69. 29 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java
  70. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentPlanGenerator.java
  71. 4 0
      cfc-backend/src/main/java/com/etotem/cfc/service/BadgeService.java
  72. 22 4
      cfc-backend/src/main/java/com/etotem/cfc/service/BeijingNutritionService.java
  73. 2 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ButlerService.java
  74. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ChallengeService.java
  75. 212 106
      cfc-backend/src/main/java/com/etotem/cfc/service/CouponService.java
  76. 10 0
      cfc-backend/src/main/java/com/etotem/cfc/service/DailyCheckinService.java
  77. 19 4
      cfc-backend/src/main/java/com/etotem/cfc/service/DailyOverviewService.java
  78. 17 6
      cfc-backend/src/main/java/com/etotem/cfc/service/DanKnowledgeBaseService.java
  79. 163 8
      cfc-backend/src/main/java/com/etotem/cfc/service/DanReportUploadService.java
  80. 39 4
      cfc-backend/src/main/java/com/etotem/cfc/service/DietIngredientService.java
  81. 193 16
      cfc-backend/src/main/java/com/etotem/cfc/service/DietRecommendationService.java
  82. 10 0
      cfc-backend/src/main/java/com/etotem/cfc/service/EmotionCheckinService.java
  83. 83 35
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyJoinRequestService.java
  84. 71 3
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberAttributeService.java
  85. 19 10
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java
  86. 5 2
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyPlatformPointsService.java
  87. 16 3
      cfc-backend/src/main/java/com/etotem/cfc/service/FinanceCheckinService.java
  88. 241 36
      cfc-backend/src/main/java/com/etotem/cfc/service/FiveDimensionSelfCheckService.java
  89. 49 0
      cfc-backend/src/main/java/com/etotem/cfc/service/GrowthPlanService.java
  90. 7 1
      cfc-backend/src/main/java/com/etotem/cfc/service/GrowthTaskService.java
  91. 17 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java
  92. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportDraftService.java
  93. 120 13
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java
  94. 6 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthStatusService.java
  95. 290 0
      cfc-backend/src/main/java/com/etotem/cfc/service/InnatePortraitService.java
  96. 215 2
      cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java
  97. 66 0
      cfc-backend/src/main/java/com/etotem/cfc/service/NumSoulCalculator.java
  98. 10 2
      cfc-backend/src/main/java/com/etotem/cfc/service/PaymentService.java
  99. 85 28
      cfc-backend/src/main/java/com/etotem/cfc/service/PlanActivationService.java
  100. 9 2
      cfc-backend/src/main/java/com/etotem/cfc/service/PointsExchangeService.java

+ 3 - 0
.gitignore

@@ -37,6 +37,9 @@ coverage/
 *~
 ~$*
 
+# 健康报告解析临时结果(含用户健康数据,不入库)
+**/uploads/json-results/
+
 # Sisyphus work directory
 .sisyphus/
 

+ 1 - 1
cfc-backend/config/application-prod.yml

@@ -29,5 +29,5 @@ admin:
   skip-captcha: false
 
 wechat:
-  test-mode: false
+  test-mode: true
   appkey: ${WECHAT_APPKEY:}

Plik diff jest za duży
+ 385 - 337
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java


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

@@ -1,11 +1,18 @@
 package com.etotem.cfc.config;
 
 import com.etotem.cfc.common.Result;
+import java.io.IOException;
 import javax.servlet.http.HttpServletRequest;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.web.HttpRequestMethodNotSupportedException;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.MissingServletRequestParameterException;
 import org.springframework.web.bind.annotation.ExceptionHandler;
 import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
 import org.springframework.web.multipart.MultipartException;
 
 @RestControllerAdvice
@@ -18,12 +25,68 @@ public class GlobalExceptionHandler {
         return Result.error(400, e.getMessage());
     }
 
+    /** 业务状态流转异常:以 400 + 业务提示返回,不再裸抛 500 */
+    @ExceptionHandler(IllegalStateException.class)
+    public Result<Void> handleIllegalStateException(IllegalStateException e) {
+        return Result.error(400, e.getMessage());
+    }
+
+    /** 请求体缺失/JSON 语法错误/类型不匹配 */
+    @ExceptionHandler(HttpMessageNotReadableException.class)
+    public Result<Void> handleHttpMessageNotReadable(HttpMessageNotReadableException e) {
+        log.warn("请求体解析失败: {}", e.getMessage());
+        return Result.error(400, "请求参数格式错误,请检查请求体 JSON 或字段类型");
+    }
+
+    /** 缺少必填请求参数(@RequestParam 未传) */
+    @ExceptionHandler(MissingServletRequestParameterException.class)
+    public Result<Void> handleMissingParam(MissingServletRequestParameterException e) {
+        return Result.error(400, "缺少必填参数: " + e.getParameterName());
+    }
+
+    /** @Valid 参数校验失败:返回首个字段错误提示 */
+    @ExceptionHandler(MethodArgumentNotValidException.class)
+    public Result<Void> handleMethodArgumentNotValid(MethodArgumentNotValidException e) {
+        if (e.getBindingResult() != null && e.getBindingResult().getFieldError() != null) {
+            return Result.error(400, e.getBindingResult().getFieldError().getDefaultMessage());
+        }
+        return Result.error(400, "参数校验失败");
+    }
+
+    /** 字段缺失/超长/非空约束等数据完整性问题:改为 400 业务提示 */
+    @ExceptionHandler(DataIntegrityViolationException.class)
+    public Result<Void> handleDataIntegrityViolation(DataIntegrityViolationException e, HttpServletRequest request) {
+        log.warn("数据完整性校验失败: uri={}, error={}", request.getRequestURI(), e.getMessage());
+        return Result.error(400, "数据不完整或格式错误:请检查必填字段与字段长度");
+    }
+
     @ExceptionHandler(MultipartException.class)
     public Result<Void> handleMultipartException(MultipartException e, HttpServletRequest request) {
         log.warn("非multipart请求访问文件接口: uri={}, error={}", request.getRequestURI(), e.getMessage());
         return Result.error(400, "请求格式错误,请使用文件表单上传");
     }
 
+    // 旧客户端用 GET 等废弃方法调用已统一为 POST 的接口(如 /api/notices/list),返回干净的 405,不打错误栈
+    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
+    public Result<Void> handleMethodNotSupported(HttpRequestMethodNotSupportedException e, HttpServletRequest request) {
+        log.warn("请求方法不支持: uri={}, method={}", request.getRequestURI(), e.getMethod());
+        return Result.error(405, "请求方法不支持,本平台接口统一使用POST");
+    }
+
+    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
+    public Result<Void> handleTypeMismatch(MethodArgumentTypeMismatchException e, HttpServletRequest request) {
+        log.warn("路径参数类型错误: uri={}, error={}", request.getRequestURI(), e.getMessage());
+        return Result.error(400, "请求参数格式错误");
+    }
+
+    // 客户端中断连接(Broken pipe / ClientAbortException):连接已断开,无法写响应,
+    // 只记录日志,避免在 @ExceptionHandler 中二次抛异常导致 "Failure in @ExceptionHandler"
+    @ExceptionHandler(IOException.class)
+    public Result<Void> handleClientAbort(IOException e, HttpServletRequest request) {
+        log.warn("客户端连接中断: uri={}, error={}", request.getRequestURI(), e.getMessage());
+        return null;
+    }
+
     @ExceptionHandler(Exception.class)
     public Result<Void> handleException(Exception e, HttpServletRequest request) {
         log.error("未捕获异常: {} - {} | uri={}", e.getClass().getName(), e.getMessage(), request.getRequestURI(), e);

+ 16 - 2
cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java

@@ -68,7 +68,8 @@ public class JwtInterceptor implements HandlerInterceptor {
 
     // 必须登录才能访问的路径:anonymous 访问直接 401(这些接口消耗微信配额/写数据/扣积分)
     private static final String[] LOGIN_REQUIRED_PATHS = {
-        "/api/share/qrcode"     // 生成海报小程序码:anonymous 会反复触发微信 access_token 失效("not latest")
+        "/api/share/qrcode",        // 生成海报小程序码:anonymous 会反复触发微信 access_token 失效("not latest")
+        "/api/auth/verify-password" // 密码验证:属凭证操作,必须登录(防 /api/auth/verify 前缀碰撞白名单放行)
     };
 
     @Override
@@ -80,7 +81,9 @@ public class JwtInterceptor implements HandlerInterceptor {
 
         String path = request.getRequestURI();
         for (String publicPath : PUBLIC_PATHS) {
-            if (path.equals(publicPath) || path.startsWith(publicPath)) {
+            String boundaryPath = publicPath.endsWith("/**")
+                    ? publicPath.substring(0, publicPath.length() - 3) : publicPath;
+            if (path.equals(publicPath) || path.startsWith(boundaryPath + "/")) {
                 return true;
             }
         }
@@ -140,6 +143,17 @@ public class JwtInterceptor implements HandlerInterceptor {
             try {
                 User user = userMapper.selectById(userId);
                 Long userFamilyId = (user != null) ? user.getFamilyId() : null;
+                // 兜底:user.family_id 为空时,从 family_members 表反查用户所属家庭
+                if (userFamilyId == null) {
+                    FamilyMember fm = familyMemberMapper.selectOne(
+                        new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<FamilyMember>()
+                            .eq(FamilyMember::getUserId, userId)
+                            .last("LIMIT 1")
+                    );
+                    if (fm != null) {
+                        userFamilyId = fm.getFamilyId();
+                    }
+                }
                 request.setAttribute("familyId", userFamilyId);
             } catch (Exception e) {
                 log.warn("查询用户familyId失败: userId={}, error={}", userId, e.getMessage());

+ 12 - 3
cfc-backend/src/main/java/com/etotem/cfc/controller/DanAssessmentController.java

@@ -377,7 +377,7 @@ public class DanAssessmentController {
 
     @Operation(summary = "确认并保存 DAN 报告(Phase 2)— 已迁移到 dan_report_uploads 表")
     @PostMapping("/report/confirm")
-    public Result<String> confirmReport(@RequestBody Map<String, Object> body,
+    public Result<Map<String, Object>> confirmReport(@RequestBody Map<String, Object> body,
                                          @RequestAttribute("userId") Long userId) {
         Long draftId = ParamUtils.getLong(body.get("draftId"));
         if (draftId == null) {
@@ -385,9 +385,18 @@ public class DanAssessmentController {
         }
         String childName = (String) body.get("childName");
         String assessmentDateStr = (String) body.get("assessmentDate");
-        Result<?> r = danReportUploadService.confirmUpload(draftId, userId, childName, assessmentDateStr);
+        Result<com.etotem.cfc.dto.DanReportUploadVO> r =
+                danReportUploadService.confirmUpload(draftId, userId, childName, assessmentDateStr);
         if (r.getCode() != null && r.getCode() == 200) {
-            return Result.success("报告已确认并保存");
+            Map<String, Object> data = new java.util.LinkedHashMap<>();
+            data.put("message", "报告已确认并保存");
+            if (r.getData() != null) {
+                data.put("uploadId", r.getData().getId());
+                data.put("linkedResultId", r.getData().getLinkedResultId());
+                data.put("planId", r.getData().getPlanId());
+                data.put("dimension", r.getData().getDimension());
+            }
+            return Result.success(data);
         }
         return Result.error(r.getCode() == null ? 500 : r.getCode(), r.getMessage());
     }

+ 42 - 25
cfc-backend/src/main/java/com/etotem/cfc/controller/EnergyController.java

@@ -41,8 +41,11 @@ public class EnergyController {
      * 获取能量沙盘数据(个人/全家两层,受通关门禁控制)
      * view=personal: 当前登录用户个人五维(需通过 SELF_CHECK 关卡)
      * view=family:   家庭所有成员聚合五维(需通过 INVITE_FAMILY 关卡)
+     * memberId(可选): 参数指定成员(family_members.id),personalData 返回该成员个人五维;
+     *                 不传时返回当前登录用户。响应始终同时携带 personalData + familyData,
+     *                 前端切换个人/全家无需再次请求。
      */
-    @Operation(summary = "获取能量沙盘(支持个人/全家,含门禁)")
+    @Operation(summary = "获取能量沙盘(个人/全家一次返回,支持指定成员,含门禁)")
     @PostMapping("/sandbox")
     public Result<Map<String, Object>> getFamilyEnergySandbox(
             @RequestAttribute("userId") Long userId,
@@ -57,31 +60,16 @@ public class EnergyController {
         }
 
         String view = "personal";
-        if (body != null && body.get("view") != null) {
-            view = String.valueOf(body.get("view"));
+        Long targetMemberId = null;
+        if (body != null) {
+            if (body.get("view") != null) {
+                view = String.valueOf(body.get("view"));
+            }
+            targetMemberId = ParamUtils.getLong(body.get("memberId"));
         }
 
         Map<String, Object> result = new HashMap<>();
 
-        if ("family".equals(view)) {
-            // 全家沙盘:需通过 INVITE_FAMILY 关卡
-            UnlockGate inviteGate = unlockGateService.getGateByType("INVITE_FAMILY");
-            boolean unlocked = inviteGate == null
-                    || unlockGateService.isGateUnlocked(user.getFamilyId(), inviteGate);
-            if (!unlocked && inviteGate != null) {
-                result.put("locked", true);
-                result.put("view", "family");
-                result.put("gate", gateToMap(inviteGate));
-                return Result.success(result);
-            }
-            EnergySandboxDTO dto = energyService.calculateFamilyEnergy(user.getFamilyId());
-            result.put("locked", false);
-            result.put("view", "family");
-            result.put("familyData", dto);
-            return Result.success(result);
-        }
-
-        // 个人沙盘(默认):家庭级判据 —— 任一成员做过五维自检(书稿"测量先行")
         boolean familySelfCheckPassed = unlockGateService.hasFamilySelfCheck(user.getFamilyId());
         if (!familySelfCheckPassed) {
             result.put("locked", true);
@@ -90,13 +78,17 @@ public class EnergyController {
             return Result.success(result);
         }
 
-        EnergySandboxDTO dto = energyService.calculatePersonalEnergy(userId);
+        EnergySandboxDTO personalData = resolvePersonalData(userId, user.getFamilyId(), targetMemberId);
+        EnergySandboxDTO familyData = energyService.calculateFamilyEnergy(user.getFamilyId());
+
         UnlockGate inviteGate = unlockGateService.getGateByType("INVITE_FAMILY");
         boolean familyUnlocked = inviteGate == null
                 || unlockGateService.isGateUnlocked(user.getFamilyId(), inviteGate);
+
         result.put("locked", false);
-        result.put("view", "personal");
-        result.put("personalData", dto);
+        result.put("view", "family".equals(view) ? "family" : "personal");
+        result.put("personalData", personalData);
+        result.put("familyData", familyData);
         result.put("familyUnlocked", familyUnlocked);
         if (!familyUnlocked && inviteGate != null) {
             result.put("familyGate", gateToMap(inviteGate));
@@ -104,6 +96,31 @@ public class EnergyController {
         return Result.success(result);
     }
 
+    private EnergySandboxDTO resolvePersonalData(Long userId, Long familyId, Long targetMemberId) {
+        if (targetMemberId != null) {
+            EnergySandboxDTO family = energyService.calculateFamilyEnergy(familyId);
+            if (family != null && family.getMembers() != null) {
+                MemberEnergyDTO target = family.getMembers().stream()
+                        .filter(m -> targetMemberId.equals(m.getMemberId()))
+                        .findFirst().orElse(null);
+                if (target != null) {
+                    EnergySandboxDTO dto = new EnergySandboxDTO();
+                    List<MemberEnergyDTO> members = new ArrayList<>();
+                    members.add(target);
+                    dto.setMembers(members);
+                    dto.setBodyScore(target.getBodyScore());
+                    dto.setMindScore(target.getMindScore());
+                    dto.setWisdomScore(target.getWisdomScore());
+                    dto.setActionScore(target.getActionScore());
+                    dto.setWealthScore(target.getWealthScore());
+                    dto.setOverallScore(target.getOverallScore());
+                    return dto;
+                }
+            }
+        }
+        return energyService.calculatePersonalEnergy(userId);
+    }
+
     private Map<String, Object> gateToMap(UnlockGate gate) {
         if (gate == null) return null;
         Map<String, Object> m = new HashMap<>();

+ 17 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthPlanController.java

@@ -3,6 +3,7 @@ package com.etotem.cfc.controller;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.service.PurchaseService;
 import com.etotem.cfc.entity.HealthPlan;
+import com.etotem.cfc.entity.Task;
 import com.etotem.cfc.service.HealthPlanService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
@@ -122,9 +123,25 @@ public class HealthPlanController {
         if (planId == null) return Result.error("planId不能为空");
         HealthPlan plan = healthPlanService.getPlanDetail(planId);
         if (plan == null) return Result.error("方案不存在");
+        plan.setTasks(healthPlanService.getPlanTasks(planId));
         return Result.success(plan);
     }
 
+    @Operation(summary = "查询方案分解任务列表")
+    @PostMapping("/tasks")
+    public Result<List<Task>> planTasks(@RequestBody Map<String, Object> params,
+                                        @RequestAttribute(value = "familyId", required = false) Long familyId) {
+        Long planId = ParamUtils.getLong(params.get("planId"));
+        if (planId == null) return Result.error("planId不能为空");
+        HealthPlan plan = healthPlanService.getPlanDetail(planId);
+        if (plan == null) return Result.error("方案不存在");
+        if (familyId != null && plan.getFamilyId() != null && !familyId.equals(plan.getFamilyId())) {
+            return Result.error("无权查看该方案任务");
+        }
+        List<Task> tasks = healthPlanService.getPlanTasks(planId);
+        return Result.success(tasks);
+    }
+
     @Operation(summary = "获取可用规划师列表")
     @PostMapping("/available-teachers")
     public Result<List<Map<String, Object>>> availableTeachers(

+ 81 - 15
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -159,6 +159,9 @@ public class HealthReportController {
     @Resource
     private com.etotem.cfc.service.PurchaseService purchaseService;
 
+    @Resource
+    private com.etotem.cfc.service.HealthPlanService healthPlanService;
+
     /**
      * 创建健康报告(含指标明细)
      */
@@ -287,7 +290,8 @@ public class HealthReportController {
             // 未指定 memberId 时,返回当前家庭的全部报告
             reports = healthReportService.getFamilyReports(familyId);
         } else {
-            return Result.error("无法获取家庭信息,请先创建或加入家庭");
+            // 无家庭信息时返回空列表,避免前端报错
+            return Result.success(new ArrayList<>());
         }
         if (size > 0) {
             return Result.success(limitLatest(reports, size));
@@ -875,7 +879,7 @@ public class HealthReportController {
             return Result.success(result);
         }
 
-        if (file.isEmpty()) {
+        if (file == null || file.isEmpty()) {
             return Result.error("文件不能为空");
         }
 
@@ -1139,16 +1143,20 @@ public class HealthReportController {
 
             // 落库通用展示块(blocks 组装失败不阻断确认)
             try {
-                reportBlockService.save("gut_flora", created.getId(),
-                        reportBlockAssembler.assembleGutFlora(payload));
+                String reportType = created.getReportType();
+                reportBlockService.save(reportType, created.getId(),
+                        reportBlockAssembler.assembleWithTemplate(reportType, payload));
             } catch (Exception e) {
-                log.warn("保存gut_flora展示块失败 reportId={}: {}", created.getId(), e.getMessage());
+                log.warn("保存展示块失败 reportId={}: {}", created.getId(), e.getMessage());
             }
 
             Map<String, Object> detail = healthReportService.getReportDetail(created.getId());
             if (subjectId != null) {
                 detail.put("subjectId", subjectId);
             }
+            detail.put("reportId", created.getId());
+            detail.put("reportType", created.getReportType());
+            triggerAutoPlanAfterConfirm(detail, draft.getFamilyId(), subjectId, created.getReportType());
             return Result.success(detail);
 
         } catch (Exception e) {
@@ -1157,6 +1165,26 @@ public class HealthReportController {
         }
     }
 
+    /** 报告确认入库后自动生成并发布健康方案(舌诊等不适用类型跳过) */
+    private void triggerAutoPlanAfterConfirm(Map<String, Object> detail, Long familyId,
+                                             Long subjectId, String reportType) {
+        if (familyId == null || subjectId == null || !shouldAutoGeneratePlan(reportType)) {
+            return;
+        }
+        try {
+            Long planId = healthPlanService.autoGenerateAndPublish(familyId, subjectId, reportType);
+            if (planId != null) {
+                detail.put("planId", planId);
+            }
+        } catch (Exception e) {
+            log.warn("确认入库后自动生成方案失败 reportType={}: {}", reportType, e.getMessage());
+        }
+    }
+
+    private boolean shouldAutoGeneratePlan(String reportType) {
+        return reportType != null && !"tongue".equals(reportType);
+    }
+
     /**
      * 放弃草稿
      */
@@ -1368,15 +1396,23 @@ public class HealthReportController {
 
     @Operation(summary = "获取食材推荐列表")
     @PostMapping("/foods")
-    public Result<List<Food>> listFoods(@RequestBody(required = false) Map<String, Object> params) {
-        if (params == null) {
-            return Result.success(java.util.Collections.emptyList());
-        }
-        Long reportId = ParamUtils.getLong(params.get("reportId"));
-        if (reportId == null) {
-            return Result.success(java.util.Collections.emptyList());
+    public Result<List<Food>> listFoods(@RequestBody(required = false) Map<String, Object> params,
+                                        @RequestAttribute(value = "userId", required = false) Long userId) {
+        Long reportId = params != null ? ParamUtils.getLong(params.get("reportId")) : null;
+        List<Food> foods;
+        if (reportId != null) {
+            // 指定报告:返回该报告的食材推荐
+            foods = foodService.listByReportId(reportId);
+        } else {
+            // 未指定报告:返回当前用户最新报告的食材推荐(当前最适合)
+            Long uid = userId != null ? userId : -1L;
+            HealthReport latest = healthReportService.getLatestReport(uid);
+            if (latest == null) {
+                foods = java.util.Collections.emptyList();
+            } else {
+                foods = foodService.listByReportId(latest.getId());
+            }
         }
-        List<Food> foods = foodService.listByReportId(reportId);
         return Result.success(foods);
     }
 
@@ -1960,11 +1996,41 @@ public class HealthReportController {
         gf.setPopulationLevel(pf.getPopulationLevel());
         gf.setDetectionRate(pf.getDetectionRate());
         gf.setDescription(pf.getDescription());
-        gf.setCategory(pf.getCategory());
-        gf.setLevel(pf.getLevel());
+        // 分类层级表归一化:募极/北京的"菌纲构成/菌目构成..."→ taxonomy + 英文LEVEL,
+        // 否则前端 taxonomy 标签页按 category='taxonomy' + level='CLASS' 过滤匹配不到
+        gf.setCategory(normalizeFloraCategory(pf.getCategory()));
+        gf.setLevel(normalizeFloraLevel(pf.getCategory(), pf.getLevel()));
         return gf;
     }
 
+    /**
+     * 菌群分类归一化:以"构成"结尾的层级分类(菌门构成/菌纲构成/菌目构成/菌科构成/菌属构成/菌种构成)
+     * 统一映射为 taxonomy,与前端标签页过滤条件对齐。
+     */
+    private String normalizeFloraCategory(String category) {
+        if (category == null) return null;
+        if (category.endsWith("构成")) {
+            return HealthGutFlora.CATEGORY_TAXONOMY; // "taxonomy"
+        }
+        return category;
+    }
+
+    /**
+     * 菌群层级归一化:中文层级(门/纲/目/科/属/种)→ 英文 LEVEL 常量,
+     * 与前端 levelMap 对齐。非层级数据(如 category=核心菌属 且 level=GENUS)原样保留。
+     */
+    private String normalizeFloraLevel(String category, String level) {
+        if (level == null) return null;
+        String normalized = level;
+        if ("门".equals(level)) normalized = HealthGutFlora.LEVEL_PHYLUM;
+        else if ("纲".equals(level)) normalized = HealthGutFlora.LEVEL_CLASS;
+        else if ("目".equals(level)) normalized = HealthGutFlora.LEVEL_ORDER;
+        else if ("科".equals(level)) normalized = HealthGutFlora.LEVEL_FAMILY;
+        else if ("属".equals(level)) normalized = HealthGutFlora.LEVEL_GENUS;
+        else if ("种".equals(level)) normalized = HealthGutFlora.LEVEL_SPECIES;
+        return normalized;
+    }
+
     private List<HealthDiseaseRisk> buildDiseaseRisksFromPayload(ParsedReportPayload.Payload payload) {
         List<ParsedReportPayload.DiseaseRisk> list = payload.getDiseaseRisks();
         if (list == null || list.isEmpty()) return new ArrayList<>();

+ 28 - 7
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthStatusController.java

@@ -13,6 +13,8 @@ import org.springframework.web.bind.annotation.RestController;
 
 import javax.annotation.Resource;
 import java.math.BigDecimal;
+import java.util.Map;
+import com.etotem.cfc.util.ParamUtils;
 
 @Tag(name = "健康现状档案", description = "当前状态调研-健康现状档案接口")
 @RestController
@@ -22,25 +24,44 @@ public class HealthStatusController {
     @Resource
     private HealthStatusService healthStatusService;
 
-    @Operation(summary = "查询当前账号健康现状(未填写返回 null)")
+    @Operation(summary = "查询当前账号健康现状(memberId 为空查账号级,非空查成员级)")
     @PostMapping("/get")
-    public Result<HealthStatus> get(@RequestAttribute("userId") Long userId) {
-        return Result.success(healthStatusService.getByUserId(userId));
+    public Result<HealthStatus> get(@RequestAttribute("userId") Long userId,
+                                    @RequestBody(required = false) Map<String, Object> params) {
+        Long memberId = params != null ? ParamUtils.getLong(params.get("memberId")) : null;
+        return Result.success(memberId != null
+                ? healthStatusService.getByMemberId(userId, memberId)
+                : healthStatusService.getByUserId(userId));
     }
 
-    @Operation(summary = "保存/更新当前账号健康现状(upsert)")
+    @Operation(summary = "保存/更新当前账号健康现状(memberId 为空存账号级,非空存成员级)")
     @PostMapping("/save")
     public Result<HealthStatus> save(
             @RequestAttribute("userId") Long userId,
-            @RequestBody HealthStatus status) {
-        if (status == null || isEmpty(status)) {
+            @RequestBody Map<String, Object> params) {
+        Long memberId = params != null ? ParamUtils.getLong(params.get("memberId")) : null;
+        HealthStatus status = new HealthStatus();
+        if (params == null) {
+            return Result.error("请至少填写一项");
+        }
+        if (params.get("heightCm") != null) status.setHeightCm(new BigDecimal(params.get("heightCm").toString()));
+        if (params.get("weightKg") != null) status.setWeightKg(new BigDecimal(params.get("weightKg").toString()));
+        if (params.get("bloodPressure") != null) status.setBloodPressure(params.get("bloodPressure").toString());
+        if (params.get("bloodGlucose") != null) status.setBloodGlucose(new BigDecimal(params.get("bloodGlucose").toString()));
+        if (params.get("bloodLipids") != null) status.setBloodLipids(params.get("bloodLipids").toString());
+        if (params.get("diseaseHistory") != null) status.setDiseaseHistory(params.get("diseaseHistory").toString());
+        if (params.get("medications") != null) status.setMedications(params.get("medications").toString());
+        if (params.get("notes") != null) status.setNotes(params.get("notes").toString());
+        if (isEmpty(status)) {
             return Result.error("请至少填写一项");
         }
         String validateMsg = validate(status);
         if (validateMsg != null) {
             return Result.error(validateMsg);
         }
-        return Result.success(healthStatusService.save(userId, status));
+        return Result.success(memberId != null
+                ? healthStatusService.saveForMember(userId, memberId, status)
+                : healthStatusService.save(userId, status));
     }
 
     /** 至少填写一项才算有效 */

+ 21 - 11
cfc-backend/src/main/java/com/etotem/cfc/controller/MembershipController.java

@@ -222,35 +222,45 @@ public class MembershipController {
             return Result.error("用户未登录");
         }
 
+        User user = userMapper.selectById(userId);
+        if (user == null) {
+            return Result.error("用户不存在");
+        }
+
         Long familyId = membershipService.getUserFamilyId(userId);
         String levelCode = params.get("levelCode").toString();
         String paymentType = params.get("paymentType").toString();
         String period = params.get("period") != null ? params.get("period").toString() : "yearly";
         Long couponId = ParamUtils.getLong(params.get("couponId"));
+        String openid = params.get("openid") != null ? params.get("openid").toString() : null;
 
-        PaymentOrderDTO order = membershipService.createOrder(userId, familyId, levelCode, paymentType, period, couponId);
-
-        // trial 单:createOrder 内已直接开通(status=paid),无需支付,原样返回
+        // trial 单:createOrderWithPayType 内直接开通(status=paid),返回 payType=none
         if ("trial".equals(paymentType)) {
-            return Result.success(order);
+            Map<String, Object> result = membershipService.createOrderWithPayType(
+                    userId, familyId, levelCode, paymentType, period, couponId, null, null);
+            return Result.success(result);
         }
 
-        // testMode 兼容:返回订单信息,前端继续走 /notify 模拟支付(保留现有模拟逻辑
+        // testMode:保持现有模拟逻辑(不触发真实虚拟/普通支付
         if (testMode) {
+            PaymentOrderDTO order = membershipService.createOrder(userId, familyId, levelCode, paymentType, period, couponId);
             return Result.success(order);
         }
 
-        // sessionKey 后端从登录态取(users.session_key,@JsonIgnore 防泄露),不由前端传
-        User user = userMapper.selectById(userId);
-        if (user == null || user.getSessionKey() == null || user.getSessionKey().isEmpty()) {
+        // sessionKey 后端从登录态取,虚拟/普通支付路径均需有效 sessionKey
+        if (user.getSessionKey() == null || user.getSessionKey().isEmpty()) {
             return Result.error(400, "会话失效,请重新登录");
         }
 
-        VirtualPayParamsDTO dto = virtualPayService.generateMembershipPayParams(order.getOrderNo(), user.getSessionKey());
-        if (dto == null) {
+        // 调用新方法,按开关分流
+        Map<String, Object> result = membershipService.createOrderWithPayType(
+                userId, familyId, levelCode, paymentType, period, couponId,
+                user.getSessionKey(), openid);
+
+        if (result == null) {
             return Result.error(46001, "该商品暂未开放购买");
         }
-        return Result.success(dto);
+        return Result.success(result);
     }
 
     /**

+ 32 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/PurchaseController.java

@@ -1,8 +1,12 @@
 package com.etotem.cfc.controller;
 
 import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.VirtualPayParamsDTO;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.service.PurchaseService;
 import com.etotem.cfc.service.ChallengeService;
+import com.etotem.cfc.service.VirtualPayService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.*;
 
@@ -15,6 +19,8 @@ public class PurchaseController {
 
     @Autowired private PurchaseService purchaseService;
     @Autowired private ChallengeService challengeService;
+    @Autowired private VirtualPayService virtualPayService;
+    @Autowired private UserMapper userMapper;
 
     @PostMapping("/check")
     public Result<Map<String, Object>> checkPurchase(HttpServletRequest request) {
@@ -31,6 +37,29 @@ public class PurchaseController {
         return Result.success(result);
     }
 
+    @PostMapping("/pay-params")
+    public Result<Object> getPayParams(@RequestBody Map<String, Object> params, HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        if (userId == null) {
+            return Result.error("用户未登录");
+        }
+        String orderNo = (String) params.get("orderNo");
+        if (orderNo == null || orderNo.isEmpty()) {
+            return Result.error("缺少orderNo");
+        }
+
+        User user = userMapper.selectById(userId);
+        if (user == null || user.getSessionKey() == null || user.getSessionKey().isEmpty()) {
+            return Result.error(400, "会话失效,请重新登录");
+        }
+
+        VirtualPayParamsDTO dto = virtualPayService.generatePurchasePayParams(orderNo, user.getSessionKey());
+        if (dto == null) {
+            return Result.error(46001, "该商品暂未开放购买");
+        }
+        return Result.success(dto);
+    }
+
     @PostMapping("/confirm")
     public Result<Map<String, Object>> confirmPayment(@RequestBody Map<String, Object> params, HttpServletRequest request) {
         String orderNo = (String) params.get("orderNo");
@@ -56,8 +85,9 @@ public class PurchaseController {
         return Result.success(result);
     }
 
-    @GetMapping("/challenge/progress")
-    public Result<Map<String, Object>> getChallengeProgress(@RequestParam Long memberId, HttpServletRequest request) {
+    @PostMapping("/challenge/progress")
+    public Result<Map<String, Object>> getChallengeProgress(@RequestBody Map<String, Object> params, HttpServletRequest request) {
+        Long memberId = params.get("memberId") != null ? ((Number) params.get("memberId")).longValue() : null;
         Map<String, Object> result = challengeService.getChallengeProgress(memberId);
         return Result.success(result);
     }

+ 33 - 44
cfc-backend/src/main/java/com/etotem/cfc/controller/UserController.java

@@ -207,21 +207,25 @@ public class UserController {
         Long familyId = user.getFamilyId();
         Map<String, Object> stats = new HashMap<>();
 
-        // 1. 总积分
-        Integer totalPoints = user.getTotalPoints() != null ? user.getTotalPoints() : 0;
+        List<FamilyMember> members = familyId != null
+                ? familyMemberMapper.selectList(new LambdaQueryWrapper<FamilyMember>()
+                    .eq(FamilyMember::getFamilyId, familyId))
+                : java.util.Collections.emptyList();
+
+        // 1. 总积分(任务积分记在 family_members,非 users.total_points)
+        int totalPoints = 0;
+        for (FamilyMember m : members) {
+            if (m.getTotalPoints() != null) {
+                totalPoints += m.getTotalPoints();
+            }
+        }
         stats.put("totalPoints", totalPoints);
 
-        // 2. 连续打卡天数(取所有孩子中最高值)
+        // 2. 连续打卡天数(取家庭成员中最高值)
         Integer maxStreakDays = 0;
-        if (familyId != null) {
-            List<FamilyMember> members = familyMemberMapper.selectList(
-                new LambdaQueryWrapper<FamilyMember>()
-                    .eq(FamilyMember::getFamilyId, familyId)
-            );
-            for (FamilyMember m : members) {
-                if (m.getStreakDays() != null && m.getStreakDays() > maxStreakDays) {
-                    maxStreakDays = m.getStreakDays();
-                }
+        for (FamilyMember m : members) {
+            if (m.getStreakDays() != null && m.getStreakDays() > maxStreakDays) {
+                maxStreakDays = m.getStreakDays();
             }
         }
         stats.put("streakDays", maxStreakDays);
@@ -240,11 +244,7 @@ public class UserController {
         // 4. 心愿兑换次数(按家庭成员统计)
         Long rewardsEarned = 0L;
         if (familyId != null) {
-            List<FamilyMember> exchangeMembers = familyMemberMapper.selectList(
-                new LambdaQueryWrapper<FamilyMember>()
-                    .eq(FamilyMember::getFamilyId, familyId)
-            );
-            for (FamilyMember m : exchangeMembers) {
+            for (FamilyMember m : members) {
                 Long cnt = pointsExchangeRecordMapper.selectCount(
                     new LambdaQueryWrapper<com.etotem.cfc.entity.PointsExchangeRecord>()
                         .eq(com.etotem.cfc.entity.PointsExchangeRecord::getFamilyMemberId, m.getId())
@@ -256,21 +256,15 @@ public class UserController {
 
         // 5. 收集勋章数(所有家庭成员)
         Integer badgesCollected = 0;
-        if (familyId != null) {
-            List<FamilyMember> members = familyMemberMapper.selectList(
-                new LambdaQueryWrapper<FamilyMember>()
-                    .eq(FamilyMember::getFamilyId, familyId)
-            );
-            for (FamilyMember m : members) {
-                try {
-                    Long cnt = childBadgeMapper.selectCount(
-                        new LambdaQueryWrapper<com.etotem.cfc.entity.ChildBadge>()
-                            .eq(com.etotem.cfc.entity.ChildBadge::getChildId, m.getId())
-                    );
-                    badgesCollected += cnt != null ? cnt.intValue() : 0;
-                } catch (Exception e) {
-                    log.warn("获取勋章数失败: memberId={}", m.getId(), e);
-                }
+        for (FamilyMember m : members) {
+            try {
+                Long cnt = childBadgeMapper.selectCount(
+                    new LambdaQueryWrapper<com.etotem.cfc.entity.ChildBadge>()
+                        .eq(com.etotem.cfc.entity.ChildBadge::getChildId, m.getId())
+                );
+                badgesCollected += cnt != null ? cnt.intValue() : 0;
+            } catch (Exception e) {
+                log.warn("获取勋章数失败: memberId={}", m.getId(), e);
             }
         }
         stats.put("badgesCollected", badgesCollected);
@@ -284,27 +278,22 @@ public class UserController {
 
         // 7. 参加挑战数(按家庭成员childId统计)
         Integer challengesJoined = 0;
-        if (familyId != null) {
-            List<FamilyMember> challengeMembers = familyMemberMapper.selectList(
-                new LambdaQueryWrapper<FamilyMember>()
-                    .eq(FamilyMember::getFamilyId, familyId)
+        for (FamilyMember m : members) {
+            Long cnt = familyChallengeProgressMapper.selectCount(
+                new LambdaQueryWrapper<com.etotem.cfc.entity.FamilyChallengeProgress>()
+                    .eq(com.etotem.cfc.entity.FamilyChallengeProgress::getChildId, m.getId())
             );
-            for (FamilyMember m : challengeMembers) {
-                Long cnt = familyChallengeProgressMapper.selectCount(
-                    new LambdaQueryWrapper<com.etotem.cfc.entity.FamilyChallengeProgress>()
-                        .eq(com.etotem.cfc.entity.FamilyChallengeProgress::getChildId, m.getId())
-                );
-                challengesJoined += cnt != null ? cnt.intValue() : 0;
-            }
+            challengesJoined += cnt != null ? cnt.intValue() : 0;
         }
         stats.put("challengesJoined", challengesJoined);
 
-        // 8. 上传健康报告数
+        // 8. 上传健康报告数(与 /api/health/report/list 一致,仅统计 active)
         Long healthReports = 0L;
         if (familyId != null) {
             healthReports = healthReportMapper.selectCount(
                 new LambdaQueryWrapper<com.etotem.cfc.entity.HealthReport>()
                     .eq(com.etotem.cfc.entity.HealthReport::getFamilyId, familyId)
+                    .eq(com.etotem.cfc.entity.HealthReport::getStatus, "active")
             );
         }
         stats.put("healthReports", healthReports);

+ 41 - 6
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java

@@ -9,12 +9,17 @@ import com.etotem.cfc.service.ArticleService;
 import com.etotem.cfc.service.FileStorageService;
 import com.etotem.cfc.service.api.WechatServiceInterface;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.multipart.MultipartFile;
 import javax.annotation.Resource;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
 import com.etotem.cfc.util.SortUtil;
 import com.etotem.cfc.util.ParamUtils;
 
@@ -22,6 +27,8 @@ import com.etotem.cfc.util.ParamUtils;
 @RequestMapping("/api/admin/articles")
 public class AdminArticleController {
 
+    private static final Logger log = LoggerFactory.getLogger(AdminArticleController.class);
+
     @Resource
     private ArticleService articleService;
 
@@ -37,6 +44,24 @@ public class AdminArticleController {
     @Resource
     private WechatServiceInterface wechatService;
 
+    @Resource
+    private ThreadPoolTaskExecutor taskExecutor;
+
+    /**
+     * 文本内容安全检测:带 5 秒硬超时,超时/异常时 fail-open 放行并记录日志,
+     * 避免微信接口卡顿导致文章创建/更新接口耗时数十秒(BUG-J)。
+     */
+    private boolean checkTextSafe(String text) {
+        if (text == null || text.trim().isEmpty()) return true;
+        try {
+            Future<Boolean> future = taskExecutor.submit(() -> wechatService.checkText(text));
+            return future.get(5, TimeUnit.SECONDS);
+        } catch (Exception e) {
+            log.warn("文本内容安全检测超时或执行失败,fail-open: {}", e.getMessage());
+            return true;
+        }
+    }
+
     @PostMapping("/list")
     public Result<Page<Article>> list(@RequestBody Map<String, Object> body) {
         String status = (String) body.get("status");
@@ -62,11 +87,11 @@ public class AdminArticleController {
         String title = (String) body.get("title");
         String content = (String) body.get("content");
 
-        // 文本内容安全检测
-        if (title != null && !title.isEmpty() && !wechatService.checkText(title)) {
+        // 文本内容安全检测(带超时,不阻塞接口)
+        if (!checkTextSafe(title)) {
             return Result.error("文章标题包含敏感信息,发布失败");
         }
-        if (content != null && !content.isEmpty() && !wechatService.checkText(content)) {
+        if (!checkTextSafe(content)) {
             return Result.error("文章内容包含敏感信息,发布失败");
         }
 
@@ -134,11 +159,11 @@ public class AdminArticleController {
         String title = (String) body.get("title");
         String content = (String) body.get("content");
 
-        // 文本内容安全检测
-        if (title != null && !title.isEmpty() && !wechatService.checkText(title)) {
+        // 文本内容安全检测(带超时,不阻塞接口)
+        if (!checkTextSafe(title)) {
             return Result.error("文章标题包含敏感信息,更新失败");
         }
-        if (content != null && !content.isEmpty() && !wechatService.checkText(content)) {
+        if (!checkTextSafe(content)) {
             return Result.error("文章内容包含敏感信息,更新失败");
         }
 
@@ -355,6 +380,16 @@ public class AdminArticleController {
         return Result.success("更新成功");
     }
 
+    @PostMapping("/reading-records")
+    public Result<Page<Map<String, Object>>> readingRecords(
+            @RequestBody Map<String, Object> body) {
+        Long articleId = ParamUtils.getLong(body.get("articleId"));
+        if (articleId == null) return Result.error("articleId不能为空");
+        int page = body.get("page") != null ? Integer.parseInt(body.get("page").toString()) : 1;
+        int size = body.get("size") != null ? Integer.parseInt(body.get("size").toString()) : 20;
+        return Result.success(articleService.getArticleReadingRecords(articleId, page, size));
+    }
+
     @PostMapping("/categories/delete")
     public Result<String> categoryDelete(@RequestBody Map<String, Object> body) {
         Long id = ParamUtils.getLong(body.get("id"));

+ 126 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminController.java

@@ -1,12 +1,17 @@
 package com.etotem.cfc.controller.admin;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.common.AdminRoles;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.*;
 import com.etotem.cfc.mapper.*;
+import com.etotem.cfc.dto.TaskReviewDTO;
+import com.etotem.cfc.service.TaskService;
 import com.etotem.cfc.service.UserService;
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.TypeReference;
 import javax.annotation.Resource;
 
 import org.springframework.util.DigestUtils;
@@ -58,6 +63,9 @@ public class AdminController {
     @Resource
     private UserService userService;
 
+    @Resource
+    private TaskService taskService;
+
     @Resource
     private GuidePackageMapper guidePackageMapper;
 
@@ -285,8 +293,8 @@ public class AdminController {
         // 为每条任务填充执行者名称
         java.util.List<Map<String, Object>> records = new java.util.ArrayList<>();
         for (Task t : result.getRecords()) {
-            Map<String, Object> item = new java.util.HashMap<>();
-            org.springframework.beans.BeanUtils.copyProperties(t, item);
+            Map<String, Object> item = JSON.parseObject(
+                    JSON.toJSONString(t), new TypeReference<Map<String, Object>>() {});
             // 根据 executorType 查昵称
             String executorType = t.getExecutorType();
             Long executorId = t.getExecutorId();
@@ -324,6 +332,24 @@ public class AdminController {
         return Result.success(stats);
     }
 
+    @Operation(summary = "获取待审核打卡任务")
+    @PostMapping("/tasks/pending-review")
+    public Result<List<Task>> getPendingReviewTasks(@RequestBody(required = false) Map<String, Object> params) {
+        Long familyId = params != null && params.get("familyId") != null
+                ? ParamUtils.getLong(params.get("familyId")) : null;
+        return Result.success(taskService.getAllPendingReviewTasks(familyId));
+    }
+
+    @Operation(summary = "审核打卡任务")
+    @PostMapping("/tasks/{id}/review")
+    public Result<Boolean> reviewTask(@PathVariable Long id, @RequestBody TaskReviewDTO dto) {
+        boolean success = taskService.reviewTask(id, dto);
+        if (!success) {
+            return Result.error("审核失败,任务可能不在待审核状态");
+        }
+        return Result.success(true);
+    }
+
     // 奖励管理
     @PostMapping("/rewards")
     public Result<Page<Reward>> getRewards(@RequestBody Map<String, Object> params) {
@@ -1314,6 +1340,104 @@ public class AdminController {
         return Result.success();
     }
 
+    /**
+     * 会员维护:按用户ID修改会员等级和推荐人
+     * body: { userId, memberLevel?, referrerId? }
+     */
+    @Operation(summary = "会员维护:修改会员等级与推荐人")
+    @PostMapping("/membership/user-update")
+    public Result<String> updateMembershipUser(@RequestBody Map<String, Object> params,
+                                               @RequestAttribute("userId") Long adminUserId,
+                                               @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        if (params.get("userId") == null) {
+            return Result.error("参数缺失: userId");
+        }
+        Long targetUserId = ((Number) params.get("userId")).longValue();
+        User user = userMapper.selectById(targetUserId);
+        if (user == null) {
+            return Result.error("用户不存在");
+        }
+
+        StringBuilder changes = new StringBuilder();
+        String oldLevel = user.getMemberLevel() != null ? user.getMemberLevel() : "FREE";
+        String newLevel = null;
+        LambdaUpdateWrapper<User> wrapper = new LambdaUpdateWrapper<>();
+        wrapper.eq(User::getId, targetUserId);
+
+        if (params.get("referrerId") != null) {
+            String raw = String.valueOf(params.get("referrerId"));
+            if (raw.trim().isEmpty()) {
+                if (user.getReferrerId() != null) {
+                    wrapper.set(User::getReferrerId, null);
+                    changes.append("清除推荐人; ");
+                }
+            } else {
+                try {
+                    Long referrerId = Long.parseLong(raw.trim());
+                    if (referrerId.equals(user.getId())) {
+                        return Result.error("推荐人不能是用户本人");
+                    }
+                    wrapper.set(User::getReferrerId, referrerId);
+                    changes.append("推荐人=").append(referrerId).append("; ");
+                } catch (NumberFormatException e) {
+                    return Result.error("推荐人ID格式不正确");
+                }
+            }
+        }
+
+        if (params.get("memberLevel") != null) {
+            String level = String.valueOf(params.get("memberLevel")).trim().toUpperCase();
+            if (level.isEmpty()) {
+                return Result.error("memberLevel 不能为空");
+            }
+            java.util.List<MembershipLevel> levels = membershipLevelMapper.selectList(null);
+            boolean valid = false;
+            for (MembershipLevel l : levels) {
+                if (l.getLevelCode() != null && l.getLevelCode().equalsIgnoreCase(level)) {
+                    valid = true;
+                    break;
+                }
+            }
+            if (!valid) {
+                return Result.error("无效的会员等级: " + level);
+            }
+            newLevel = level;
+            wrapper.set(User::getMemberLevel, newLevel);
+            if ("LIFETIME".equals(newLevel)) {
+                wrapper.set(User::getMemberExpireTime, null);
+            }
+            if (!oldLevel.equals(newLevel)) {
+                changes.append("等级=").append(oldLevel).append("->").append(newLevel).append("; ");
+            }
+        }
+
+        if (changes.length() > 0) {
+            wrapper.set(User::getUpdatedAt, new Date());
+            userMapper.update(null, wrapper);
+
+            if (newLevel != null && !oldLevel.equals(newLevel)) {
+                MemberUpgradeRecord record = new MemberUpgradeRecord();
+                record.setUserId(targetUserId);
+                record.setFromLevel(oldLevel);
+                record.setToLevel(newLevel);
+                record.setUpgradeType("admin");
+                record.setApproveUserId(adminUserId);
+                record.setApproveStatus("approved");
+                record.setApproveTime(new Date());
+                record.setApproveRemark("管理员会员维护");
+                record.setCreatedAt(new Date());
+                memberUpgradeRecordMapper.insert(record);
+            }
+        }
+
+        return Result.success(changes.length() > 0
+                ? "更新成功: " + changes.toString()
+                : "未做任何修改");
+    }
+
     // ========== 测评待分配规划师 ==========
 
     @PostMapping("/assessment/pending-list")

+ 59 - 14
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCouponController.java

@@ -4,8 +4,10 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.Coupon;
 import com.etotem.cfc.entity.CouponGrantLog;
+import com.etotem.cfc.entity.FamilyCouponGrantLog;
 import com.etotem.cfc.mapper.CouponGrantLogMapper;
 import com.etotem.cfc.mapper.CouponMapper;
+import com.etotem.cfc.mapper.FamilyCouponGrantLogMapper;
 import com.etotem.cfc.service.CouponService;
 import org.springframework.web.bind.annotation.*;
 
@@ -29,6 +31,9 @@ public class AdminCouponController {
     @Resource
     private CouponGrantLogMapper couponGrantLogMapper;
 
+    @Resource
+    private FamilyCouponGrantLogMapper familyCouponGrantLogMapper;
+
     @PostMapping("/list")
     public Result<List<Coupon>> list(@RequestAttribute("role") String role) {
         if (!"admin".equals(role)) {
@@ -69,16 +74,36 @@ public class AdminCouponController {
             return Result.error("无权限");
         }
         Long couponId = ParamUtils.getLong(params.get("couponId"));
-        Object userIdsObj = params.get("userIds");
-        if (userIdsObj instanceof List) {
-            List<?> userIds = (List<?>) userIdsObj;
-            for (Object uid : userIds) {
-                couponService.issueToUser(couponId, Long.valueOf(uid.toString()));
+        Object familyIdsObj = params.get("familyIds");
+        if (familyIdsObj instanceof List) {
+            List<?> familyIds = (List<?>) familyIdsObj;
+            for (Object fid : familyIds) {
+                couponService.issueToFamily(couponId, Long.valueOf(fid.toString()));
             }
         }
         return Result.success("发放成功");
     }
 
+    /** 单家庭发放(家庭管理页使用) */
+    @PostMapping("/issue-family")
+    public Result<String> issueFamily(@RequestBody Map<String, Object> params,
+                                      @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        Long familyId = ParamUtils.getLong(params.get("familyId"));
+        Long couponId = ParamUtils.getLong(params.get("couponId"));
+        Integer quantity = params.get("quantity") == null ? 1 : Integer.valueOf(params.get("quantity").toString());
+        if (familyId == null || couponId == null) {
+            return Result.error("参数错误");
+        }
+        if (quantity == null || quantity < 1) quantity = 1;
+        for (int i = 0; i < quantity; i++) {
+            couponService.issueToFamily(couponId, familyId);
+        }
+        return Result.success("发放成功");
+    }
+
     @PostMapping("/delete")
     public Result<String> delete(@RequestBody Map<String, Object> params,
                                  @RequestAttribute("role") String role) {
@@ -90,22 +115,42 @@ public class AdminCouponController {
         return Result.success("删除成功");
     }
 
+    @PostMapping("/toggle-status")
+    public Result<String> toggleStatus(@RequestBody Map<String, Object> params,
+                                       @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        Long id = ParamUtils.getLong(params.get("id"));
+        String status = params.get("status") == null ? null : params.get("status").toString();
+        if (id == null || !("ACTIVE".equals(status) || "DISABLED".equals(status))) {
+            return Result.error("参数错误");
+        }
+        Coupon coupon = couponMapper.selectById(id);
+        if (coupon == null) {
+            return Result.error("优惠券不存在");
+        }
+        coupon.setStatus(status);
+        couponMapper.updateById(coupon);
+        return Result.success("ACTIVE".equals(status) ? "启用成功" : "停用成功");
+    }
+
     @PostMapping("/grant-log")
-    public Result<List<CouponGrantLog>> grantLog(@RequestBody Map<String, Object> params,
-                                                 @RequestAttribute("role") String role) {
+    public Result<List<FamilyCouponGrantLog>> grantLog(@RequestBody Map<String, Object> params,
+                                                       @RequestAttribute("role") String role) {
         if (!"admin".equals(role)) {
             return Result.error("无权限");
         }
-        Long userId = ParamUtils.getLong(params.get("userId"));
+        Long familyId = ParamUtils.getLong(params.get("familyId"));
         Long couponId = ParamUtils.getLong(params.get("couponId"));
-        LambdaQueryWrapper<CouponGrantLog> wrapper = new LambdaQueryWrapper<>();
-        if (userId != null) {
-            wrapper.eq(CouponGrantLog::getUserId, userId);
+        LambdaQueryWrapper<FamilyCouponGrantLog> wrapper = new LambdaQueryWrapper<>();
+        if (familyId != null) {
+            wrapper.eq(FamilyCouponGrantLog::getFamilyId, familyId);
         }
         if (couponId != null) {
-            wrapper.eq(CouponGrantLog::getCouponId, couponId);
+            wrapper.eq(FamilyCouponGrantLog::getCouponId, couponId);
         }
-        wrapper.orderByDesc(CouponGrantLog::getCreatedAt);
-        return Result.success(couponGrantLogMapper.selectList(wrapper));
+        wrapper.orderByDesc(FamilyCouponGrantLog::getCreatedAt);
+        return Result.success(familyCouponGrantLogMapper.selectList(wrapper));
     }
 }

+ 62 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminHealthPlanController.java

@@ -0,0 +1,62 @@
+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.HealthPlan;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.service.HealthPlanService;
+import com.etotem.cfc.util.ParamUtils;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "管理端-健康方案", description = "跨家庭健康方案列表与详情")
+@RestController
+@RequestMapping("/api/admin/health-plan")
+public class AdminHealthPlanController {
+
+    @Resource
+    private HealthPlanService healthPlanService;
+
+    @Operation(summary = "健康方案分页列表(按成员展示)")
+    @PostMapping("/list")
+    public Result<Page<HealthPlan>> list(@RequestBody Map<String, Object> params,
+                                         @RequestAttribute("role") String role) {
+        if (!"admin".equals(role) && !"teacher".equals(role) && !"butler".equals(role)
+                && !"nutritionist".equals(role) && !"operator".equals(role)) {
+            return Result.error("无权限");
+        }
+        int page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
+        int size = params.get("size") != null ? ((Number) params.get("size")).intValue() : 20;
+        Long familyId = ParamUtils.getLong(params.get("familyId"));
+        String status = params.get("status") != null ? params.get("status").toString() : null;
+        String keyword = params.get("keyword") != null ? params.get("keyword").toString() : null;
+        Page<HealthPlan> result = healthPlanService.adminListPlans(familyId, status, keyword, page, size);
+        return Result.success(result);
+    }
+
+    @Operation(summary = "健康方案详情(含分解任务)")
+    @PostMapping("/detail")
+    public Result<HealthPlan> detail(@RequestBody Map<String, Object> params,
+                                     @RequestAttribute("role") String role) {
+        if (!"admin".equals(role) && !"teacher".equals(role) && !"butler".equals(role)
+                && !"nutritionist".equals(role) && !"operator".equals(role)) {
+            return Result.error("无权限");
+        }
+        Long planId = ParamUtils.getLong(params.get("planId"));
+        if (planId == null) {
+            return Result.error("planId不能为空");
+        }
+        HealthPlan plan = healthPlanService.getPlanDetail(planId);
+        if (plan == null) {
+            return Result.error("方案不存在");
+        }
+        List<Task> tasks = healthPlanService.getPlanTasks(planId);
+        plan.setTasks(tasks);
+        return Result.success(plan);
+    }
+}

+ 51 - 5
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminSupplierController.java

@@ -48,24 +48,70 @@ public class AdminSupplierController {
         return Result.success(data);
     }
 
-    @Operation(summary = "新增供应商")
+    @Operation(summary = "新增供应商(兼容新旧字段契约)")
     @PostMapping("/save")
-    public Result<EcomSupplier> save(@RequestBody EcomSupplier supplier) {
+    public Result<EcomSupplier> save(@RequestBody Map<String, Object> body) {
+        EcomSupplier supplier = new EcomSupplier();
+        applyFields(supplier, body);
+        if (supplier.getName() == null || supplier.getName().trim().isEmpty()) {
+            return Result.error("供应商名称不能为空");
+        }
         supplier.setCreatedAt(new Date());
         supplier.setUpdatedAt(new Date());
         ecomSupplierMapper.insert(supplier);
         return Result.success(supplier);
     }
 
-    @Operation(summary = "更新供应商")
+    @Operation(summary = "更新供应商(兼容新旧字段契约)")
     @PostMapping("/update")
-    public Result<String> update(@RequestBody EcomSupplier supplier) {
-        if (supplier.getId() == null) return Result.error("id不能为空");
+    public Result<String> update(@RequestBody Map<String, Object> body) {
+        Long id = ParamUtils.getLong(body.get("id"));
+        if (id == null) return Result.error("id不能为空");
+        EcomSupplier supplier = ecomSupplierMapper.selectById(id);
+        if (supplier == null) return Result.error("供应商不存在");
+        applyFields(supplier, body);
         supplier.setUpdatedAt(new Date());
         ecomSupplierMapper.updateById(supplier);
         return Result.success("ok");
     }
 
+    /**
+     * 字段契约兼容:前端表单提交 {phone,nickname,realName,vendorType,vendorStatus},
+     * 库表字段为 {name,contactName,contactPhone,status,remark}。新旧字段都接受,新字段优先。
+     */
+    private void applyFields(EcomSupplier supplier, Map<String, Object> body) {
+        String name = str(body, "nickname", body.get("name"));
+        if (name != null) supplier.setName(name);
+        String contactName = str(body, "realName", body.get("contactName"));
+        if (contactName != null) supplier.setContactName(contactName);
+        String contactPhone = str(body, "phone", body.get("contactPhone"));
+        if (contactPhone != null) supplier.setContactPhone(contactPhone);
+        String remark = str(body, "remark", null);
+        if (remark != null) supplier.setRemark(remark);
+        Object vendorStatus = body.get("vendorStatus") != null ? body.get("vendorStatus") : body.get("status");
+        if (vendorStatus != null) {
+            supplier.setStatus(parseStatus(vendorStatus));
+        }
+    }
+
+    private static String str(Map<String, Object> body, String key, Object fallback) {
+        Object v = body.get(key);
+        if (v == null) v = fallback;
+        return v != null ? v.toString() : null;
+    }
+
+    private static Integer parseStatus(Object v) {
+        if (v instanceof Number) return ((Number) v).intValue();
+        String s = v.toString().trim();
+        if ("active".equalsIgnoreCase(s) || "enabled".equalsIgnoreCase(s) || "1".equals(s)) return 1;
+        if ("pending".equalsIgnoreCase(s) || "disabled".equalsIgnoreCase(s) || "0".equals(s)) return 0;
+        try {
+            return Integer.valueOf(s);
+        } catch (NumberFormatException e) {
+            return 1;
+        }
+    }
+
     @Operation(summary = "删除供应商")
     @PostMapping("/delete")
     public Result<String> delete(@RequestBody Map<String, Object> params) {

+ 50 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/InnatePortraitConfigController.java

@@ -0,0 +1,50 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.InnatePortraitConfig;
+import com.etotem.cfc.mapper.InnatePortraitConfigMapper;
+import com.etotem.cfc.service.FamilyMemberAttributeService;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+
+@Slf4j
+@RestController
+@RequestMapping("/api/admin/innate-portrait-config")
+public class InnatePortraitConfigController {
+
+    @Resource
+    private InnatePortraitConfigMapper innatePortraitConfigMapper;
+
+    @Resource
+    private FamilyMemberAttributeService familyMemberAttributeService;
+
+    @PostMapping("/list")
+    public Result<List<InnatePortraitConfig>> list() {
+        return Result.success(innatePortraitConfigMapper.selectList(
+                new LambdaQueryWrapper<InnatePortraitConfig>().orderByAsc(InnatePortraitConfig::getSortOrder)));
+    }
+
+    @PostMapping("/update")
+    public Result<Void> update(@RequestBody InnatePortraitConfig config) {
+        if (config.getId() == null) {
+            return Result.error("缺少id");
+        }
+        innatePortraitConfigMapper.update(null, new LambdaUpdateWrapper<InnatePortraitConfig>()
+                .eq(InnatePortraitConfig::getId, config.getId())
+                .set(InnatePortraitConfig::getWeight, config.getWeight())
+                .set(InnatePortraitConfig::getEnabled, config.getEnabled())
+                .set(InnatePortraitConfig::getAiEnabled, config.getAiEnabled()));
+        return Result.success(null);
+    }
+
+    @PostMapping("/invalidate")
+    public Result<Void> invalidate() {
+        familyMemberAttributeService.invalidateAllInnateScores();
+        return Result.success(null);
+    }
+}

+ 14 - 6
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/KnowledgeBaseController.java

@@ -48,12 +48,15 @@ public class KnowledgeBaseController {
     }
 
     @PostMapping("/get")
-    public Result<Map<String, Object>> get(@RequestParam Long id) {
-        Map<String, Object> result = danKnowledgeBaseService.getWithAssociations(id);
-        if (result == null) {
-            return Result.error("知识不存在");
+    public Result<Map<String, Object>> get(@RequestBody(required = false) Map<String, Object> body,
+                                           @RequestParam(required = false) Long id) {
+        if (body != null && body.get("id") != null) {
+            id = com.etotem.cfc.util.ParamUtils.getLong(body.get("id"));
         }
-        return Result.success(result);
+        if (id == null) return Result.error("id不能为空");
+        return danKnowledgeBaseService.getWithAssociations(id) != null
+                ? Result.success(danKnowledgeBaseService.getWithAssociations(id))
+                : Result.error("知识条目不存在");
     }
 
     @SuppressWarnings("unchecked")
@@ -90,7 +93,12 @@ public class KnowledgeBaseController {
     }
 
     @PostMapping("/delete")
-    public Result<Void> delete(@RequestParam Long id) {
+    public Result<Void> delete(@RequestBody(required = false) Map<String, Object> body,
+                               @RequestParam(required = false) Long id) {
+        if (body != null && body.get("id") != null) {
+            id = com.etotem.cfc.util.ParamUtils.getLong(body.get("id"));
+        }
+        if (id == null) return Result.error("id不能为空");
         boolean success = danKnowledgeBaseService.deleteKnowledge(id);
         return success ? Result.success(null) : Result.error("删除失败");
     }

+ 51 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/NumSoulDetailConfigController.java

@@ -0,0 +1,51 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.NumSoulDetailConfig;
+import com.etotem.cfc.mapper.NumSoulDetailConfigMapper;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+
+@Slf4j
+@RestController
+@RequestMapping("/api/admin/numsoul-detail-config")
+public class NumSoulDetailConfigController {
+
+    @Resource
+    private NumSoulDetailConfigMapper numSoulDetailConfigMapper;
+
+    @PostMapping("/list")
+    public Result<List<NumSoulDetailConfig>> list(@RequestBody(required = false) java.util.Map<String, Object> params) {
+        LambdaQueryWrapper<NumSoulDetailConfig> wrapper = new LambdaQueryWrapper<>();
+        if (params != null && params.get("numberType") != null) {
+            wrapper.eq(NumSoulDetailConfig::getNumberType, params.get("numberType").toString());
+        }
+        wrapper.orderByAsc(NumSoulDetailConfig::getNumberType).orderByAsc(NumSoulDetailConfig::getNumberValue);
+        return Result.success(numSoulDetailConfigMapper.selectList(wrapper));
+    }
+
+    @PostMapping("/create")
+    public Result<Void> create(@RequestBody NumSoulDetailConfig config) {
+        numSoulDetailConfigMapper.insert(config);
+        return Result.success(null);
+    }
+
+    @PostMapping("/update")
+    public Result<Void> update(@RequestBody NumSoulDetailConfig config) {
+        if (config.getId() == null) {
+            return Result.error("缺少id");
+        }
+        numSoulDetailConfigMapper.updateById(config);
+        return Result.success(null);
+    }
+
+    @PostMapping("/delete")
+    public Result<Void> delete(@RequestParam Long id) {
+        numSoulDetailConfigMapper.deleteById(id);
+        return Result.success(null);
+    }
+}

+ 60 - 5
cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java

@@ -59,6 +59,15 @@ public class AIChatController {
     @Resource
     private AiGateway aiGateway;
 
+    @Resource
+    private com.etotem.cfc.service.PortraitService portraitService;
+
+    @Resource
+    private com.etotem.cfc.mapper.FiveDimensionSelfCheckMapper selfCheckMapper;
+
+    @Resource
+    private com.fasterxml.jackson.databind.ObjectMapper objectMapper;
+
     @Operation(summary = "发送聊天消息(支持传入reportId以解读报告)")
     @PostMapping("/chat/send")
     public Result<Map<String, Object>> sendMessage(
@@ -68,6 +77,7 @@ public class AIChatController {
         String conversationId = params.get("conversationId");
         String reportIdStr = params.get("reportId");
         String surveyIdStr = params.get("surveyId");
+        String selfCheckIdStr = params.get("selfCheckId");
 
         if (query == null || query.trim().isEmpty()) {
             return Result.error("消息不能为空");
@@ -109,6 +119,32 @@ public class AIChatController {
             inputs.put("mascot_persona", "");
         }
 
+        // 画像注入
+        String chatMemberIdStr = params.get("memberId");
+        Long chatMemberId = chatMemberIdStr != null && !chatMemberIdStr.trim().isEmpty()
+                ? Long.valueOf(chatMemberIdStr) : null;
+        String chatPortrait = portraitService.buildPortraitPrompt(userId, chatMemberId);
+        if (chatPortrait != null) {
+            inputs.put("portrait_prompt", chatPortrait);
+        }
+
+        // 注入自检上下文(P1-2)
+        if (selfCheckIdStr != null && !selfCheckIdStr.trim().isEmpty()) {
+            try {
+                Long selfCheckId = Long.valueOf(selfCheckIdStr);
+                com.etotem.cfc.entity.FiveDimensionSelfCheck selfCheck = selfCheckMapper.selectById(selfCheckId);
+                if (selfCheck != null) {
+                    java.util.Map<String, Object> sc = new java.util.LinkedHashMap<>();
+                    sc.put("totalScore", selfCheck.getTotalScore());
+                    sc.put("scores", selfCheck.getScoresJson());
+                    sc.put("createdAt", selfCheck.getCreatedAt() != null ? selfCheck.getCreatedAt().toString() : "");
+                    inputs.put("self_check_result", objectMapper.writeValueAsString(sc));
+                }
+            } catch (Exception e) {
+                log.warn("注入自检上下文失败: {}", e.getMessage());
+            }
+        }
+
         // AI记忆层注入(会话摘要+关键事实)
         inputs = aiService.enrichInputsWithMemory(userId, conversationId, inputs);
 
@@ -272,11 +308,19 @@ public class AIChatController {
             inputs.put("mascot_persona", "");
         }
 
-        // 3. 调用精准营养助手
-        Map<String, Object> difyResp = aiService.sendNutritionMessage(
-                query, String.valueOf(userId), conversationId, inputs);
+        // 3. 注入用户画像
+        String memberIdStr = params.get("memberId");
+        Long memberId = memberIdStr != null && !memberIdStr.trim().isEmpty()
+                ? Long.valueOf(memberIdStr) : null;
+        String portraitPrompt = portraitService.buildPortraitPrompt(userId, memberId);
+        if (portraitPrompt != null) {
+            inputs.put("portrait_prompt", portraitPrompt);
+        }
+
+        // 4. 调用营养助手(走 LangGraph)
+        Map<String, Object> resp = aiGateway.chat(query, userId, conversationId, inputs);
 
-        String answer = (String) difyResp.getOrDefault("answer", "");
+        String answer = (String) resp.getOrDefault("answer", "");
 
         // 4. 解析 [RECOMMEND] 标记
         List<RecommendationResult> recommendations = null;
@@ -340,7 +384,7 @@ public class AIChatController {
     // 5. 组装结果
     Map<String, Object> result = new LinkedHashMap<>();
     result.put("answer", cleanAnswer);
-    result.put("conversationId", difyResp.getOrDefault("conversationId", ""));
+    result.put("conversationId", resp != null ? resp.getOrDefault("conversationId", "") : "");
     result.put("recommendations", recommendations != null ? recommendations : Collections.emptyList());
     result.put("tasks", tasks);
     return Result.success(result);
@@ -448,6 +492,11 @@ public class AIChatController {
             inputs.put("mascot_gender", "");
             inputs.put("mascot_persona", "");
         }
+        // 画像注入
+        String coachPortrait = portraitService.buildPortraitPrompt(userId, memberId);
+        if (coachPortrait != null) {
+            inputs.put("portrait_prompt", coachPortrait);
+        }
         Map<String, Object> resp = aiGateway.chat(query, userId, conversationId, inputs);
         String answer = resp != null ? (String) resp.getOrDefault("answer", "") : "";
         Map<String, Object> result = new LinkedHashMap<>();
@@ -480,6 +529,12 @@ public class AIChatController {
         if (params.get("focus") != null) {
             inputs.put("focus", params.get("focus").toString());
         }
+        // 画像注入
+        Long butlerMemberId = params.get("memberId") != null ? Long.valueOf(params.get("memberId").toString()) : null;
+        String butlerPortrait = portraitService.buildPortraitPrompt(userId, butlerMemberId);
+        if (butlerPortrait != null) {
+            inputs.put("portrait_prompt", butlerPortrait);
+        }
         Map<String, Object> resp = aiGateway.chat(query, userId, conversationId, inputs);
         String answer = resp != null ? (String) resp.getOrDefault("answer", "") : "";
         Map<String, Object> result = new LinkedHashMap<>();

+ 54 - 6
cfc-backend/src/main/java/com/etotem/cfc/controller/assessment/AssessmentReportController.java

@@ -11,6 +11,8 @@ import com.etotem.cfc.service.FileStorageService;
 import com.etotem.cfc.service.GrowthGuidanceService;
 import com.etotem.cfc.service.ReportParseService;
 import com.etotem.cfc.service.StorageService;
+import com.etotem.cfc.service.TaskPlanService;
+import com.etotem.cfc.entity.TaskPlanInstance;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.slf4j.Logger;
@@ -19,6 +21,7 @@ import org.springframework.web.bind.annotation.*;
 import org.springframework.web.multipart.MultipartFile;
 
 import javax.annotation.Resource;
+import javax.servlet.http.HttpServletRequest;
 import java.util.Date;
 import java.util.concurrent.CompletableFuture;
 import java.util.List;
@@ -52,6 +55,51 @@ public class AssessmentReportController {
     @Resource
     private UserMapper userMapper;
 
+    @Resource
+    private TaskPlanService taskPlanService;
+
+    @Operation(summary = "获取用户已生成的测评方案列表")
+    @PostMapping("/plans/list")
+    public Result<List<TaskPlanInstance>> getMyPlans(
+            @RequestParam(value = "familyId", required = false) Long familyId,
+            @RequestParam(value = "childId", required = false) Long childId,
+            @RequestBody(required = false) Map<String, Object> body,
+            HttpServletRequest request) {
+        try {
+            // 参数兼容:优先查询参数,其次 JSON body,最后回退 JWT 注入的 familyId
+            if (familyId == null && body != null && body.get("familyId") != null) {
+                familyId = ((Number) body.get("familyId")).longValue();
+            }
+            if (childId == null && body != null && body.get("childId") != null) {
+                childId = ((Number) body.get("childId")).longValue();
+            }
+            if (familyId == null) {
+                Object attr = request.getAttribute("familyId");
+                if (attr instanceof Number) {
+                    familyId = ((Number) attr).longValue();
+                }
+            }
+            if (familyId == null) {
+                return Result.error("缺少familyId参数且无法从登录态获取家庭信息");
+            }
+
+            // 如果传了 childId,只查该孩子的方案;否则查整个家庭的方案
+            List<TaskPlanInstance> plans;
+            if (childId != null) {
+                plans = taskPlanService.getChildPlans(childId);
+            } else {
+                plans = taskPlanService.getFamilyPlans(familyId);
+            }
+            
+            // 按创建时间倒序排列
+            plans.sort((a, b) -> b.getCreatedAt().after(a.getCreatedAt()) ? 1 : -1);
+            return Result.success(plans);
+        } catch (Exception e) {
+            log.error("获取测评方案失败: familyId={}, childId={}", familyId, childId, e);
+            return Result.error("获取方案失败:" + e.getMessage());
+        }
+    }
+
     @Operation(summary = "家长上传测评报告")
     @PostMapping("/upload")
     public Result<DanAssessmentResult> uploadReport(
@@ -121,8 +169,8 @@ public class AssessmentReportController {
         return Result.success(list);
     }
 
-    @Operation(summary = "规划师确认家长上传报告")
-    @PostMapping("/{resultId}/confirm-upload")
+    @Operation(summary = "用户确认报告入库并自动生成方案")
+    @PostMapping("/{resultId}/confirm-inbox")
     public Result<DanAssessmentResult> confirmUpload(
             @PathVariable Long resultId,
             @RequestBody Map<String, Object> body) {
@@ -140,16 +188,16 @@ public class AssessmentReportController {
         }
         resultMapper.updateById(result);
 
-        // 自动生成测评方案
+        // 用户确认后立即自动生成测评方案(无需规划师审核)
         try {
             User child = userMapper.selectById(result.getChildId());
             Long familyId = child != null ? child.getFamilyId() : null;
             if (familyId != null) {
-                planGenerator.generate(result, familyId, result.getChildId());
-                log.info("已为测评结果{}自动生成方案", resultId);
+                TaskPlanInstance plan = planGenerator.generate(result, familyId, result.getChildId());
+                log.info("已为测评结果{}自动生成方案 planId={}", resultId, plan.getId());
             }
         } catch (Exception e) {
-            log.error("自动生成方案失败: resultId={}", resultId, e);
+            log.error("自动生成方案失败resultId={}", resultId, e);
         }
 
         return Result.success(result);

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleController.java

@@ -77,6 +77,7 @@ public class ArticleController {
         if (article == null) {
             return Result.error(403, "无权访问该文章");
         }
+        articleService.incrementViewCount(id);
         return Result.success(article);
     }
 

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/diet/DietIngredientController.java

@@ -48,4 +48,11 @@ public class DietIngredientController {
             @RequestBody Map<String, Object> request) {
         return Result.success(dietIngredientService.confirmIngredients(familyId, request));
     }
+
+    @PostMapping("/search")
+    public Result<Map> searchFoods(@RequestBody(required = false) Map<String, Object> request) {
+        String keyword = request != null && request.get("keyword") != null
+                ? request.get("keyword").toString() : "";
+        return Result.success(dietIngredientService.searchFoods(keyword));
+    }
 }

+ 89 - 28
cfc-backend/src/main/java/com/etotem/cfc/controller/family/FamilyInviteController.java

@@ -104,7 +104,7 @@ public class FamilyInviteController {
     }
 
     /**
-     * 通过邀请码/令牌提交加入家庭请求(需管理员审批)
+     * 通过邀请码/令牌提交加入家庭请求(令牌路径绑定邀请人,等待邀请人确认;旧邀请码路径由家庭管理员兜底审批)
      */
     @Operation(summary = "通过邀请码申请加入家庭")
     @PostMapping("/request-join-by-code")
@@ -119,16 +119,11 @@ public class FamilyInviteController {
         // 获取申请人角色
         String familyRole = body.get("familyRole");
 
-        // 兼容邀请令牌(token):通过 token 解析到 familyId
+        // 兼容邀请令牌(token):直接透传原始 token,服务层解析并绑定邀请人(审批人)
         if (inviteCode == null || inviteCode.trim().isEmpty()) {
             String token = body.get("token");
             if (token != null && !token.trim().isEmpty()) {
-                try {
-                    InviteValidateDTO dto = familyInvitationService.validateInvitation(token);
-                    inviteCode = String.valueOf(dto.getFamilyId());
-                } catch (Exception e) {
-                    return Result.error("邀请令牌无效");
-                }
+                inviteCode = token;
             }
         }
 
@@ -142,20 +137,20 @@ public class FamilyInviteController {
             Map<String, Object> result = new HashMap<>();
             result.put("requestId", request.getId());
             result.put("familyName", family != null ? family.getName() : "");
-            return Result.success("申请已提交,等待家庭管理员审批", result);
+            return Result.success("申请已提交,等待邀请人确认", result);
         } catch (Exception e) {
             return Result.error(e.getMessage());
         }
     }
 
     /**
-     * 管理员审批加入请求
+     * 邀请人审批加入请求(谁分享的卡谁确认;旧数据无邀请人时由家庭管理员兜底)
      */
     @Operation(summary = "审批加入请求")
     @PostMapping("/approve-request")
     public Result<Void> approveJoinRequest(@RequestBody Map<String, Object> body,
-        @RequestAttribute("userId") Long adminUserId) {
-        if (adminUserId == null) {
+        @RequestAttribute("userId") Long approverUserId) {
+        if (approverUserId == null) {
             return Result.error("请先登录");
         }
         Long requestId = ParamUtils.getLong(body.get("requestId"));
@@ -164,7 +159,7 @@ public class FamilyInviteController {
             return Result.error("请求ID不能为空");
         }
         try {
-            familyJoinRequestService.approveJoinRequest(requestId, adminUserId, comment);
+            familyJoinRequestService.approveJoinRequest(requestId, approverUserId, comment);
             return Result.success();
         } catch (Exception e) {
             return Result.error(e.getMessage());
@@ -172,13 +167,13 @@ public class FamilyInviteController {
     }
 
     /**
-     * 管理员拒绝加入请求
+     * 邀请人拒绝加入请求(旧数据无邀请人时由家庭管理员兜底)
      */
     @Operation(summary = "拒绝加入请求")
     @PostMapping("/reject-request")
     public Result<Void> rejectJoinRequest(@RequestBody Map<String, Object> body,
-        @RequestAttribute("userId") Long adminUserId) {
-        if (adminUserId == null) {
+        @RequestAttribute("userId") Long approverUserId) {
+        if (approverUserId == null) {
             return Result.error("请先登录");
         }
         Long requestId = ParamUtils.getLong(body.get("requestId"));
@@ -187,7 +182,7 @@ public class FamilyInviteController {
             return Result.error("请求ID不能为空");
         }
         try {
-            familyJoinRequestService.rejectJoinRequest(requestId, adminUserId, comment);
+            familyJoinRequestService.rejectJoinRequest(requestId, approverUserId, comment);
             return Result.success();
         } catch (Exception e) {
             return Result.error(e.getMessage());
@@ -195,23 +190,20 @@ public class FamilyInviteController {
     }
 
     /**
-     * 查询家庭待审批请求列表
+     * 查询待我处理的加入申请(我是邀请人的;旧数据未绑定邀请人的由目标家庭管理员兜底可见)
      */
-    @Operation(summary = "查询待审批加入请求")
+    @Operation(summary = "查询待我处理的加入申请")
     @PostMapping("/pending-requests")
     public Result<java.util.List<com.etotem.cfc.dto.FamilyJoinRequestDTO>> getPendingRequests(
-        @RequestAttribute(value = "familyId", required = false) Long familyId,
         @RequestAttribute("userId") Long userId) {
-        if (familyId == null) {
-            return Result.noFamily("请先创建或加入家庭");
+        if (userId == null) {
+            return Result.error("请先登录");
         }
-        // 验证管理员身份
-        User admin = userMapper.selectById(userId);
-        if (admin == null || !admin.getFamilyId().equals(familyId)
-                || !Boolean.TRUE.equals(admin.getIsFamilyAdmin())) {
-            return Result.error("只有家庭管理员才能查看待审批请求");
+        try {
+            return Result.success(familyJoinRequestService.getPendingRequests(userId));
+        } catch (Exception e) {
+            return Result.error(e.getMessage());
         }
-        return Result.success(familyJoinRequestService.getPendingRequests(familyId));
     }
 
     /**
@@ -303,6 +295,75 @@ public class FamilyInviteController {
         }
     }
 
+    /**
+     * 验证家庭短邀请码(如 fam_abc123)
+     * 供落地页展示家庭信息用,不创建加入请求记录
+     */
+    @Operation(summary = "验证家庭短邀请码")
+    @PostMapping("/validate-family-code")
+    public Result<InviteValidateDTO> validateFamilyCode(@RequestBody Map<String, String> body) {
+        String inviteCode = body.get("inviteCode");
+        if (inviteCode == null || inviteCode.trim().isEmpty()) {
+            return Result.error("邀请码不能为空");
+        }
+
+        Family family = familyMapper.selectOne(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Family>()
+                        .eq(Family::getInviteCode, inviteCode.trim()));
+        if (family == null) {
+            return Result.error("邀请码无效或不存在");
+        }
+
+        User creator = userMapper.selectById(family.getCreatorId());
+        Long memberCount = userMapper.selectCount(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<User>()
+                        .eq(User::getFamilyId, family.getId()));
+
+        InviteValidateDTO dto = new InviteValidateDTO();
+        dto.setFamilyId(family.getId());
+        dto.setFamilyName(family.getName());
+        dto.setInviterName(creator != null ? creator.getNickname() : null);
+        dto.setInviterAvatar(creator != null ? creator.getAvatar() : null);
+        dto.setMemberCount(memberCount.intValue());
+        return Result.success(dto);
+    }
+
+    /**
+     * 首次注册绑定家庭邀请人(幂等,已有绑定则忽略)
+     */
+    @Operation(summary = "首次注册绑定家庭邀请人")
+    @PostMapping("/bind-inviter")
+    public Result<Void> bindFamilyInviter(@RequestBody Map<String, String> body,
+        @RequestAttribute("userId") Long userId) {
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        String inviteCode = body.get("familyInviteCode");
+        if (inviteCode == null || inviteCode.trim().isEmpty()) {
+            return Result.error("邀请码不能为空");
+        }
+
+        Family family = familyMapper.selectOne(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Family>()
+                        .eq(Family::getInviteCode, inviteCode.trim()));
+        if (family == null) {
+            return Result.error("邀请码无效");
+        }
+
+        User user = userMapper.selectById(userId);
+        if (user == null) {
+            return Result.error("用户不存在");
+        }
+        // 幂等:已绑定过则直接返回成功
+        if (user.getFamilyInviterId() != null && user.getFamilyInviterId().equals(family.getCreatorId())) {
+            return Result.success();
+        }
+        user.setFamilyInviterId(family.getCreatorId());
+        user.setUpdatedAt(new java.util.Date());
+        userMapper.updateById(user);
+        return Result.success();
+    }
+
     /**
      * 接受家庭邀请
      */

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

@@ -74,6 +74,8 @@ public Result<?> listMembers(@RequestBody(required = false) Map<String, Object>
  if (visibleOnly) {
    return Result.success(userService.getVisibleMembers(userId));
  }
+ // 与 visibleOnly/includeFamily 一致:无家庭时先兜底创建,避免空列表误伤前端选人
+ userService.ensureUserFamily(userId);
  List<FamilyMemberVO> members = familyMemberService.listMembers(userId);
  return Result.success(members);
 }

+ 70 - 3
cfc-backend/src/main/java/com/etotem/cfc/controller/family/FiveDimensionSelfCheckController.java

@@ -8,16 +8,20 @@ import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.service.FiveDimensionSelfCheckService;
 import com.etotem.cfc.service.UnlockGateService;
+import com.etotem.cfc.util.ParamUtils;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestAttribute;
 import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestAttribute;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 
 import javax.annotation.Resource;
+import java.util.Date;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 
 /**
  * 五维家庭自检问卷(P0-1)
@@ -37,12 +41,21 @@ public class FiveDimensionSelfCheckController {
     @Resource
     private UserMapper userMapper;
 
-    @Operation(summary = "获取自检问卷题目(15题静态题库)")
+    @Resource
+    private com.etotem.cfc.service.HealthPlanService healthPlanService;
+
+    @Operation(summary = "获取自检问卷题目(首次固定 15 题;再次合并题库随机抽)")
     @PostMapping("/questions")
-    public Result<List<SelfCheckQuestionVO>> getQuestions(@RequestAttribute("userId") Long userId) {
+    public Result<List<SelfCheckQuestionVO>> getQuestions(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody(required = false) Map<String, Object> body) {
         if (userId == null) {
             return Result.error("请先登录");
         }
+        Boolean retake = body != null && body.get("retake") != null && Boolean.TRUE.equals(body.get("retake"));
+        if (retake) {
+            return Result.success(selfCheckService.getQuestionsForRetake(userId));
+        }
         return Result.success(selfCheckService.getQuestions());
     }
 
@@ -98,4 +111,58 @@ public class FiveDimensionSelfCheckController {
             return Result.error(e.getMessage());
         }
     }
+
+    @Operation(summary = "获取自检状态(是否有记录、是否可检、距下次提醒天数)")
+    @PostMapping("/status")
+    public Result<Map<String, Object>> getStatus(@RequestAttribute("userId") Long userId) {
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        return Result.success(selfCheckService.getStatus(userId));
+    }
+
+    @Operation(summary = "忽略本次自检提醒(重置 15 天计时)")
+    @PostMapping("/ignore")
+    public Result<Map<String, Object>> ignoreSelfCheck(@RequestBody(required = false) Map<String, Object> body,
+                                                       @RequestAttribute("userId") Long userId) {
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        Long checkId = body != null ? ParamUtils.getLong(body.get("checkId")) : null;
+        Date ignoredAt = selfCheckService.ignoreSelfCheck(userId, checkId);
+        Map<String, Object> resp = new HashMap<>();
+        resp.put("nextRemindAt", new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss").format(
+                new Date(ignoredAt.getTime() + 15L * 24 * 60 * 60 * 1000)));
+        return Result.success(resp);
+    }
+
+    @Operation(summary = "用户点击生成健康计划(基于自检低分维度)")
+    @PostMapping("/generate-plan")
+    public Result<Map<String, Object>> generatePlan(
+            @RequestBody(required = false) Map<String, Object> body,
+            @RequestAttribute("userId") Long userId) {
+        if (userId == null) return Result.error("请先登录");
+        Long checkId = body != null ? com.etotem.cfc.util.ParamUtils.getLong(body.get("checkId")) : null;
+        try {
+            Long planId = healthPlanService.generateFromSelfCheck(userId, checkId);
+            if (planId == null) return Result.error("计划生成失败,请稍后重试");
+            Map<String, Object> resp = new HashMap<>();
+            resp.put("planId", planId);
+            resp.put("status", "draft");
+            return Result.success(resp);
+        } catch (Exception e) {
+            return Result.error("计划生成失败");
+        }
+    }
+
+    @Operation(summary = "获取自检历史趋势分析")
+    @PostMapping("/trend-analysis")
+    public Result<Map<String, Object>> getTrendAnalysis(@RequestAttribute("userId") Long userId) {
+        if (userId == null) return Result.error("请先登录");
+        try {
+            return Result.success(selfCheckService.getTrendAnalysis(userId));
+        } catch (Exception e) {
+            return Result.error("趋势分析失败");
+        }
+    }
 }

+ 68 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/growth/GrowthPlanController.java

@@ -11,6 +11,10 @@ import javax.annotation.Resource;
 import java.util.List;
 import java.util.Map;
 import com.etotem.cfc.util.ParamUtils;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.mapper.GrowthPlanMapper;
 
 @Tag(name = "成长方案", description = "成长方案创建、更新、复盘等接口")
 @RestController
@@ -20,6 +24,12 @@ public class GrowthPlanController {
     @Resource
     private GrowthPlanService growthPlanService;
 
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private GrowthPlanMapper growthPlanMapper;
+
     @Operation(summary = "创建成长方案")
     @PostMapping("/create")
     public Result<GrowthPlan> createPlan(@RequestBody GrowthPlan plan,
@@ -92,4 +102,62 @@ public class GrowthPlanController {
         }
         return Result.success(true);
     }
+
+    @Operation(summary = "分页查询所有成长计划(管理端)")
+    @PostMapping("/list")
+    public Result<IPage<Map<String, Object>>> listPlans(@RequestBody Map<String, Object> params,
+                                                          @RequestAttribute("userId") Long userId,
+                                                          @RequestAttribute("role") String role) {
+        if (!"teacher".equals(role) && !"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        int page = params.get("page") != null ? Integer.parseInt(params.get("page").toString()) : 1;
+        int size = params.get("size") != null ? Integer.parseInt(params.get("size").toString()) : 10;
+        String keyword = params.get("keyword") != null ? (String) params.get("keyword") : null;
+
+        IPage<GrowthPlan> pageParam = new Page<>(page, size);
+        com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<GrowthPlan> wrapper = new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<>();
+        wrapper.orderByDesc(GrowthPlan::getCreatedAt);
+
+        if (keyword != null && !keyword.isEmpty()) {
+            wrapper.and(w -> w.like(GrowthPlan::getPlanTitle, keyword)
+                    .or()
+                    .like(GrowthPlan::getPlanContent, keyword));
+        }
+
+        IPage<GrowthPlan> result = growthPlanMapper.selectPage(pageParam, wrapper);
+
+        // 关联 childName 字段(假名,不在数据库)
+        List<Map<String, Object>> withChildName = result.getRecords().stream().map(plan -> {
+            Map<String, Object> map = new java.util.HashMap<>();
+            map.put("id", plan.getId());
+            map.put("childId", plan.getChildId());
+            map.put("planTitle", plan.getPlanTitle());
+            map.put("planContent", plan.getPlanContent());
+            map.put("durationMonths", plan.getDurationMonths());
+            map.put("status", plan.getStatus());
+            map.put("startDate", plan.getStartDate());
+            map.put("endDate", plan.getEndDate());
+            map.put("teacherId", plan.getTeacherId());
+            map.put("createdAt", plan.getCreatedAt());
+            map.put("updatedAt", plan.getUpdatedAt());
+
+            // 查询孩子昵称
+            List<com.etotem.cfc.entity.User> users = userMapper.selectList(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<com.etotem.cfc.entity.User>()
+                    .eq(com.etotem.cfc.entity.User::getId, plan.getChildId()));
+            String childName = users.stream()
+                .map(u -> u.getNickname())
+                .findFirst()
+                .orElse("未知孩子");
+            map.put("childName", childName);
+
+            return map;
+        }).collect(java.util.stream.Collectors.toList());
+
+        IPage<Map<String, Object>> resultMap = new Page<>(page, size, result.getTotal());
+        resultMap.setRecords(withChildName);
+
+        return Result.success(resultMap);
+    }
 }

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideFamilyTaskController.java

@@ -454,7 +454,7 @@ public class GuideFamilyTaskController {
                         new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Task>()
                             .eq(Task::getFamilyMemberId, child.getId())
                             .eq(Task::getNeedReview, 1)
-                            .eq(Task::getStatus, "completed"));
+                            .eq(Task::getStatus, "pending_review"));
                     pendingReview += pending;
                 }
             }

+ 11 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideManagementController.java

@@ -7,6 +7,7 @@ import com.etotem.cfc.entity.GuideApplication;
 import com.etotem.cfc.entity.GuideApplicationRecord;
 import com.etotem.cfc.entity.GuideInviteCode;
 import com.etotem.cfc.entity.GuidePackage;
+import com.etotem.cfc.entity.GuidePackageTemplate;
 import com.etotem.cfc.entity.GuideHierarchy;
 import com.etotem.cfc.entity.PackageOrder;
 import com.etotem.cfc.entity.User;
@@ -15,6 +16,7 @@ import com.etotem.cfc.service.GuideApplicationService;
 import com.etotem.cfc.service.GuideHierarchyService;
 import com.etotem.cfc.service.GuideInviteCodeService;
 import com.etotem.cfc.service.GuidePackageService;
+import com.etotem.cfc.service.GuidePackageTemplateService;
 import com.etotem.cfc.service.UserService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
@@ -44,6 +46,9 @@ public class GuideManagementController {
     @Resource
     private GuidePackageService guidePackageService;
 
+    @Resource
+    private GuidePackageTemplateService guidePackageTemplateService;
+
     @Resource
     private UserMapper userMapper;
 
@@ -266,6 +271,12 @@ public class GuideManagementController {
         return Result.success(parentGuide);
     }
 
+    @Operation(summary = "获取套餐模板列表")
+    @PostMapping("/package/templates")
+    public Result<List<GuidePackageTemplate>> getGuidePackageTemplates() {
+        return Result.success(guidePackageTemplateService.list());
+    }
+
     /**
      * 添加成长规划师任务模板
      */

+ 3 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/guide/PaymentController.java

@@ -74,7 +74,8 @@ public class PaymentController {
      */
     @Operation(summary = "创建微信Native支付")
     @PostMapping("/wechat/native")
-    public Result<Map<String, Object>> createWechatNativePay(@RequestBody Map<String, Object> params) {
+    public Result<Map<String, Object>> createWechatNativePay(@RequestBody Map<String, Object> params,
+                                                             @RequestAttribute("userId") Long userId) {
         String orderNo = (String) params.get("orderNo");
         String description = (String) params.get("description");
         Integer amount = (Integer) params.get("amount");
@@ -83,7 +84,7 @@ public class PaymentController {
         String contactAddress = (String) params.get("contactAddress");
 
         // 保存联系人信息
-        paymentService.saveContactInfo(orderNo, contactName, contactPhone, contactAddress);
+        paymentService.saveContactInfo(orderNo, contactName, contactPhone, contactAddress, userId);
 
         Map<String, Object> result = paymentService.createWechatNativePayOrder(orderNo, description, amount);
         return Result.success(result);

+ 15 - 3
cfc-backend/src/main/java/com/etotem/cfc/controller/guide/PlanReviewController.java

@@ -41,15 +41,27 @@ public class PlanReviewController {
     public Result<List<TaskPlanInstance>> listFamilyPlans(@RequestBody Map<String, Object> body,
                                                            @RequestAttribute("userId") Long userId,
                                                            @RequestAttribute("role") String role,
-                                                           @RequestAttribute(value = "familyId", required = false) Long familyId) {
+                                                           @RequestAttribute(value = "familyId", required = false) Long jwtFamilyId) {
         if (!"teacher".equals(role) && !"admin".equals(role)) {
             return Result.error("无权限");
         }
         String status = (String) body.get("status");
-        if (familyId == null) return Result.noFamily("请先创建或加入家庭");
+        // 合并逻辑:admin 未指定家庭时查看全部方案;teacher 优先 body.familyId,JWT 作为默认值
+        Long filterFamilyId;
+        if ("admin".equals(role)) {
+            filterFamilyId = ParamUtils.getLong(body.get("familyId"));
+        } else {
+            Object bodyFamilyId = body.get("familyId");
+            filterFamilyId = bodyFamilyId != null ? ParamUtils.getLong(bodyFamilyId) : jwtFamilyId;
+            if (filterFamilyId == null) {
+                return Result.noFamily("请先创建或加入家庭");
+            }
+        }
         LambdaQueryWrapper<TaskPlanInstance> w = new LambdaQueryWrapper<TaskPlanInstance>()
-                .eq(TaskPlanInstance::getFamilyId, familyId)
                 .orderByDesc(TaskPlanInstance::getCreatedAt);
+        if (filterFamilyId != null) {
+            w.eq(TaskPlanInstance::getFamilyId, filterFamilyId);
+        }
         if (status != null && !status.isEmpty()) {
             w.eq(TaskPlanInstance::getStatus, status);
         }

+ 89 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/mind/InnatePortraitController.java

@@ -0,0 +1,89 @@
+package com.etotem.cfc.controller.mind;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.InnatePortraitReportVO;
+import com.etotem.cfc.dto.InnatePortraitVO;
+import com.etotem.cfc.dto.InnateTrajectoryVO;
+import com.etotem.cfc.dto.NumSoulDetailVO;
+import com.etotem.cfc.service.InnatePortraitService;
+import com.etotem.cfc.util.ParamUtils;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.Map;
+
+@Slf4j
+@RestController
+@RequestMapping("/api/mind/innate")
+public class InnatePortraitController {
+
+    @Resource
+    private InnatePortraitService innatePortraitService;
+
+    @PostMapping("/portrait")
+    public Result<InnatePortraitVO> portrait(@RequestBody Map<String, Object> params) {
+        try {
+            Long memberId = ParamUtils.getLong(params.get("memberId"));
+            String memberType = params.containsKey("memberType") ? params.get("memberType").toString() : "child";
+            InnatePortraitVO vo = innatePortraitService.getInnatePortrait(memberId, memberType);
+            if (vo == null) {
+                return Result.error("成员不存在或未设置出生信息");
+            }
+            return Result.success(vo);
+        } catch (Exception e) {
+            log.error("获取先天画像失败", e);
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @PostMapping("/reading")
+    public Result<InnatePortraitReportVO> reading(@RequestBody Map<String, Object> params) {
+        try {
+            Long memberId = ParamUtils.getLong(params.get("memberId"));
+            String memberType = params.containsKey("memberType") ? params.get("memberType").toString() : "child";
+            Long familyId = ParamUtils.getLong(params.get("familyId"));
+            InnatePortraitReportVO vo = innatePortraitService.generateAiReading(memberId, memberType, familyId);
+            if (vo == null) {
+                return Result.error("成员不存在或未设置出生信息");
+            }
+            return Result.success(vo);
+        } catch (Exception e) {
+            log.error("生成AI解读失败", e);
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @PostMapping("/numsoul")
+    public Result<NumSoulDetailVO> numsoul(@RequestBody Map<String, Object> params) {
+        try {
+            Integer year = params.get("year") instanceof Number ? ((Number) params.get("year")).intValue() : null;
+            Integer month = params.get("month") instanceof Number ? ((Number) params.get("month")).intValue() : null;
+            Integer day = params.get("day") instanceof Number ? ((Number) params.get("day")).intValue() : null;
+            if (year == null || month == null || day == null) {
+                return Result.error("请提供出生年月日");
+            }
+            return Result.success(innatePortraitService.calculateNumSoul(year, month, day));
+        } catch (Exception e) {
+            log.error("获取数字能量失败", e);
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @PostMapping("/trajectory")
+    public Result<InnateTrajectoryVO> trajectory(@RequestBody Map<String, Object> params) {
+        try {
+            Long memberId = ParamUtils.getLong(params.get("memberId"));
+            String memberType = params.containsKey("memberType") ? params.get("memberType").toString() : "child";
+            Long familyId = ParamUtils.getLong(params.get("familyId"));
+            InnateTrajectoryVO vo = innatePortraitService.getTrajectory(memberId, memberType, familyId);
+            if (vo == null) {
+                return Result.error("成员不存在或未设置出生信息");
+            }
+            return Result.success(vo);
+        } catch (Exception e) {
+            log.error("获取成长轨迹失败", e);
+            return Result.error(e.getMessage());
+        }
+    }
+}

+ 6 - 4
cfc-backend/src/main/java/com/etotem/cfc/controller/notice/NoticeController.java

@@ -20,10 +20,12 @@ public class NoticeController {
     @Resource
     private NoticeMapper noticeMapper;
 
-@Operation(summary = "获取已发布公告列表(已废弃,请使用 /api/admin/notices/list)")
-    @Deprecated
-    @PostMapping("/list")
+@Operation(summary = "获取已发布公告列表")
+    @RequestMapping(value = "/list", method = {RequestMethod.GET, RequestMethod.POST})
     public Result<List<Notice>> list() {
-        return Result.error(410, "该接口已废弃(违反统一POST规范),请使用 /api/admin/notices/list");
+        LambdaQueryWrapper<Notice> q = new LambdaQueryWrapper<>();
+        q.eq(Notice::getStatus, "active");
+        q.orderByDesc(Notice::getCreatedAt);
+        return Result.success(noticeMapper.selectList(q));
     }
 }

+ 13 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/task/TaskController.java

@@ -80,7 +80,19 @@ public class TaskController {
     @Operation(summary = "获取今日任务")
     @PostMapping("/today")
     public Result<List<Task>> getTodayTasks(@RequestBody Map<String, Object> params,
-                                            @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+                                            @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId,
+                                            @RequestAttribute(value = "familyId", required = false) Long familyId) {
+        // familyWide=true:按家庭聚合今日任务(家长查看方案分解到其他成员的任务)
+        boolean familyWide = params.get("familyWide") != null
+                && ("true".equalsIgnoreCase(String.valueOf(params.get("familyWide")))
+                    || Boolean.TRUE.equals(params.get("familyWide"))
+                    || "1".equals(String.valueOf(params.get("familyWide"))));
+        if (familyWide) {
+            if (familyId == null) {
+                return Result.error("familyId不能为空");
+            }
+            return Result.success(taskService.getTodayTasksByFamily(familyId));
+        }
         Long memberId = params.get("memberId") != null ? ParamUtils.getLong(params.get("memberId")) : currentMemberId;
         String category = params.get("category") != null ? params.get("category").toString() : null;
         String dimensionCode = params.get("dimensionCode") != null ? params.get("dimensionCode").toString() : null;

+ 40 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/user/PortraitController.java

@@ -0,0 +1,40 @@
+package com.etotem.cfc.controller.user;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.PortraitService;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/user/portrait")
+public class PortraitController {
+
+    @Resource
+    private PortraitService portraitService;
+
+    @PostMapping("/get")
+    public Result<Map<String, Object>> getPortrait(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        Long targetUserId = params.get("userId") != null
+                ? Long.valueOf(params.get("userId").toString()) : null;
+        Long memberId = params.get("memberId") != null
+                ? Long.valueOf(params.get("memberId").toString()) : null;
+        return Result.success(portraitService.getPortrait(userId, targetUserId, memberId));
+    }
+
+    @PostMapping("/edit")
+    public Result<String> editPortrait(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        Object promptObj = params.get("portraitPrompt");
+        String portraitPrompt = promptObj != null ? promptObj.toString() : null;
+        if (portraitPrompt != null && portraitPrompt.length() > 2000) {
+            return Result.error("画像描述最多 2000 字符");
+        }
+        portraitService.updatePortraitPrompt(userId, portraitPrompt);
+        return Result.success("保存成功");
+    }
+}

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

@@ -28,6 +28,8 @@ public class DanReportUploadVO {
     private Long confirmedBy;
     private Integer editCount;
     private Long linkedResultId;
+    /** 确认入库后自动生成的健康方案 ID(与健康报告 confirm 一致) */
+    private Long planId;
     /** 关联的测评结果(含各项分数,可为 null) */
     private Map<String, Object> result;
     private Date createdAt;

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

@@ -11,7 +11,9 @@ public class FamilyJoinRequestDTO implements Serializable {
     private Long id;
     private Long requesterUserId;
     private String requesterNickname;
+    private String requesterAvatar;
     private String inviteCode;
+    private Long inviterUserId;
     private Long targetFamilyId;
     private String targetFamilyName;
     private String status; // pending / approved / rejected

+ 21 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/InnatePortraitReportVO.java

@@ -0,0 +1,21 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+@Data
+public class InnatePortraitReportVO {
+
+    private InnatePortraitVO portrait;
+
+    /** AI解读文案 */
+    private String aiReading;
+
+    /** 解读生成时间 */
+    private String generatedAt;
+
+    /** 是否来自缓存 */
+    private Boolean fromCache;
+
+    /** 是否降级为模板文案 */
+    private Boolean fallbackUsed;
+}

+ 39 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/InnatePortraitVO.java

@@ -0,0 +1,39 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.util.List;
+import java.util.Map;
+
+@Data
+public class InnatePortraitVO {
+
+    /** 成员ID */
+    private Long memberId;
+
+    private String memberType;
+
+    /** 八字四柱 {"year":"甲子","month":"丙寅","day":"戊辰","hour":"壬申"} */
+    private Map<String, String> eightCharacters;
+
+    /** 五行 {"wood":30,"fire":45,"earth":60,"metal":25,"water":40} */
+    private Map<String, Integer> wuxingElements;
+
+    /** 生肖: rat/ox/... */
+    private String zodiac;
+
+    /** 血型: A/B/AB/O */
+    private String bloodType;
+
+    /** 心先天基础分 */
+    private Integer mindBaseScore;
+
+    /** 智先天基础分 */
+    private Integer wisdomBaseScore;
+
+    /** 数字能量详情 */
+    private NumSoulDetailVO numSoul;
+
+    /** 各来源贡献明细 */
+    private List<Map<String, Object>> sourceContributions;
+}

+ 26 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/InnateTrajectoryVO.java

@@ -0,0 +1,26 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.util.Map;
+
+@Data
+public class InnateTrajectoryVO {
+
+    private InnatePortraitVO innatePortrait;
+
+    /** 五维先天分 {"mind":72,"body":65,...} */
+    private Map<String, Integer> innateDimensionScores;
+
+    /** 后天能量 {"mindBalance":120,"mindTotalEarned":350} */
+    private Map<String, Object> currentEnergy;
+
+    /** 先天vs后天 {"mindInnate":72,"mindCurrent":81,"growth":"+9"} */
+    private Map<String, Object> innateVsCurrent;
+
+    /** 成长趋势文案 */
+    private String growthTrend;
+
+    /** 成长建议 */
+    private String advice;
+}

+ 36 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/NumSoulDetailVO.java

@@ -0,0 +1,36 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+@Data
+public class NumSoulDetailVO {
+
+    /** 生命灵数 */
+    private Integer lifePath;
+
+    private String lifePathTitle;
+
+    private String lifePathKeywords;
+
+    private String lifePathAdvice;
+
+    /** 天赋数(可能有2个) */
+    private Integer talent1;
+
+    private Integer talent2;
+
+    private String talentTitle;
+
+    /** 生日数 */
+    private Integer birthday;
+
+    private String birthdayTitle;
+
+    /** 命运数 */
+    private Integer destiny;
+
+    private String destinyTitle;
+
+    /** 生命灵数代表色 */
+    private String colorHex;
+}

+ 13 - 45
cfc-backend/src/main/java/com/etotem/cfc/dto/WuxingSourcingAdviceVO.java

@@ -1,65 +1,33 @@
 package com.etotem.cfc.dto;
 
+import com.fasterxml.jackson.annotation.JsonProperty;
 import lombok.Data;
+import java.util.List;
 
-/**
- * 五行相生寻源建议(P0-2 五行相生寻源建议引擎输出)
- * 对应书稿《附录-工具包汇总》附录六五行相生速查表
- */
 @Data
 public class WuxingSourcingAdviceVO {
 
-    /** 低分维度 code(body/wisdom/wealth/action/mind) */
     private String dimension;
-
-    /** 低分维度中文名(身/智/富/行/心) */
     private String dimensionName;
-
-    /** 低分维度五行(土/金/水/木/火) */
     private String element;
-
-    /** 低分维度主题色 */
     private String color;
-
-    /** 自检得分(满分9) */
     private Integer score;
-
-    /** 等级:healthy健康 / attention留意 / tense紧绷 */
     private String level;
-
-    /** 等级中文 */
     private String levelName;
 
-    /** 相生寻源:上游维度 code */
-    private String upstreamDimension;
-
-    /** 相生寻源:上游维度中文名 */
-    private String upstreamName;
-
-    /** 相生寻源:上游五行 */
-    private String upstreamElement;
-
-    /** 相生寻源:上游主题色 */
-    private String upstreamColor;
-
-    /** 相生寻源:为什么补上游更有效 */
-    private String upstreamReason;
-
-    /** 相克寻源:压制维度 code */
-    private String restrainerDimension;
-
-    /** 相克寻源:压制维度中文名 */
-    private String restrainerName;
+    /** AI 解读(替换原 upstreamReason) */
+    private String interpretation;
 
-    /** 相克寻源:压制五行 */
-    private String restrainerElement;
+    /** AI 微行动列表 */
+    private List<String> microActions;
 
-    /** 相克寻源:压制主题色 */
-    private String restrainerColor;
+    /** AI 补充洞察 */
+    private String aiInsight;
 
-    /** 相克寻源:谁在压制它 */
-    private String restrainerReason;
+    /** 家庭整体洞察(仅第一条携带) */
+    private String familyInsight;
 
-    /** 简单应对建议 */
-    private String action;
+    /** 是否使用降级静态建议 */
+    @JsonProperty("fallbackUsed")
+    private Boolean fallbackUsed;
 }

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/Coupon.java

@@ -57,5 +57,8 @@ public class Coupon implements Serializable {
 
     private Integer usedCount;
 
+    /** 状态: ACTIVE=启用 / DISABLED=停用,NULL 视为 ACTIVE */
+    private String status;
+
     private Date createdAt;
 }

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

@@ -132,6 +132,8 @@ public class DanAssessmentResult implements Serializable {
     private String dimension; // mind(心-A2)/wisdom(智-B4)
     private String reportFileId; // 上传文件媒资ID
     private String draftStatus; // pending(待确认)/confirmed(已确认)
+    /** DAN 等级 A/B/C/D(旧库必填列;家长上传无分数时写 U) */
+    private String danLevel;
 
     // === 独立化扩展字段(迁移93新增) ===
     private Long sourceReportId; // 关联 dan_report_uploads.id

+ 30 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyCoupon.java

@@ -0,0 +1,30 @@
+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("family_coupon")
+public class FamilyCoupon implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long familyId;
+
+    private Long couponId;
+
+    /** 状态: AVAILABLE / USED */
+    private String status;
+
+    private Date receivedAt;
+
+    private Date usedAt;
+
+    private Long orderId;
+}

+ 34 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyCouponGrantLog.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("family_coupon_grant_log")
+public class FamilyCouponGrantLog implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long familyId;
+
+    private Long couponId;
+
+    /** JOIN/PERIODIC/POPULATION/EXCHANGE/CF_EXCHANGE */
+    private String grantType;
+
+    /** 周期标识(YYYY-MM或YYYY-Qn), PERIODIC防重用 */
+    private String period;
+
+    private Integer quantity;
+
+    /** 触发来源(订单号/成员ID等) */
+    private String source;
+
+    private Date createdAt;
+}

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

@@ -29,10 +29,13 @@ public class FamilyJoinRequest implements Serializable {
     /** 邀请码或令牌 */
     private String inviteRef;
 
+    /** 邀请人用户ID(分享邀请令牌的人,审批由其确认;旧数据/邀请码路径为空) */
+    private Long inviterUserId;
+
     /** 状态:pending / approved / rejected */
     private String status;
 
-    /** 审批人(family admin)ID */
+    /** 审批人ID(邀请人或旧数据场景下的家庭管理员) */
     private Long approverUserId;
 
     /** 审批备注 */

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/FiveDimensionSelfCheck.java

@@ -37,5 +37,8 @@ public class FiveDimensionSelfCheck implements Serializable {
     /** 寻源建议JSON(五行相生寻源引擎输出) */
     private String adviceJson;
 
+    /** 本次自检使用的题号JSON,如 [1,2,3,101] */
+    private String questionIdsJson;
+
     private Date createdAt;
 }

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

@@ -17,6 +17,7 @@ import java.util.Date;
 public class HealthGutFlora implements Serializable {
 
     // ===== Level 常量 =====
+    public static final String LEVEL_PHYLUM = "PHYLUM";    // 门
     public static final String LEVEL_CLASS = "CLASS";      // 纲
     public static final String LEVEL_ORDER = "ORDER";      // 目
     public static final String LEVEL_FAMILY = "FAMILY";    // 科

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthPlan.java

@@ -7,6 +7,7 @@ import com.baomidou.mybatisplus.annotation.TableName;
 import lombok.Data;
 import java.io.Serializable;
 import java.util.Date;
+import java.util.List;
 
 @Data
 @TableName("health_plans")
@@ -27,4 +28,12 @@ public class HealthPlan implements Serializable {
     @TableField("teacher_id") private Long teacherId;
     @TableField("created_at") private Date createdAt;
     @TableField("updated_at") private Date updatedAt;
+
+    /** 方案分解任务(非数据库字段,detail 接口填充) */
+    @TableField(exist = false)
+    private List<Task> tasks;
+
+    /** 家庭名称(非数据库字段,管理端列表填充) */
+    @TableField(exist = false)
+    private String familyName;
 }

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

@@ -3,7 +3,15 @@ package com.etotem.cfc.entity;
 import com.baomidou.mybatisplus.annotation.IdType;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import com.fasterxml.jackson.databind.JsonMappingException;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
 import lombok.Data;
+
+import java.io.IOException;
 import java.io.Serializable;
 import java.util.Date;
 
@@ -14,7 +22,11 @@ public class HealthSleepRecord implements Serializable {
     private Long id;
     private Long memberId;
     private Long checkinId;
+    @JsonFormat(pattern = "HH:mm")
+    @JsonDeserialize(using = HealthSleepRecord.SleepTimeDeserializer.class)
     private Date sleepTime;
+    @JsonFormat(pattern = "HH:mm")
+    @JsonDeserialize(using = HealthSleepRecord.SleepTimeDeserializer.class)
     private Date wakeTime;
     private Integer durationMinutes;
     private Integer deepSleepMinutes;
@@ -24,4 +36,28 @@ public class HealthSleepRecord implements Serializable {
     private Integer dreamCount;
     private String remark;
     private Date createdAt;
+
+    /**
+     * 前端时间选择器传 "HH:mm"(如 21:00),而 JDK 8+ 的 java.sql.Time.valueOf 只接受
+     * "HH:mm:ss" 三段格式;Jackson 2.13 对 java.sql.Time 的 @JsonFormat 反序列化不生效
+     * (String 工厂路径直接调 Time.valueOf),因此自定义解析并补秒。
+     */
+    public static class SleepTimeDeserializer extends JsonDeserializer<Date> {
+        @Override
+        public Date deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
+            String v = p.getValueAsString();
+            if (v == null || v.trim().isEmpty()) {
+                return null;
+            }
+            v = v.trim();
+            if (v.length() == 5 && v.charAt(2) == ':') {
+                v = v + ":00";
+            }
+            try {
+                return java.sql.Time.valueOf(v);
+            } catch (IllegalArgumentException e) {
+                throw new JsonMappingException(p, "睡眠时间字段格式错误,期望 HH:mm 或 HH:mm:ss,收到: " + v);
+            }
+        }
+    }
 }

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

@@ -16,9 +16,12 @@ public class HealthStatus implements Serializable {
     @TableId(type = IdType.AUTO)
     private Long id;
 
-    /** 所属账号(一个账号一份) */
+    /** 所属账号 */
     private Long userId;
 
+    /** 家庭成员ID(NULL=账号级,非空=成员级归档) */
+    private Long memberId;
+
     /** 身高(cm) */
     private BigDecimal heightCm;
 

+ 39 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/InnatePortraitConfig.java

@@ -0,0 +1,39 @@
+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("innate_portrait_config")
+public class InnatePortraitConfig implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 来源: zodiac/bazi/blood/numsoul */
+    private String sourceType;
+
+    /** 来源名称 */
+    private String sourceName;
+
+    /** 权重(基点,2500=25%) */
+    private Integer weight;
+
+    /** 是否启用 0/1 */
+    private Integer enabled;
+
+    /** 是否启用AI解读 0/1 */
+    private Integer aiEnabled;
+
+    /** 排序 */
+    private Integer sortOrder;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 34 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/InnatePortraitReport.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("innate_portrait_report")
+public class InnatePortraitReport implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long memberId;
+
+    /** 成员类型: child/parent */
+    private String memberType;
+
+    private Long familyId;
+
+    /** 先天画像数据JSON */
+    private String portraitJson;
+
+    /** AI解读文案 */
+    private String aiReading;
+
+    private Date generatedAt;
+
+    private Date createdAt;
+}

+ 36 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/NumSoulDetailConfig.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("numsoul_detail_config")
+public class NumSoulDetailConfig implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 类型: life_path/talent/birthday/destiny */
+    private String numberType;
+
+    /** 数字值 1-9 或主数 11/22/33 */
+    private Integer numberValue;
+
+    private String title;
+
+    private String keywords;
+
+    /** 心先天基础分(百分位) */
+    private Integer mindBase;
+
+    private String mindAdvice;
+
+    private String colorHex;
+
+    private Date createdAt;
+}

+ 23 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/SelfCheckIgnore.java

@@ -0,0 +1,23 @@
+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("self_check_ignores")
+public class SelfCheckIgnore implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long userId;
+
+    private Long checkId;
+
+    private Date createdAt;
+}

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

@@ -113,6 +113,10 @@ public class User implements Serializable {
     // AI助手形象: xibao(浠宝-女孩)/fubao(福宝-男孩)/null(未选择)
     private String mascot;
 
+    // 用户自定义画像 prompt
+    @TableField("portrait_prompt")
+    private String portraitPrompt;
+
     // 家庭管理员标记
     private Boolean isFamilyAdmin;
 
@@ -122,6 +126,9 @@ public class User implements Serializable {
     // 佣金系统 - 推荐人ID
     private Long referrerId;
 
+    // 家庭邀请 - 首次注册时绑定的邀请人ID(用于追踪邀请来源)
+    private Long familyInviterId;
+
     // 佣金系统 - 个人邀请码(6位字母数字)
     private String referralCode;
 

+ 39 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ArticleReadingRecordMapper.java

@@ -4,7 +4,11 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.etotem.cfc.entity.ArticleReadingRecord;
 import org.apache.ibatis.annotations.Mapper;
 import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Result;
+import org.apache.ibatis.annotations.Results;
 import org.apache.ibatis.annotations.Select;
+import java.util.List;
+import java.util.Map;
 
 @Mapper
 public interface ArticleReadingRecordMapper extends BaseMapper<ArticleReadingRecord> {
@@ -13,4 +17,39 @@ public interface ArticleReadingRecordMapper extends BaseMapper<ArticleReadingRec
             "WHERE child_id = #{childId} AND read_at >= #{since}")
     Integer selectTotalDurationByChildSince(@Param("childId") Long childId,
                                             @Param("since") java.util.Date since);
+
+    /**
+     * 按文章 ID 分页查询阅读记录,JOIN family_members 获取昵称,按最后阅读时间倒序
+     */
+    @Select("SELECT " +
+            "  r.id, " +
+            "  r.user_id, " +
+            "  r.child_id, " +
+            "  r.duration_seconds, " +
+            "  r.read_at, " +
+            "  r.updated_at, " +
+            "  m.nickname, " +
+            "  DATE(r.read_at) AS read_date " +
+            "FROM article_reading_records r " +
+            "LEFT JOIN family_members m ON r.child_id = m.id " +
+            "WHERE r.article_id = #{articleId} " +
+            "ORDER BY r.updated_at DESC, r.read_at DESC " +
+            "LIMIT #{limit} OFFSET #{offset}")
+    @Results(id = "readRecordResultMap", value = {
+            @Result(column = "id", property = "id"),
+            @Result(column = "user_id", property = "user_id"),
+            @Result(column = "child_id", property = "child_id"),
+            @Result(column = "duration_seconds", property = "duration_seconds"),
+            @Result(column = "read_at", property = "read_at"),
+            @Result(column = "updated_at", property = "updated_at"),
+            @Result(column = "nickname", property = "nickname"),
+            @Result(column = "read_date", property = "read_date")
+    })
+    List<Map<String, Object>> selectByArticle(
+            @Param("articleId") Long articleId,
+            @Param("offset") int offset,
+            @Param("limit") int limit);
+
+    @Select("SELECT COUNT(*) FROM article_reading_records WHERE article_id = #{articleId}")
+    long countByArticle(@Param("articleId") Long articleId);
 }

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

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

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

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

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

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

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

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

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

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

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

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

+ 12 - 10
cfc-backend/src/main/java/com/etotem/cfc/service/ActivityAdminService.java

@@ -110,10 +110,10 @@ public class ActivityAdminService {
     public void submitForReview(Long id) {
         Activity activity = activityMapper.selectById(id);
         if (activity == null) {
-            throw new RuntimeException("活动不存在");
+            throw new IllegalStateException("活动不存在");
         }
         if (!"draft".equals(activity.getStatus())) {
-            throw new RuntimeException("仅草稿状态可提交审核");
+            throw new IllegalStateException("仅草稿状态可提交审核");
         }
         activity.setStatus("pending");
         activity.setAuditStatus("pending");
@@ -124,13 +124,13 @@ public class ActivityAdminService {
     public void auditActivity(Long id, String action, String auditReason, Long adminId) {
         Activity activity = activityMapper.selectById(id);
         if (activity == null) {
-            throw new RuntimeException("活动不存在");
+            throw new IllegalStateException("活动不存在");
         }
         if (!"pending".equals(activity.getStatus())) {
-            throw new RuntimeException("仅待审核状态可审核");
+            throw new IllegalStateException("仅待审核状态可审核");
         }
         if (!"approved".equals(action) && !"rejected".equals(action)) {
-            throw new RuntimeException("审核操作无效");
+            throw new IllegalStateException("审核操作无效");
         }
         if ("approved".equals(action)) {
             activity.setStatus("published");
@@ -149,10 +149,10 @@ public class ActivityAdminService {
     public void withdraw(Long id) {
         Activity activity = activityMapper.selectById(id);
         if (activity == null) {
-            throw new RuntimeException("活动不存在");
+            throw new IllegalStateException("活动不存在");
         }
         if (!"published".equals(activity.getStatus())) {
-            throw new RuntimeException("仅发布中的活动可撤回");
+            throw new IllegalStateException("仅发布中的活动可撤回");
         }
         activity.setStatus("withdrawn");
         activityMapper.updateById(activity);
@@ -162,10 +162,12 @@ public class ActivityAdminService {
     public void reDraft(Long id) {
         Activity activity = activityMapper.selectById(id);
         if (activity == null) {
-            throw new RuntimeException("活动不存在");
+            throw new IllegalStateException("活动不存在");
         }
-        if (!"rejected".equals(activity.getStatus())) {
-            throw new RuntimeException("仅已驳回的活动可重新编辑");
+        // 已驳回/已撤回/已发布的活动均可退回草稿重新编辑
+        String status = activity.getStatus();
+        if (!"rejected".equals(status) && !"withdrawn".equals(status) && !"published".equals(status)) {
+            throw new IllegalStateException("当前状态不可退回草稿");
         }
         activity.setStatus("draft");
         activity.setAuditStatus(null);

+ 84 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java

@@ -226,6 +226,90 @@ public class AiGateway {
         }
     }
 
+    /**
+     * 先天画像 AI 解读:调用 LangGraph innate_portrait_graph
+     * @param portrait 先天画像数据
+     * @return 含 "reading" 字段的 Map;失败返回 null(调用方降级模板)
+     */
+    public Map<String, Object> generateInnateReading(Object portrait) {
+        if (!enabled || isCircuitOpen()) return null;
+        try {
+            ObjectNode body = objectMapper.createObjectNode();
+            body.set("portrait", objectMapper.valueToTree(portrait));
+
+            HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
+            String url = baseUrl + "/api/v1/innate/reading";
+
+            ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
+            if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
+                JsonNode root = objectMapper.readTree(response.getBody());
+                Map<String, Object> result = new LinkedHashMap<>();
+                result.put("reading", root.has("reading") ? root.get("reading").asText() : "");
+                consecutiveFailures.set(0);
+                return result;
+            }
+            return null;
+        } catch (Exception e) {
+            log.warn("AiGateway generateInnateReading 调用失败: {}", e.getMessage());
+            recordFailure();
+            return null;
+        }
+    }
+
+    /**
+     * 生成自检 AI 建议(调用 LangGraph self_check_analysis_graph)
+     * @return 含 "advice_json" 和 "fallback_used" 的 Map;失败返回 null
+     */
+    public Map<String, Object> generateSelfCheckAdvice(Map<String, Object> inputs) {
+        if (!enabled || isCircuitOpen()) return null;
+        try {
+            ObjectNode body = objectMapper.valueToTree(inputs);
+            HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
+            String url = baseUrl + "/api/v1/self-check/analysis";
+            ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
+            if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
+                JsonNode root = objectMapper.readTree(response.getBody());
+                Map<String, Object> result = new LinkedHashMap<>();
+                result.put("advice_json", root.has("advice_json") ? root.get("advice_json").asText() : null);
+                result.put("fallback_used", root.has("fallback_used") ? root.get("fallback_used").asBoolean() : true);
+                consecutiveFailures.set(0);
+                return result;
+            }
+            return null;
+        } catch (Exception e) {
+            log.warn("AiGateway generateSelfCheckAdvice 调用失败: {}", e.getMessage());
+            recordFailure();
+            return null;
+        }
+    }
+
+    /**
+     * 生成自检趋势分析(调用 LangGraph self_check_trend_graph)
+     * @return 含 "aiInsight" 和 "trendSummary" 的 Map;失败返回 null
+     */
+    public Map<String, Object> generateSelfCheckTrend(Map<String, Object> inputs) {
+        if (!enabled || isCircuitOpen()) return null;
+        try {
+            ObjectNode body = objectMapper.valueToTree(inputs);
+            HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
+            String url = baseUrl + "/api/v1/self-check/trend";
+            ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
+            if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
+                JsonNode root = objectMapper.readTree(response.getBody());
+                Map<String, Object> result = new LinkedHashMap<>();
+                result.put("aiInsight", root.has("aiInsight") ? root.get("aiInsight").asText() : "");
+                result.put("trendSummary", root.has("trendSummary") ? root.get("trendSummary").asText() : "");
+                consecutiveFailures.set(0);
+                return result;
+            }
+            return null;
+        } catch (Exception e) {
+            log.warn("AiGateway generateSelfCheckTrend 调用失败: {}", e.getMessage());
+            recordFailure();
+            return null;
+        }
+    }
+
     private List<Map<String, Object>> parseTasks(JsonNode tasksNode) {
         List<Map<String, Object>> tasks = new ArrayList<>();
         if (tasksNode != null && tasksNode.isArray()) {

+ 29 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java

@@ -344,6 +344,35 @@ public class ArticleService {
         }
     }
 
+    /**
+     * 递增文章浏览数(打开详情页即计数,匿名/登录用户均计)
+     */
+    public void incrementViewCount(Long id) {
+        if (id == null) return;
+        articleMapper.update(null, Wrappers.<Article>lambdaUpdate()
+                .eq(Article::getId, id)
+                .setSql("view_count = view_count + 1"));
+    }
+
+    /**
+     * 按文章 ID 分页查询阅读记录,返回用户昵称、时长、最后阅读时间
+     */
+    public Page<Map<String, Object>> getArticleReadingRecords(Long articleId, int page, int size) {
+        long total = articleReadingRecordMapper.countByArticle(articleId);
+        if (total == 0) {
+            Page<Map<String, Object>> empty = new Page<>(page, size);
+            empty.setTotal(0);
+            empty.setRecords(Collections.emptyList());
+            return empty;
+        }
+        int offset = (page - 1) * size;
+        List<Map<String, Object>> records = articleReadingRecordMapper.selectByArticle(articleId, offset, size);
+        Page<Map<String, Object>> result = new Page<>(page, size);
+        result.setTotal(total);
+        result.setRecords(records);
+        return result;
+    }
+
     public Article getById(Long id) {
         return articleMapper.selectById(id);
     }

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentPlanGenerator.java

@@ -73,6 +73,9 @@ public class AssessmentPlanGenerator {
         plan.setStatus("generated");
         plan.setSource("assessment");
         plan.setSourceResultId(result.getId());
+        if (!templateIds.isEmpty()) {
+            plan.setPackageId(templateIds.iterator().next());
+        }
         plan.setTotalDays(14);
         plan.setCompletedDays(0);
         plan.setStartDate(new Date());

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

@@ -50,6 +50,10 @@ public class BadgeService {
 
     @Transactional
     public void createBadge(Badge badge) {
+        // badge_id 为唯一非空字段,前端未传时自动生成,避免 NOT NULL 约束报 500
+        if (badge.getBadgeId() == null || badge.getBadgeId().trim().isEmpty()) {
+            badge.setBadgeId("b_" + java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 12));
+        }
         badge.setCreatedAt(new Date());
         badge.setUpdatedAt(new Date());
         badgeMapper.insert(badge);

+ 22 - 4
cfc-backend/src/main/java/com/etotem/cfc/service/BeijingNutritionService.java

@@ -326,7 +326,7 @@ public class BeijingNutritionService {
                         return s != null && !"正常".equals(s);
                     }).count();
             dim.put("abnormalCount", abnormalCount);
-            dim.put("totalCount", entry.getValue().size());
+            dim.put("totalCount", (long) entry.getValue().size());
 
             // 评估状态
             if (abnormalCount == 0) {
@@ -405,8 +405,8 @@ public class BeijingNutritionService {
     /** 功能维度摘要 */
     private String getFuncSummary(String funcName, Map<String, Object> dim) {
         String status = (String) dim.get("status");
-        long total = (long) dim.get("totalCount");
-        long abnormal = (long) dim.get("abnormalCount");
+        long total = ((Number) dim.get("totalCount")).longValue();
+        long abnormal = ((Number) dim.get("abnormalCount")).longValue();
 
         switch (funcName) {
             case "丁酸盐生产者":
@@ -559,6 +559,18 @@ public class BeijingNutritionService {
      * @return 推荐食材列表(Top 15,含推荐理由)
      */
     public List<IngredientRecommendation> generateIngredientList(Long familyId, java.time.LocalDate date) {
+        return generateIngredientList(familyId, date, 0);
+    }
+
+    /**
+     * 生成食材推荐列表(支持 seed 偏移,使"换一批"真正返回不同组合)
+     *
+     * @param familyId 家庭ID
+     * @param date     日期
+     * @param seed     偏移种子(0=默认截取Top 15,>0=旋转后截取)
+     * @return 推荐食材列表(Top 15,含推荐理由)
+     */
+    public List<IngredientRecommendation> generateIngredientList(Long familyId, java.time.LocalDate date, int seed) {
         List<IngredientRecommendation> result = new ArrayList<>();
         
         // 1. 查 meal_configs → participant_member_ids
@@ -711,7 +723,13 @@ public class BeijingNutritionService {
         
         // 6. 排序并返回 Top 15
         result.sort((a, b) -> Integer.compare(b.getScore(), a.getScore()));
-        return result.size() > 15 ? result.subList(0, 15) : result;
+        if (seed != 0 && result.size() > 1) {
+            int offset = Math.abs(seed) % result.size();
+            if (offset > 0) {
+                java.util.Collections.rotate(result, offset);
+            }
+        }
+        return result.size() > 15 ? new ArrayList<>(result.subList(0, 15)) : result;
     }
     
     private boolean isWeekend(java.time.LocalDate date) {

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ButlerService.java

@@ -13,6 +13,7 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import javax.annotation.Resource;
+import org.springframework.context.annotation.Lazy;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
@@ -42,6 +43,7 @@ public class ButlerService {
     private ButlerAssignmentMapper butlerAssignmentMapper;
 
     @Resource
+    @Lazy
     private MemberSubscriptionService memberSubscriptionService;
 
     /**

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ChallengeService.java

@@ -11,4 +11,7 @@ public interface ChallengeService {
     
     /** 更新任务进度 */
     void updateTaskProgress(Long memberId, String taskKey, int currentValue);
+    
+    /** 同步连续打卡进度(打卡完成后调用) */
+    void syncStreakProgress(Long memberId);
 }

+ 212 - 106
cfc-backend/src/main/java/com/etotem/cfc/service/CouponService.java

@@ -2,11 +2,14 @@ package com.etotem.cfc.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.entity.Coupon;
-import com.etotem.cfc.entity.CouponGrantLog;
-import com.etotem.cfc.entity.UserCoupon;
-import com.etotem.cfc.mapper.CouponGrantLogMapper;
+import com.etotem.cfc.entity.FamilyCoupon;
+import com.etotem.cfc.entity.FamilyCouponGrantLog;
+import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.CouponMapper;
-import com.etotem.cfc.mapper.UserCouponMapper;
+import com.etotem.cfc.mapper.FamilyCouponGrantLogMapper;
+import com.etotem.cfc.mapper.FamilyCouponMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import org.springframework.context.annotation.Lazy;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -28,27 +31,85 @@ public class CouponService {
     private CouponMapper couponMapper;
 
     @Resource
-    private UserCouponMapper userCouponMapper;
+    private FamilyCouponMapper familyCouponMapper;
 
     @Resource
-    private CouponGrantLogMapper couponGrantLogMapper;
+    private FamilyCouponGrantLogMapper familyCouponGrantLogMapper;
+
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    @Lazy
+    private MembershipService membershipService;
+
+    // ----------------------------------------------------------------
+    // 内部辅助方法
+    // ----------------------------------------------------------------
+
+    /** userId → familyId(用于查询当前用户所在家庭) */
+    private Long getFamilyId(Long userId) {
+        User user = userMapper.selectById(userId);
+        if (user == null || user.getFamilyId() == null) {
+            return null;
+        }
+        return user.getFamilyId();
+    }
+
+    /** 构建与 CouponService.listMyCoupons 相同格式的响应 Map */
+    private Map<String, Object> toCouponVO(FamilyCoupon fc) {
+        Coupon coupon = couponMapper.selectById(fc.getCouponId());
+        if (coupon == null) return null;
+        Date now = new Date();
+        String status = fc.getStatus();
+        if ("AVAILABLE".equals(status) && !"ACTIVE".equals(coupon.getStatus())) {
+            status = "DISABLED";
+        } else if ("AVAILABLE".equals(status) && coupon.getValidUntil() != null && now.after(coupon.getValidUntil())) {
+            status = "EXPIRED";
+        }
+        Map<String, Object> item = new HashMap<>();
+        // 对外字段名保持 userCouponId / id 等原有契约,前端不感知内部实现变化
+        item.put("userCouponId", fc.getId());
+        item.put("id", fc.getId());
+        item.put("couponId", coupon.getId());
+        item.put("name", coupon.getName());
+        item.put("type", coupon.getType());
+        item.put("value", coupon.getValue());
+        item.put("discountRate", coupon.getDiscountRate());
+        item.put("minSpend", coupon.getMinSpend());
+        item.put("applicableTo", coupon.getApplicableTo());
+        item.put("validFrom", coupon.getValidFrom());
+        item.put("validUntil", coupon.getValidUntil());
+        item.put("status", status);
+        item.put("receivedAt", fc.getReceivedAt());
+        item.put("usedAt", fc.getUsedAt());
+        item.put("orderId", fc.getOrderId());
+        return item;
+    }
+
+    // ----------------------------------------------------------------
+    // 对外保留接口(签名不变,内部改为读 family_coupon)
+    // ----------------------------------------------------------------
 
     public List<Coupon> listAvailable(Long userId) {
-        List<UserCoupon> userCoupons = userCouponMapper.selectList(
-                new LambdaQueryWrapper<UserCoupon>()
-                        .eq(UserCoupon::getUserId, userId)
-                        .eq(UserCoupon::getStatus, "AVAILABLE"));
-        if (userCoupons.isEmpty()) {
+        Long familyId = getFamilyId(userId);
+        if (familyId == null) return new ArrayList<>();
+        List<FamilyCoupon> familyCoupons = familyCouponMapper.selectList(
+                new LambdaQueryWrapper<FamilyCoupon>()
+                        .eq(FamilyCoupon::getFamilyId, familyId)
+                        .eq(FamilyCoupon::getStatus, "AVAILABLE"));
+        if (familyCoupons.isEmpty()) {
             return new ArrayList<>();
         }
         List<Long> couponIds = new ArrayList<>();
-        for (UserCoupon uc : userCoupons) {
-            couponIds.add(uc.getCouponId());
+        for (FamilyCoupon fc : familyCoupons) {
+            couponIds.add(fc.getCouponId());
         }
         Date now = new Date();
         return couponMapper.selectList(
                 new LambdaQueryWrapper<Coupon>()
                         .in(Coupon::getId, couponIds)
+                        .and(w -> w.isNull(Coupon::getStatus).or().eq(Coupon::getStatus, "ACTIVE"))
                         .le(Coupon::getValidFrom, now)
                         .ge(Coupon::getValidUntil, now)
                         .orderByAsc(Coupon::getValue));
@@ -56,10 +117,15 @@ public class CouponService {
 
     @Transactional
     public String claim(Long userId, Long couponId) {
+        Long familyId = getFamilyId(userId);
+        if (familyId == null) return "未加入家庭";
         Coupon coupon = couponMapper.selectById(couponId);
         if (coupon == null) {
             return "优惠券不存在";
         }
+        if (!"ACTIVE".equals(coupon.getStatus())) {
+            return "优惠券已停用";
+        }
         if (coupon.getTotalCount() != null && coupon.getUsedCount() != null
                 && coupon.getUsedCount() >= coupon.getTotalCount()) {
             return "优惠券已领完";
@@ -71,33 +137,43 @@ public class CouponService {
         if (coupon.getValidUntil() != null && now.after(coupon.getValidUntil())) {
             return "优惠券已过期";
         }
-        Long count = userCouponMapper.selectCount(
-                new LambdaQueryWrapper<UserCoupon>()
-                        .eq(UserCoupon::getUserId, userId)
-                        .eq(UserCoupon::getCouponId, couponId));
+        // 去重:同一个家庭同一张券只发一次(同 grantType 的幂等也通过 grant 方法保证)
+        Long count = familyCouponMapper.selectCount(
+                new LambdaQueryWrapper<FamilyCoupon>()
+                        .eq(FamilyCoupon::getFamilyId, familyId)
+                        .eq(FamilyCoupon::getCouponId, couponId));
         if (count != null && count > 0) {
             return "已领取过该优惠券";
         }
-        UserCoupon uc = new UserCoupon();
-        uc.setUserId(userId);
-        uc.setCouponId(couponId);
-        uc.setStatus("AVAILABLE");
-        uc.setReceivedAt(new Date());
-        userCouponMapper.insert(uc);
+        issueToFamily(couponId, familyId);
         coupon.setUsedCount(coupon.getUsedCount() == null ? 1 : coupon.getUsedCount() + 1);
         couponMapper.updateById(coupon);
         return "领取成功";
     }
 
+    /**
+     * 判断用户是否为有效会员(FAMILY 或 PREMIUM 且未过期)
+     */
+    private boolean isMember(Long userId) {
+        if (userId == null) return false;
+        String level = membershipService.getMemberLevel(userId);
+        return "FAMILY".equals(level) || "PREMIUM".equals(level);
+    }
+
     public Integer apply(Long userId, Long userCouponId, String orderType, Integer orderAmount) {
-        UserCoupon uc = userCouponMapper.selectById(userCouponId);
-        if (uc == null || !uc.getUserId().equals(userId) || !"AVAILABLE".equals(uc.getStatus())) {
+        Long familyId = getFamilyId(userId);
+        if (familyId == null) return null;
+        FamilyCoupon fc = familyCouponMapper.selectById(userCouponId);
+        if (fc == null || !fc.getFamilyId().equals(familyId) || !"AVAILABLE".equals(fc.getStatus())) {
             return null;
         }
-        Coupon coupon = couponMapper.selectById(uc.getCouponId());
+        Coupon coupon = couponMapper.selectById(fc.getCouponId());
         if (coupon == null) {
             return null;
         }
+        if (!"ACTIVE".equals(coupon.getStatus())) {
+            return null;
+        }
         Date now = new Date();
         if (coupon.getValidFrom() != null && now.before(coupon.getValidFrom())) {
             return null;
@@ -108,6 +184,10 @@ public class CouponService {
         if (!"ALL".equals(coupon.getApplicableTo()) && !coupon.getApplicableTo().equals(orderType)) {
             return null;
         }
+        // 新增:会员专属券(applicableTo=MEMBERSHIP)仅允许会员使用
+        if ("MEMBERSHIP".equals(coupon.getApplicableTo()) && !isMember(userId)) {
+            return null;
+        }
         // 无门槛券(CASH)忽略起用门槛;满减/折扣券校验
         if (!"CASH".equals(coupon.getType()) && coupon.getMinSpend() != null && orderAmount < coupon.getMinSpend()) {
             return null;
@@ -123,23 +203,23 @@ public class CouponService {
 
     @Transactional
     public void markUsed(Long userCouponId, Long orderId) {
-        UserCoupon uc = userCouponMapper.selectById(userCouponId);
-        if (uc != null) {
-            uc.setStatus("USED");
-            uc.setUsedAt(new Date());
-            uc.setOrderId(orderId);
-            userCouponMapper.updateById(uc);
+        FamilyCoupon fc = familyCouponMapper.selectById(userCouponId);
+        if (fc != null) {
+            fc.setStatus("USED");
+            fc.setUsedAt(new Date());
+            fc.setOrderId(orderId);
+            familyCouponMapper.updateById(fc);
         }
     }
 
     @Transactional
     public void revert(Long userCouponId) {
-        UserCoupon uc = userCouponMapper.selectById(userCouponId);
-        if (uc != null) {
-            uc.setStatus("AVAILABLE");
-            uc.setUsedAt(null);
-            uc.setOrderId(null);
-            userCouponMapper.updateById(uc);
+        FamilyCoupon fc = familyCouponMapper.selectById(userCouponId);
+        if (fc != null) {
+            fc.setStatus("AVAILABLE");
+            fc.setUsedAt(null);
+            fc.setOrderId(null);
+            familyCouponMapper.updateById(fc);
         }
     }
 
@@ -147,33 +227,41 @@ public class CouponService {
         return couponMapper.selectList(null);
     }
 
+    public Coupon getById(Long couponId) {
+        return couponMapper.selectById(couponId);
+    }
+
+    /** 单条发放到家庭(替代旧 issueToUser) */
     @Transactional
-    public void issueToUser(Long couponId, Long userId) {
-        UserCoupon uc = new UserCoupon();
-        uc.setCouponId(couponId);
-        uc.setUserId(userId);
-        uc.setStatus("AVAILABLE");
-        uc.setReceivedAt(new Date());
-        userCouponMapper.insert(uc);
+    public void issueToFamily(Long couponId, Long familyId) {
+        FamilyCoupon fc = new FamilyCoupon();
+        fc.setCouponId(couponId);
+        fc.setFamilyId(familyId);
+        fc.setStatus("AVAILABLE");
+        fc.setReceivedAt(new Date());
+        familyCouponMapper.insert(fc);
     }
 
     /**
-     * 发放优惠券并落流水(幂等)。
+     * 发放优惠券并落流水(幂等,家庭维度)。
      * period 非空时先查流水:已存在则跳过(PERIODIC 防重用);唯一键冲突由事务回滚保证一致性。
      */
     @Transactional
-    public boolean grant(Long userId, Long couponId, String grantType, String period, String source) {
+    public boolean grantFamily(Long familyId, Long couponId, String grantType, String period, String source) {
         Coupon coupon = couponMapper.selectById(couponId);
         if (coupon == null) {
             return false;
         }
+        if (!"ACTIVE".equals(coupon.getStatus())) {
+            return false;
+        }
         if (period != null) {
-            Long exists = couponGrantLogMapper.selectCount(
-                    new LambdaQueryWrapper<CouponGrantLog>()
-                            .eq(CouponGrantLog::getUserId, userId)
-                            .eq(CouponGrantLog::getCouponId, couponId)
-                            .eq(CouponGrantLog::getGrantType, grantType)
-                            .eq(CouponGrantLog::getPeriod, period));
+            Long exists = familyCouponGrantLogMapper.selectCount(
+                    new LambdaQueryWrapper<FamilyCouponGrantLog>()
+                            .eq(FamilyCouponGrantLog::getFamilyId, familyId)
+                            .eq(FamilyCouponGrantLog::getCouponId, couponId)
+                            .eq(FamilyCouponGrantLog::getGrantType, grantType)
+                            .eq(FamilyCouponGrantLog::getPeriod, period));
             if (exists != null && exists > 0) {
                 return false;
             }
@@ -181,51 +269,73 @@ public class CouponService {
         int quantity = coupon.getGrantQuantity() != null && coupon.getGrantQuantity() > 0
                 ? coupon.getGrantQuantity() : 1;
         for (int i = 0; i < quantity; i++) {
-            issueToUser(couponId, userId);
+            issueToFamily(couponId, familyId);
         }
-        CouponGrantLog log = new CouponGrantLog();
-        log.setUserId(userId);
+        FamilyCouponGrantLog log = new FamilyCouponGrantLog();
+        log.setFamilyId(familyId);
         log.setCouponId(couponId);
         log.setGrantType(grantType);
         log.setPeriod(period);
         log.setQuantity(quantity);
         log.setSource(source);
-        couponGrantLogMapper.insert(log);
+        familyCouponGrantLogMapper.insert(log);
         return true;
     }
 
     /** JOIN: 开通/续费指定等级会员时发放 */
     @Transactional
-    public void grantJoinCoupons(Long userId, String levelCode, String source) {
+    public void grantFamilyJoinCoupons(Long familyId, String levelCode, String source) {
         List<Coupon> coupons = couponMapper.selectList(
                 new LambdaQueryWrapper<Coupon>()
                         .eq(Coupon::getGrantType, "JOIN")
-                        .eq(Coupon::getGrantLevelCode, levelCode));
+                        .eq(Coupon::getGrantLevelCode, levelCode)
+                        .and(w -> w.isNull(Coupon::getStatus).or().eq(Coupon::getStatus, "ACTIVE")));
         if (coupons.isEmpty()) {
-            logger.warn("未找到 JOIN 赠券模板: userId={}, levelCode={}", userId, levelCode);
+            logger.warn("未找到 JOIN 赠券模板: familyId={}, levelCode={}", familyId, levelCode);
             return;
         }
         for (Coupon c : coupons) {
-            grant(userId, c.getId(), "JOIN", "JOIN", source);
+            grantFamily(familyId, c.getId(), "JOIN", "JOIN", source);
+        }
+        logger.info("发放 JOIN 赠券完成: familyId={}, levelCode={}, count={}", familyId, levelCode, coupons.size());
+    }
+
+    /** 兼容旧签名(标记废弃,保留供降级) */
+    @Deprecated
+    public void grantJoinCoupons(Long userId, String levelCode, String source) {
+        Long familyId = getFamilyId(userId);
+        if (familyId != null) {
+            grantFamilyJoinCoupons(familyId, levelCode, source);
         }
-        logger.info("发放 JOIN 赠券完成: userId={}, levelCode={}, count={}", userId, levelCode, coupons.size());
     }
 
-    /** POPULATION: 新增家庭成员时发放(目标账户由调用方决定) */
+    /** POPULATION: 新增家庭成员时发放 */
     @Transactional
-    public void grantPopulationCoupons(Long userId, Long memberId) {
+    public void grantFamilyPopulationCoupons(Long familyId, Long memberId) {
         List<Coupon> coupons = couponMapper.selectList(
                 new LambdaQueryWrapper<Coupon>()
-                        .eq(Coupon::getGrantType, "POPULATION"));
+                        .eq(Coupon::getGrantType, "POPULATION")
+                        .and(w -> w.isNull(Coupon::getStatus).or().eq(Coupon::getStatus, "ACTIVE")));
         for (Coupon c : coupons) {
-            grant(userId, c.getId(), "POPULATION", null, "member:" + memberId);
+            grantFamily(familyId, c.getId(), "POPULATION", null, "member:" + memberId);
+        }
+    }
+
+    /** 兼容旧签名(标记废弃,保留供降级) */
+    @Deprecated
+    public void grantPopulationCoupons(Long userId, Long memberId) {
+        Long familyId = getFamilyId(userId);
+        if (familyId != null) {
+            grantFamilyPopulationCoupons(familyId, memberId);
         }
     }
 
     /** PERIODIC: 返回所有周期性补发券模板 */
     public List<Coupon> listPeriodicTemplates() {
         return couponMapper.selectList(
-                new LambdaQueryWrapper<Coupon>().eq(Coupon::getGrantType, "PERIODIC"));
+                new LambdaQueryWrapper<Coupon>()
+                        .eq(Coupon::getGrantType, "PERIODIC")
+                        .and(w -> w.isNull(Coupon::getStatus).or().eq(Coupon::getStatus, "ACTIVE")));
     }
 
     /** 积分兑换中心:所有可兑换券(points_price>0 且在有效期内) */
@@ -234,6 +344,7 @@ public class CouponService {
         return couponMapper.selectList(
                 new LambdaQueryWrapper<Coupon>()
                         .gt(Coupon::getPointsPrice, 0)
+                        .and(w -> w.isNull(Coupon::getStatus).or().eq(Coupon::getStatus, "ACTIVE"))
                         .le(Coupon::getValidFrom, now)
                         .ge(Coupon::getValidUntil, now)
                         .orderByAsc(Coupon::getPointsPrice));
@@ -246,24 +357,28 @@ public class CouponService {
                 new LambdaQueryWrapper<Coupon>()
                         .eq(Coupon::getProductId, productId)
                         .gt(Coupon::getPointsPrice, 0)
+                        .and(w -> w.isNull(Coupon::getStatus).or().eq(Coupon::getStatus, "ACTIVE"))
                         .le(Coupon::getValidFrom, now)
                         .ge(Coupon::getValidUntil, now)
                         .orderByAsc(Coupon::getPointsPrice));
     }
 
     /**
-     * 查询用户尚未拥有的可兑换优惠券(用于结算页直接兑换)
+     * 查询家庭尚未拥有的可兑换优惠券(用于结算页直接兑换)
      * @param productId 商品ID,非空时只返回该商品绑定的券
      */
     public List<Coupon> listUnownedExchangeable(Long userId, Long productId) {
+        Long familyId = getFamilyId(userId);
+        if (familyId == null) return new ArrayList<>();
         Date now = new Date();
-        List<Long> ownedIds = userCouponMapper.selectList(
-                new LambdaQueryWrapper<UserCoupon>()
-                        .eq(UserCoupon::getUserId, userId)
-                        .eq(UserCoupon::getStatus, "AVAILABLE"))
-                .stream().map(UserCoupon::getCouponId).collect(java.util.stream.Collectors.toList());
+        List<Long> ownedIds = familyCouponMapper.selectList(
+                new LambdaQueryWrapper<FamilyCoupon>()
+                        .eq(FamilyCoupon::getFamilyId, familyId)
+                        .eq(FamilyCoupon::getStatus, "AVAILABLE"))
+                .stream().map(FamilyCoupon::getCouponId).collect(java.util.stream.Collectors.toList());
         LambdaQueryWrapper<Coupon> wrapper = new LambdaQueryWrapper<Coupon>()
                 .gt(Coupon::getPointsPrice, 0)
+                .and(w -> w.isNull(Coupon::getStatus).or().eq(Coupon::getStatus, "ACTIVE"))
                 .le(Coupon::getValidFrom, now)
                 .ge(Coupon::getValidUntil, now)
                 .notIn(!ownedIds.isEmpty(), Coupon::getId, ownedIds)
@@ -277,46 +392,37 @@ public class CouponService {
     /** JOIN 权益展示:所有加入赠券模板 */
     public List<Coupon> listJoinRules() {
         return couponMapper.selectList(
-                new LambdaQueryWrapper<Coupon>().eq(Coupon::getGrantType, "JOIN"));
+                new LambdaQueryWrapper<Coupon>()
+                        .eq(Coupon::getGrantType, "JOIN")
+                        .and(w -> w.isNull(Coupon::getStatus).or().eq(Coupon::getStatus, "ACTIVE")));
     }
 
-    /** 我的优惠券:返回 user_coupon + coupon 联合数据,含 status/receivedAt/usedAt */
+    /**
+     * 我的优惠券:返回 family_coupon + coupon 联合数据,含 status/receivedAt/usedAt
+     * 对外字段名保持不变(userCouponId = family_coupon.id),前端不感知内部变化
+     */
     public List<Map<String, Object>> listMyCoupons(Long userId) {
-        List<UserCoupon> userCoupons = userCouponMapper.selectList(
-                new LambdaQueryWrapper<UserCoupon>()
-                        .eq(UserCoupon::getUserId, userId)
-                        .orderByDesc(UserCoupon::getReceivedAt));
-        if (userCoupons.isEmpty()) {
+        Long familyId = getFamilyId(userId);
+        if (familyId == null) return new ArrayList<>();
+        List<FamilyCoupon> familyCoupons = familyCouponMapper.selectList(
+                new LambdaQueryWrapper<FamilyCoupon>()
+                        .eq(FamilyCoupon::getFamilyId, familyId)
+                        .orderByDesc(FamilyCoupon::getReceivedAt));
+        if (familyCoupons.isEmpty()) {
             return new ArrayList<>();
         }
-        Date now = new Date();
         List<Map<String, Object>> result = new ArrayList<>();
-        for (UserCoupon uc : userCoupons) {
-            Coupon coupon = couponMapper.selectById(uc.getCouponId());
-            if (coupon == null) continue;
-            // 过期判断
-            String status = uc.getStatus();
-            if ("AVAILABLE".equals(status) && coupon.getValidUntil() != null && now.after(coupon.getValidUntil())) {
-                status = "EXPIRED";
-            }
-            Map<String, Object> item = new HashMap<>();
-            item.put("userCouponId", uc.getId());
-            item.put("id", uc.getId());
-            item.put("couponId", coupon.getId());
-            item.put("name", coupon.getName());
-            item.put("type", coupon.getType());
-            item.put("value", coupon.getValue());
-            item.put("discountRate", coupon.getDiscountRate());
-            item.put("minSpend", coupon.getMinSpend());
-            item.put("applicableTo", coupon.getApplicableTo());
-            item.put("validFrom", coupon.getValidFrom());
-            item.put("validUntil", coupon.getValidUntil());
-            item.put("status", status);
-            item.put("receivedAt", uc.getReceivedAt());
-            item.put("usedAt", uc.getUsedAt());
-            item.put("orderId", uc.getOrderId());
-            result.add(item);
+        for (FamilyCoupon fc : familyCoupons) {
+            Map<String, Object> vo = toCouponVO(fc);
+            if (vo != null) result.add(vo);
         }
         return result;
     }
+
+    /**
+     * 查询指定会员等级的活跃家庭 ID 列表(供 CouponGrantTask 周期补发)
+     */
+    public List<Long> listActiveMemberFamilyIds(String levelCode) {
+        return membershipService.listActiveMemberFamilyIds(levelCode);
+    }
 }

+ 10 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/DailyCheckinService.java

@@ -26,6 +26,9 @@ public class DailyCheckinService {
     @Resource
     private EmotionCheckinMapper emotionCheckinMapper;
 
+    @Resource
+    private ChallengeService challengeService;
+
     public List<DailyHealthCheckin> getCheckinsByMember(Long memberId) {
         return dailyCheckinMapper.selectList(new LambdaQueryWrapper<DailyHealthCheckin>()
                 .eq(DailyHealthCheckin::getMemberId, memberId)
@@ -58,6 +61,13 @@ public class DailyCheckinService {
             log.warn("心情打卡同步情绪日记失败,不影响主流程: {}", e.getMessage());
         }
 
+        // 同步闯关连续打卡进度(健康启航计划)
+        try {
+            challengeService.syncStreakProgress(checkin.getMemberId());
+        } catch (Exception e) {
+            log.warn("同步闯关进度失败,不影响打卡记录: {}", e.getMessage());
+        }
+
         return checkin;
     }
 

+ 19 - 4
cfc-backend/src/main/java/com/etotem/cfc/service/DailyOverviewService.java

@@ -44,7 +44,7 @@ public class DailyOverviewService {
         vo.setMemberId(memberId);
         vo.setDate(today);
         vo.setCheckins(buildCheckins(userId, memberId, today));
-        vo.setTasks(buildTasks(userId, memberId));
+        vo.setTasks(buildTasks(userId, memberId, familyId));
         vo.setActivities(buildActivities(memberId));
         vo.setAssessments(buildAssessments(memberId, familyId));
         vo.setHasMore(false);
@@ -96,18 +96,33 @@ public class DailyOverviewService {
 
     // ---------- 任务类 ----------
 
-    private DailyOverviewVO.TaskSummary buildTasks(Long userId, Long memberId) {
+    private DailyOverviewVO.TaskSummary buildTasks(Long userId, Long memberId, Long familyId) {
         DailyOverviewVO.TaskSummary s = new DailyOverviewVO.TaskSummary();
         s.setPendingAccept(0);
 
         List<Task> pending = taskService.getMyPendingTasks(memberId);
         s.setPendingAccept(pending == null ? 0 : pending.size());
 
+        // 有 familyId 时按家庭聚合(方案任务可能挂在任意成员);否则按当前成员
+        int todo = 0;
+        int done = 0;
+        List<Task> todayTasks = familyId != null
+                ? taskService.getTodayTasksByFamily(familyId)
+                : taskService.getTodayTasks(memberId);
+        if (todayTasks != null) {
+            for (Task t : todayTasks) {
+                if ("completed".equals(t.getStatus())) {
+                    done++;
+                } else if ("pending".equals(t.getStatus()) || "pending_review".equals(t.getStatus())
+                        || "in_progress".equals(t.getStatus()) || "locked".equals(t.getStatus())) {
+                    todo++;
+                }
+            }
+        }
+
         Map<String, String> params = new HashMap<>();
         params.put("type", "DAILY");
         List<Map<String, Object>> dailyList = growthTaskService.getTaskList(userId, params);
-        int todo = 0;
-        int done = 0;
         if (dailyList != null) {
             for (Map<String, Object> t : dailyList) {
                 Object c = t.get("completed");

+ 17 - 6
cfc-backend/src/main/java/com/etotem/cfc/service/DanKnowledgeBaseService.java

@@ -11,6 +11,7 @@ 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 lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -21,6 +22,7 @@ import java.util.Map;
 import java.util.stream.Collectors;
 import com.etotem.cfc.util.SortUtil;
 
+@Slf4j
 @Service
 public class DanKnowledgeBaseService extends ServiceImpl<DanKnowledgeBaseMapper, DanKnowledgeBase> {
 
@@ -110,12 +112,21 @@ public class DanKnowledgeBaseService extends ServiceImpl<DanKnowledgeBaseMapper,
 
     @Transactional(rollbackFor = Exception.class)
     public boolean deleteKnowledge(Long id) {
-        danKnowledgeBaseDimensionMapper.delete(
-            new LambdaQueryWrapper<DanKnowledgeBaseDimension>()
-                .eq(DanKnowledgeBaseDimension::getKnowledgeId, id));
-        danKnowledgeBaseTagMapper.delete(
-            new LambdaQueryWrapper<DanKnowledgeBaseTag>()
-                .eq(DanKnowledgeBaseTag::getKnowledgeId, id));
+        // 关联表可能未建/已缺失,删除主记录不允许被关联表异常阻断
+        try {
+            danKnowledgeBaseDimensionMapper.delete(
+                new LambdaQueryWrapper<DanKnowledgeBaseDimension>()
+                    .eq(DanKnowledgeBaseDimension::getKnowledgeId, id));
+        } catch (Exception e) {
+            log.warn("删除知识维度关联失败(忽略,继续删除主记录): id={}, error={}", id, e.getMessage());
+        }
+        try {
+            danKnowledgeBaseTagMapper.delete(
+                new LambdaQueryWrapper<DanKnowledgeBaseTag>()
+                    .eq(DanKnowledgeBaseTag::getKnowledgeId, id));
+        } catch (Exception e) {
+            log.warn("删除知识标签关联失败(忽略,继续删除主记录): id={}, error={}", id, e.getMessage());
+        }
         return this.removeById(id);
     }
 

+ 163 - 8
cfc-backend/src/main/java/com/etotem/cfc/service/DanReportUploadService.java

@@ -5,16 +5,17 @@ import com.etotem.cfc.dto.DanReportEditDTO;
 import com.etotem.cfc.dto.DanReportUploadVO;
 import com.etotem.cfc.entity.DanAssessmentResult;
 import com.etotem.cfc.entity.DanReportUpload;
+import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.UserIntent;
 import com.etotem.cfc.mapper.DanAssessmentResultMapper;
 import com.etotem.cfc.mapper.DanReportUploadMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.UserIntentMapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.fasterxml.jackson.core.type.TypeReference;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
-import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.web.multipart.MultipartFile;
@@ -63,6 +64,12 @@ public class DanReportUploadService {
     @Resource
     private NotificationDispatchService notificationDispatchService;
 
+    @Resource
+    private HealthPlanService healthPlanService;
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
     private final ObjectMapper objectMapper = new ObjectMapper();
 
     // Phase 1: 上传并预览 PDF
@@ -89,8 +96,12 @@ if (!DanReportUpload.DIM_MIND.equals(dimension) && !DanReportUpload.DIM_WISDOM.e
         upload.setFamilyMemberId(memberId);
         upload.setUploaderUserId(userId);
         upload.setDimension(dimension);
+        Long familyId = resolveFamilyId(memberId);
+        if (familyId != null) {
+            upload.setFamilyId(familyId);
+        }
         upload.setFileUrl(fileUrl);
-        upload.setFileId(fileUrl);  // 同义
+        upload.setFileId(toShortFileId(fileUrl));
         upload.setFileName(file.getOriginalFilename());
         upload.setFileSize(file.getSize());
         upload.setUploadedAt(new Date());
@@ -174,17 +185,36 @@ if (!DanReportUpload.DIM_MIND.equals(dimension) && !DanReportUpload.DIM_WISDOM.e
 
         // 创建数值结果记录
         DanAssessmentResult result = new DanAssessmentResult();
-        result.setFamilyMemberId(upload.getFamilyMemberId());
+        Long memberId = upload.getFamilyMemberId();
+        // child_id 为历史必填列,与 family_member_id 同义(均指向 family_members.id)
+        result.setChildId(memberId);
+        result.setFamilyMemberId(memberId);
         result.setTeacherId(upload.getUploaderUserId());
         result.setSource(DanAssessmentResult.SOURCE_PARENT_UPLOAD);
         result.setSourceReportId(upload.getId());
         result.setDimension(upload.getDimension());
-        result.setReportFileId(upload.getFileUrl());
+        result.setReportFileId(upload.getFileId() != null && !upload.getFileId().isEmpty()
+                ? upload.getFileId()
+                : toShortFileId(upload.getFileUrl()));
         result.setDraftStatus("confirmed");
         result.setAnalysisStatus("pending");
         result.setStatus("completed");
+        result.setDanLevel("U"); // 旧库 dan_level NOT NULL;无综合分时占位,有分数再覆盖
         result.setAssessmentDate(new Date());
-        result.setChildName(childName);
+        // 成员快照:优先入参姓名,否则取家庭成员昵称
+        FamilyMember member = memberId != null ? familyMemberMapper.selectById(memberId) : null;
+        String resolvedName = childName;
+        if ((resolvedName == null || resolvedName.isEmpty()) && member != null) {
+            resolvedName = member.getNickname();
+        }
+        result.setChildName(resolvedName);
+        if (member != null) {
+            result.setFamilyMemberName(member.getNickname());
+            result.setFamilyMemberBirthday(member.getBirthday());
+            result.setFamilyMemberGender(member.getGender());
+            result.setChildBirthday(member.getBirthday());
+            result.setChildGender(member.getGender());
+        }
         if (assessmentDateStr != null && !assessmentDateStr.isEmpty()) {
             try {
                 result.setAssessmentDate(new SimpleDateFormat("yyyy-MM-dd").parse(assessmentDateStr));
@@ -200,6 +230,12 @@ if (!DanReportUpload.DIM_MIND.equals(dimension) && !DanReportUpload.DIM_WISDOM.e
         result.setGrowthSuggestions(upload.getEditedSuggestions() != null
                 ? upload.getEditedSuggestions()
                 : upload.getParsedSuggestions());
+        // 尝试从解析项推断综合分并映射 DAN 等级
+        Integer overall = extractOverallScore(result.getStructuredAnalysis());
+        if (overall != null) {
+            result.setOverallScore(overall);
+            result.setDanLevel(mapScoreToDanLevel(overall));
+        }
         result.setCreatedAt(new Date());
         result.setUpdatedAt(new Date());
         resultMapper.insert(result);
@@ -219,7 +255,7 @@ if (!DanReportUpload.DIM_MIND.equals(dimension) && !DanReportUpload.DIM_WISDOM.e
             String suggestions = upload.getEditedSuggestions() != null
                     ? upload.getEditedSuggestions() : upload.getParsedSuggestions();
             reportBlockService.save("dan", upload.getId(),
-                    reportBlockAssembler.assembleDanFromJson(itemsJson, summary, suggestions));
+                    reportBlockAssembler.assembleDanByType(itemsJson, summary, suggestions));
         } catch (Exception e) {
             log.warn("保存dan展示块失败 uploadId={}: {}", uploadId, e.getMessage());
         }
@@ -237,7 +273,117 @@ if (!DanReportUpload.DIM_MIND.equals(dimension) && !DanReportUpload.DIM_WISDOM.e
                     upload.getFamilyMemberId(), result.getId(), upload.getDimension());
         } catch (Exception ignored) {}
 
-        return Result.success(DanReportUploadVO.fromEntity(upload));
+        // 与健康报告一致:确认入库后自动生成健康方案(draft,待用户在方案页点「确定」再分解任务)
+        DanReportUploadVO vo = DanReportUploadVO.fromEntity(upload);
+        Long planId = triggerAutoPlanAfterConfirm(upload);
+        if (planId != null) {
+            vo.setPlanId(planId);
+        }
+        return Result.success(vo);
+    }
+
+    /**
+     * DAN 确认后自动生成方案,维度按测评维度映射(mind→mind,wisdom→wisdom)。
+     * 失败仅记日志,不阻断确认主流程。
+     */
+    private Long triggerAutoPlanAfterConfirm(DanReportUpload upload) {
+        Long memberId = upload.getFamilyMemberId();
+        if (memberId == null) {
+            return null;
+        }
+        Long familyId = upload.getFamilyId();
+        if (familyId == null) {
+            familyId = resolveFamilyId(memberId);
+        }
+        if (familyId == null) {
+            log.warn("DAN确认后无法生成方案:缺少 familyId uploadId={}", upload.getId());
+            return null;
+        }
+        String planReportType = "dan_" + (upload.getDimension() != null ? upload.getDimension() : "mind");
+        try {
+            Long planId = healthPlanService.autoGenerateAndPublish(familyId, memberId, planReportType);
+            if (planId != null) {
+                log.info("DAN确认后自动生成方案 planId={}, uploadId={}, reportType={}",
+                        planId, upload.getId(), planReportType);
+            }
+            return planId;
+        } catch (Exception e) {
+            log.warn("DAN确认后自动生成方案失败 uploadId={}: {}", upload.getId(), e.getMessage());
+            return null;
+        }
+    }
+
+    private Long resolveFamilyId(Long memberId) {
+        if (memberId == null) {
+            return null;
+        }
+        FamilyMember member = familyMemberMapper.selectById(memberId);
+        return member != null ? member.getFamilyId() : null;
+    }
+
+    /**
+     * file_id 存相对路径或文件名,避免把完整 OSS URL 写入短字段导致 Data truncation。
+     * 完整地址放在 file_url。
+     */
+    private String toShortFileId(String fileUrl) {
+        if (fileUrl == null || fileUrl.isEmpty()) {
+            return null;
+        }
+        int uploadsIdx = fileUrl.indexOf("/uploads/");
+        if (uploadsIdx >= 0) {
+            return fileUrl.substring(uploadsIdx + 1);
+        }
+        int slash = fileUrl.lastIndexOf('/');
+        if (slash >= 0 && slash < fileUrl.length() - 1) {
+            return fileUrl.substring(slash + 1);
+        }
+        return fileUrl.length() > 500 ? fileUrl.substring(0, 500) : fileUrl;
+    }
+
+    /** 从 structured_analysis JSON 中尝试提取综合分(0-100) */
+    private Integer extractOverallScore(String structuredJson) {
+        if (structuredJson == null || structuredJson.isEmpty()) {
+            return null;
+        }
+        try {
+            java.util.List<java.util.Map<String, Object>> items = objectMapper.readValue(
+                    structuredJson, new TypeReference<java.util.List<java.util.Map<String, Object>>>() {});
+            if (items == null) {
+                return null;
+            }
+            for (java.util.Map<String, Object> item : items) {
+                if (item == null) continue;
+                String code = item.get("code") != null ? String.valueOf(item.get("code")) : "";
+                String name = item.get("name") != null ? String.valueOf(item.get("name")) : "";
+                String key = (code + name).toLowerCase();
+                if (key.contains("overall") || key.contains("综合") || key.contains("总分")
+                        || "total".equalsIgnoreCase(code)) {
+                    Object v = item.get("value");
+                    if (v == null) continue;
+                    String s = String.valueOf(v).replace("%", "").trim();
+                    try {
+                        double d = Double.parseDouble(s);
+                        int score = (int) Math.round(d);
+                        if (score > 100 && score <= 1000) {
+                            // 可能是百分位或放大分,截到 0-100
+                            score = Math.min(100, score);
+                        }
+                        return Math.max(0, Math.min(100, score));
+                    } catch (NumberFormatException ignored) {
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.debug("解析 structured_analysis 综合分失败: {}", e.getMessage());
+        }
+        return null;
+    }
+
+    private String mapScoreToDanLevel(int score) {
+        if (score >= 85) return "A";
+        if (score >= 70) return "B";
+        if (score >= 55) return "C";
+        return "D";
     }
 
     // 规划师完成分析
@@ -333,7 +479,16 @@ if (!DanReportUpload.DIM_MIND.equals(dimension) && !DanReportUpload.DIM_WISDOM.e
             return Result.error("记录不存在");
         }
         DanReportUploadVO vo = DanReportUploadVO.fromEntity(upload);
-        vo.setBlocks(reportBlockService.getBlocks("dan", upload.getId()));
+        // DAN 报告始终按类型重算 blocks(历史数据也生效),数据源 = edited_*/parsed_*
+        String itemsJson = upload.getEditedItems() != null ? upload.getEditedItems() : upload.getParsedItems();
+        if (itemsJson != null && !itemsJson.isEmpty()) {
+            String summary = upload.getEditedSummary() != null ? upload.getEditedSummary() : upload.getParsedSummary();
+            String suggestions = upload.getEditedSuggestions() != null
+                    ? upload.getEditedSuggestions() : upload.getParsedSuggestions();
+            vo.setBlocks(reportBlockAssembler.assembleDanByType(itemsJson, summary, suggestions));
+        } else {
+            vo.setBlocks(reportBlockService.getBlocks("dan", upload.getId()));
+        }
         if (upload.getLinkedResultId() != null) {
             DanAssessmentResult r = resultMapper.selectById(upload.getLinkedResultId());
             if (r != null) {

+ 39 - 4
cfc-backend/src/main/java/com/etotem/cfc/service/DietIngredientService.java

@@ -1,6 +1,8 @@
 package com.etotem.cfc.service;
 
 import com.etotem.cfc.dto.IngredientRecommendation;
+import com.etotem.cfc.entity.Food;
+import com.etotem.cfc.mapper.FoodMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 
@@ -15,6 +17,9 @@ public class DietIngredientService {
     @Resource
     private BeijingNutritionService beijingNutritionService;
 
+    @Resource
+    private FoodMapper foodMapper;
+
     // 会话内食材选择(按 familyId 隔离)
     private final Map<Long, List<IngredientRecommendation>> selectedIngredients = new HashMap<>();
     private final Map<Long, Integer> refreshSeed = new HashMap<>();
@@ -33,11 +38,11 @@ public class DietIngredientService {
     public Map<String, Object> refreshIngredients(Long familyId) {
         Integer seed = refreshSeed.getOrDefault(familyId, 0) + 1;
         refreshSeed.put(familyId, seed);
-        
-        // 重新生成(通过随机种子变化
-        List<IngredientRecommendation> suggestions = beijingNutritionService.generateIngredientList(familyId, LocalDate.now());
+
+        // 重新生成(通过 seed 偏移截取,真正换一批
+        List<IngredientRecommendation> suggestions = beijingNutritionService.generateIngredientList(familyId, LocalDate.now(), seed);
         selectedIngredients.put(familyId, suggestions);
-        
+
         Map<String, Object> result = new HashMap<>();
         result.put("ingredients", suggestions);
         result.put("selectedCount", 0);
@@ -88,4 +93,34 @@ public class DietIngredientService {
         
         return result;
     }
+
+    /**
+     * 搜索食材(供"添加食材"弹窗使用)
+     *
+     * @param keyword 关键字(空=返回全部,最多50条)
+     * @return {foods:[{id,name,category}]}
+     */
+    public Map<String, Object> searchFoods(String keyword) {
+        List<Map<String, Object>> foods = new ArrayList<>();
+        List<Food> all = foodMapper.selectList(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Food>()
+                        .eq(Food::getStatus, "active")
+                        .orderByAsc(Food::getSortOrder)
+        );
+        String kw = keyword == null ? "" : keyword.trim();
+        int count = 0;
+        for (Food f : all) {
+            if (kw.isEmpty() || (f.getName() != null && f.getName().contains(kw))) {
+                Map<String, Object> item = new HashMap<>();
+                item.put("id", f.getId());
+                item.put("name", f.getName());
+                item.put("category", f.getCategory());
+                foods.add(item);
+                if (++count >= 50) break;
+            }
+        }
+        Map<String, Object> result = new HashMap<>();
+        result.put("foods", foods);
+        return result;
+    }
 }

+ 193 - 16
cfc-backend/src/main/java/com/etotem/cfc/service/DietRecommendationService.java

@@ -2,7 +2,11 @@ package com.etotem.cfc.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.entity.DietRecommendation;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.MealConfig;
 import com.etotem.cfc.mapper.DietRecommendationMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.MealConfigMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 
@@ -24,6 +28,12 @@ public class DietRecommendationService {
     @Resource
     private AiGateway aiGateway;
 
+    @Resource
+    private MealConfigMapper mealConfigMapper;
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
     public Map<String, Object> getTodayRecommendation(Long familyId, String dateStr) {
         LocalDate date = LocalDate.parse(dateStr);
         
@@ -57,36 +67,53 @@ public class DietRecommendationService {
         String dateStr = request.get("date");
         String mealType = request.get("meal_type");
         LocalDate date = LocalDate.parse(dateStr);
-        
-        // 查询共餐配置
-        List<Long> participantIds = new ArrayList<>();
-        // 这里需要从 meal_configs 表查询,简化处理
-        
-        // 生成食材推荐
-        List<com.etotem.cfc.dto.IngredientRecommendation> ingredients = 
-            beijingNutritionService.generateIngredientList(familyId, date);
-        
-        // 调用 AI 生成菜单
-        String selectedFoodsJson = "[]";
+
+        // 1. 解析用户所选食材(前端传 selected_foods: "[{\"food_id\":1,\"name\":\"西红柿\"}]")
+        String selectedFoodsJson = request.get("selected_foods");
+        if (selectedFoodsJson == null || selectedFoodsJson.trim().isEmpty()) {
+            selectedFoodsJson = "[]";
+        }
+
+        // 2. 查询共餐参与者
+        List<Long> participantIds = queryParticipantIds(familyId, date);
+
+        // 3. 调用 AI 生成菜单(参与者数量至少 1)
         String participantsJson = "[]";
+        if (!participantIds.isEmpty()) {
+            try {
+                participantsJson = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(participantIds);
+            } catch (Exception ignore) {}
+        }
         String menuJson = aiGateway.generateMenu(selectedFoodsJson, participantsJson, dateStr);
-        
-        // 保存推荐
+
+        // 4. AI 失败时降级:用推荐食材拼一个最小菜单,避免空壳
+        if (menuJson == null || menuJson.trim().isEmpty() || menuJson.trim().equals("{}")) {
+            menuJson = buildFallbackMenu(familyId, date, participantIds.size());
+        }
+
+        // 5. 归一化 LangGraph 菜单结构 → 前端期望结构 + 聚合营养
+        Map<String, Object> normalized = normalizeMenu(menuJson, participantIds.size());
+        String normalizedMenuJson = safeWrite(normalized);
+        Map<String, Object> nutritionSummary = aggregateNutrition(normalized);
+
+        // 6. 保存推荐
         DietRecommendation record = new DietRecommendation();
         record.setFamilyId(familyId);
         record.setRecommendationDate(java.util.Date.from(date.atStartOfDay(java.time.ZoneId.systemDefault()).toInstant()));
         record.setMealType(mealType != null ? mealType : "all");
-        record.setParticipantMemberIds(new ArrayList<>(participantIds).toString());
-        record.setMenuJson(menuJson != null ? menuJson : "{}");
+        record.setParticipantMemberIds(participantIds.toString());
+        record.setMenuJson(normalizedMenuJson != null ? normalizedMenuJson : "{}");
+        record.setNutritionSummary(safeWrite(nutritionSummary));
         record.setStatus("pending");
         record.setVersion(1);
         record.setCreatedAt(new Date());
         record.setUpdatedAt(new Date());
         dietRecommendationMapper.insert(record);
-        
+
         Map<String, Object> result = new HashMap<>();
         result.put("id", record.getId());
         result.put("menu", record.getMenuJson());
+        result.put("nutritionSummary", record.getNutritionSummary());
         result.put("message", "食谱推荐已生成");
         return result;
     }
@@ -154,4 +181,154 @@ public class DietRecommendationService {
             dietRecommendationMapper.updateById(record);
         }
     }
+
+    // ============================================================
+    // 内部辅助
+    // ============================================================
+
+    /** 查询共餐参与者(按工作日/周末的 meal_configs,未配置则降级为全部家庭成员) */
+    private List<Long> queryParticipantIds(Long familyId, LocalDate date) {
+        boolean weekend = date.getDayOfWeek() == java.time.DayOfWeek.SATURDAY
+                || date.getDayOfWeek() == java.time.DayOfWeek.SUNDAY;
+        String dateType = weekend ? "weekend" : "weekday";
+        Set<Long> ids = new HashSet<>();
+        for (String mealType : Arrays.asList("breakfast", "lunch", "dinner")) {
+            LambdaQueryWrapper<MealConfig> w = new LambdaQueryWrapper<>();
+            w.eq(MealConfig::getFamilyId, familyId)
+             .eq(MealConfig::getConfigDateType, dateType)
+             .eq(MealConfig::getMealType, mealType);
+            MealConfig config = mealConfigMapper.selectOne(w);
+            if (config != null && config.getParticipantMemberIds() != null) {
+                try {
+                    com.fasterxml.jackson.databind.JsonNode nodes = new com.fasterxml.jackson.databind.ObjectMapper()
+                            .readTree(config.getParticipantMemberIds());
+                    if (nodes.isArray()) {
+                        for (com.fasterxml.jackson.databind.JsonNode node : nodes) ids.add(node.asLong());
+                    }
+                } catch (Exception ignore) {}
+            }
+        }
+        if (ids.isEmpty()) {
+            LambdaQueryWrapper<FamilyMember> w = new LambdaQueryWrapper<>();
+            w.eq(FamilyMember::getFamilyId, familyId);
+            List<FamilyMember> members = familyMemberMapper.selectList(w);
+            for (FamilyMember m : members) ids.add(m.getId());
+        }
+        return new ArrayList<>(ids);
+    }
+
+    /** 归一化 LangGraph 菜单 → 前端期望结构(dish.calories / ingredients[].amount / method) */
+    private Map<String, Object> normalizeMenu(String menuJson, int participantCount) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        List<Map<String, Object>> meals = new ArrayList<>();
+        result.put("meals", meals);
+        if (menuJson == null || menuJson.trim().isEmpty()) return result;
+        try {
+            com.fasterxml.jackson.databind.ObjectMapper om = new com.fasterxml.jackson.databind.ObjectMapper();
+            com.fasterxml.jackson.databind.JsonNode root = om.readTree(menuJson);
+            com.fasterxml.jackson.databind.JsonNode mealsNode = root.get("meals");
+            if (mealsNode != null && mealsNode.isArray()) {
+                for (com.fasterxml.jackson.databind.JsonNode mealNode : mealsNode) {
+                    Map<String, Object> meal = new LinkedHashMap<>();
+                    meal.put("type", mealNode.has("type") ? mealNode.get("type").asText() : "lunch");
+                    meal.put("name", mealNode.has("name") ? mealNode.get("name").asText() : "");
+                    meal.put("participants", participantCount > 0 ? participantCount : 1);
+                    List<Map<String, Object>> dishes = new ArrayList<>();
+                    com.fasterxml.jackson.databind.JsonNode dishesNode = mealNode.get("dishes");
+                    if (dishesNode != null && dishesNode.isArray()) {
+                        for (com.fasterxml.jackson.databind.JsonNode dishNode : dishesNode) {
+                            Map<String, Object> dish = new LinkedHashMap<>();
+                            dish.put("name", dishNode.has("name") ? dishNode.get("name").asText() : "");
+                            com.fasterxml.jackson.databind.JsonNode nutrition = dishNode.get("nutrition");
+                            dish.put("calories", nutrition != null && nutrition.has("calories") ? nutrition.get("calories").asInt() : 0);
+                            List<Map<String, Object>> ingredients = new ArrayList<>();
+                            com.fasterxml.jackson.databind.JsonNode ingNode = dishNode.get("ingredients");
+                            if (ingNode != null && ingNode.isArray()) {
+                                for (com.fasterxml.jackson.databind.JsonNode ing : ingNode) {
+                                    Map<String, Object> item = new LinkedHashMap<>();
+                                    item.put("name", ing.has("name") ? ing.get("name").asText() : "");
+                                    item.put("amount", ing.has("grams") ? ing.get("grams").asInt() : 0);
+                                    ingredients.add(item);
+                                }
+                            }
+                            dish.put("ingredients", ingredients);
+                            dish.put("method", dishNode.has("cooking_method") ? dishNode.get("cooking_method").asText() : "");
+                            dishes.add(dish);
+                        }
+                    }
+                    meal.put("dishes", dishes);
+                    meals.add(meal);
+                }
+            }
+        } catch (Exception e) {
+            log.warn("归一化菜单失败: {}", e.getMessage());
+        }
+        return result;
+    }
+
+    /** 聚合营养汇总(目前聚合热量,其余字段置 0,供前端 4 宫格渲染) */
+    private Map<String, Object> aggregateNutrition(Map<String, Object> normalized) {
+        Map<String, Object> summary = new LinkedHashMap<>();
+        int calories = 0;
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> meals = (List<Map<String, Object>>) normalized.get("meals");
+        if (meals != null) {
+            for (Map<String, Object> meal : meals) {
+                @SuppressWarnings("unchecked")
+                List<Map<String, Object>> dishes = (List<Map<String, Object>>) meal.get("dishes");
+                if (dishes == null) continue;
+                for (Map<String, Object> dish : dishes) {
+                    Object cal = dish.get("calories");
+                    if (cal instanceof Number) calories += ((Number) cal).intValue();
+                }
+            }
+        }
+        summary.put("calories", calories);
+        summary.put("protein", 0);
+        summary.put("carbs", 0);
+        summary.put("fat", 0);
+        return summary;
+    }
+
+    /** AI 不可用时的规则降级:用推荐食材拼出三餐占位菜单 */
+    private String buildFallbackMenu(Long familyId, LocalDate date, int participantCount) {
+        List<com.etotem.cfc.dto.IngredientRecommendation> ingredients =
+                beijingNutritionService.generateIngredientList(familyId, date);
+        String[] mealTypes = {"breakfast", "lunch", "dinner"};
+        String[] mealNames = {"早餐", "午餐", "晚餐"};
+        Map<String, Object> root = new LinkedHashMap<>();
+        List<Map<String, Object>> meals = new ArrayList<>();
+        for (int i = 0; i < mealTypes.length; i++) {
+            Map<String, Object> meal = new LinkedHashMap<>();
+            meal.put("type", mealTypes[i]);
+            meal.put("name", mealNames[i]);
+            meal.put("participants", participantCount > 0 ? participantCount : 1);
+            List<Map<String, Object>> dishes = new ArrayList<>();
+            Map<String, Object> dish = new LinkedHashMap<>();
+            dish.put("name", "营养餐");
+            dish.put("calories", 0);
+            List<Map<String, Object>> dishIngredients = new ArrayList<>();
+            for (int j = i * 3; j < Math.min(i * 3 + 3, ingredients.size()); j++) {
+                Map<String, Object> item = new LinkedHashMap<>();
+                item.put("name", ingredients.get(j).getName());
+                item.put("amount", 100);
+                dishIngredients.add(item);
+            }
+            dish.put("ingredients", dishIngredients);
+            dish.put("method", "建议以清淡为主");
+            dishes.add(dish);
+            meal.put("dishes", dishes);
+            meals.add(meal);
+        }
+        root.put("meals", meals);
+        return safeWrite(root);
+    }
+
+    private String safeWrite(Object obj) {
+        try {
+            return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(obj);
+        } catch (Exception e) {
+            return "{}";
+        }
+    }
 }

+ 10 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/EmotionCheckinService.java

@@ -40,6 +40,9 @@ public class EmotionCheckinService {
     @Resource
     private AIService aiService;
 
+    @Resource
+    private ChallengeService challengeService;
+
     @Transactional
     public EmotionCheckinVO createCheckin(EmotionCheckinDTO dto, Long userId) {
         FamilyMember child = familyMemberMapper.selectById(dto.getMemberId());
@@ -77,6 +80,13 @@ public class EmotionCheckinService {
             log.error("情绪风险检测失败: checkinId={}", checkin.getId(), e);
         }
 
+        // 同步闯关连续打卡进度(健康启航计划)
+        try {
+            challengeService.syncStreakProgress(dto.getMemberId());
+        } catch (Exception e) {
+            log.warn("同步闯关进度失败,不影响打卡记录: {}", e.getMessage());
+        }
+
         return toVO(checkin, child.getNickname(), false);
     }
 

+ 83 - 35
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyJoinRequestService.java

@@ -37,11 +37,16 @@ public class FamilyJoinRequestService {
     @Resource
     private FamilyInvitationService familyInvitationService;
 
+    @Resource
+    private UnlockGateService unlockGateService;
+
     /**
      * 创建家庭加入请求(待审批)
      */
     public FamilyJoinRequest createJoinRequest(Long userId, String inviteCode, String familyRole) {
         Family family;
+        Long inviterUserId = null;
+        String joinMethod = "by_code";
 
         // 支持传入 familyId(数字)作为 inviteCode
         try {
@@ -68,6 +73,9 @@ public class FamilyJoinRequestService {
                 if (family == null) {
                     throw new RuntimeException("邀请令牌无效");
                 }
+                // 审批绑定邀请人:谁分享的令牌,申请就由谁确认
+                inviterUserId = invitation.getCreatedBy();
+                joinMethod = "by_token";
             } else {
                 family = familyMapper.selectOne(
                         new LambdaQueryWrapper<Family>()
@@ -104,7 +112,8 @@ public class FamilyJoinRequestService {
         FamilyJoinRequest request = new FamilyJoinRequest();
         request.setRequesterUserId(userId);
         request.setTargetFamilyId(family.getId());
-        request.setJoinMethod("by_code");
+        request.setInviterUserId(inviterUserId);
+        request.setJoinMethod(joinMethod);
         request.setInviteRef(inviteCode.trim());
         request.setFamilyRole(familyRole);
         request.setStatus("pending");
@@ -112,14 +121,15 @@ public class FamilyJoinRequestService {
         request.setUpdatedAt(new Date());
         familyJoinRequestMapper.insert(request);
 
-        log.info("已创建家庭加入请求: userId={}, familyId={}, requestId={}", userId, family.getId(), request.getId());
+        log.info("已创建家庭加入请求: userId={}, familyId={}, inviterUserId={}, requestId={}",
+                userId, family.getId(), inviterUserId, request.getId());
         return request;
     }
 
     /**
-     * 家庭管理员审批加入请求
+     * 审批通过加入请求(审批人=邀请人,谁分享的卡谁确认;旧数据无邀请人时回退为家庭管理员)
      */
-    public void approveJoinRequest(Long requestId, Long adminUserId, String comment) {
+    public void approveJoinRequest(Long requestId, Long approverUserId, String comment) {
         FamilyJoinRequest request = familyJoinRequestMapper.selectById(requestId);
         if (request == null) {
             throw new RuntimeException("加入请求不存在");
@@ -128,16 +138,12 @@ public class FamilyJoinRequestService {
             throw new RuntimeException("该请求已处理");
         }
 
-        // 验证管理员身份
         Family family = familyMapper.selectById(request.getTargetFamilyId());
         if (family == null) {
             throw new RuntimeException("目标家庭不存在");
         }
-        User admin = userMapper.selectById(adminUserId);
-        if (admin == null || !admin.getFamilyId().equals(family.getId())
-                || !Boolean.TRUE.equals(admin.getIsFamilyAdmin())) {
-            throw new RuntimeException("只有家庭管理员才能审批");
-        }
+        // 验证审批人权限(邀请人,或旧数据兜底的家庭管理员)
+        checkApprovePermission(request, approverUserId);
 
         // 审批通过:将用户加入家庭
         User user = userMapper.selectById(request.getRequesterUserId());
@@ -159,18 +165,25 @@ public class FamilyJoinRequestService {
         }
 
         request.setStatus("approved");
-        request.setApproverUserId(adminUserId);
+        request.setApproverUserId(approverUserId);
         request.setComment(comment);
         request.setUpdatedAt(new Date());
         familyJoinRequestMapper.updateById(request);
 
-        log.info("已审批通过加入请求: requestId={}, userId={}, adminId={}", requestId, user.getId(), adminUserId);
+        // 与 /accept 直加路径保持一致:成员变更后刷新家庭通关解锁
+        try {
+            unlockGateService.refreshFamilyGates(family.getId(), user.getId());
+        } catch (Exception e) {
+            log.warn("刷新家庭通关解锁失败(不阻断审批): requestId={}, error={}", requestId, e.getMessage());
+        }
+
+        log.info("已审批通过加入请求: requestId={}, userId={}, approverId={}", requestId, user.getId(), approverUserId);
     }
 
     /**
-     * 家庭管理员拒绝加入请求
+     * 拒绝加入请求(审批人=邀请人;旧数据无邀请人时回退为家庭管理员)
      */
-    public void rejectJoinRequest(Long requestId, Long adminUserId, String comment) {
+    public void rejectJoinRequest(Long requestId, Long approverUserId, String comment) {
         FamilyJoinRequest request = familyJoinRequestMapper.selectById(requestId);
         if (request == null) {
             throw new RuntimeException("加入请求不存在");
@@ -179,37 +192,41 @@ public class FamilyJoinRequestService {
             throw new RuntimeException("该请求已处理");
         }
 
-        // 验证管理员身份
-        Family family = familyMapper.selectById(request.getTargetFamilyId());
-        if (family == null) {
-            throw new RuntimeException("目标家庭不存在");
-        }
-        User admin = userMapper.selectById(adminUserId);
-        if (admin == null || !admin.getFamilyId().equals(family.getId())
-                || !Boolean.TRUE.equals(admin.getIsFamilyAdmin())) {
-            throw new RuntimeException("只有家庭管理员才能审批");
-        }
+        // 验证审批人权限(邀请人,或旧数据兜底的家庭管理员)
+        checkApprovePermission(request, approverUserId);
 
         request.setStatus("rejected");
-        request.setApproverUserId(adminUserId);
+        request.setApproverUserId(approverUserId);
         request.setComment(comment);
         request.setUpdatedAt(new Date());
         familyJoinRequestMapper.updateById(request);
 
-        log.info("已拒绝加入请求: requestId={}, userId={}, adminId={}, comment={}",
-                requestId, request.getRequesterUserId(), adminUserId, comment);
+        log.info("已拒绝加入请求: requestId={}, userId={}, approverId={}, comment={}",
+                requestId, request.getRequesterUserId(), approverUserId, comment);
     }
 
     /**
-     * 查询家庭待审批请求列表
+     * 查询待我处理的加入申请列表:
+     * 新数据按邀请人查询(谁分享的卡,申请出现在谁的列表);
+     * 旧数据未绑定邀请人的,由目标家庭管理员兜底可见。
      */
-    public List<FamilyJoinRequestDTO> getPendingRequests(Long familyId) {
-        List<FamilyJoinRequest> requests = familyJoinRequestMapper.selectList(
-                new LambdaQueryWrapper<FamilyJoinRequest>()
-                        .eq(FamilyJoinRequest::getTargetFamilyId, familyId)
-                        .eq(FamilyJoinRequest::getStatus, "pending")
-                        .orderByDesc(FamilyJoinRequest::getCreatedAt)
-        );
+    public List<FamilyJoinRequestDTO> getPendingRequests(Long viewerUserId) {
+        User viewer = userMapper.selectById(viewerUserId);
+        if (viewer == null) {
+            throw new RuntimeException("用户不存在");
+        }
+        boolean adminFallback = Boolean.TRUE.equals(viewer.getIsFamilyAdmin()) && viewer.getFamilyId() != null;
+        LambdaQueryWrapper<FamilyJoinRequest> wrapper = new LambdaQueryWrapper<FamilyJoinRequest>()
+                .eq(FamilyJoinRequest::getStatus, "pending")
+                .and(w -> {
+                    w.eq(FamilyJoinRequest::getInviterUserId, viewerUserId);
+                    if (adminFallback) {
+                        w.or(o -> o.isNull(FamilyJoinRequest::getInviterUserId)
+                                .eq(FamilyJoinRequest::getTargetFamilyId, viewer.getFamilyId()));
+                    }
+                })
+                .orderByDesc(FamilyJoinRequest::getCreatedAt);
+        List<FamilyJoinRequest> requests = familyJoinRequestMapper.selectList(wrapper);
 
         return requests.stream().map(req -> {
             User user = userMapper.selectById(req.getRequesterUserId());
@@ -217,6 +234,8 @@ public class FamilyJoinRequestService {
             dto.setId(req.getId());
             dto.setRequesterUserId(req.getRequesterUserId());
             dto.setRequesterNickname(user != null ? user.getNickname() : "未知用户");
+            dto.setRequesterAvatar(user != null ? user.getAvatar() : "");
+            dto.setInviterUserId(req.getInviterUserId());
             dto.setInviteCode(req.getInviteRef());
             dto.setTargetFamilyId(req.getTargetFamilyId());
             Family family = familyMapper.selectById(req.getTargetFamilyId());
@@ -273,4 +292,33 @@ public class FamilyJoinRequestService {
         req.setUpdatedAt(new Date());
         familyJoinRequestMapper.updateById(req);
     }
+
+    /**
+     * 校验审批人权限:
+     * 申请绑定邀请人的 → 仅该邀请人(且仍为目标家庭成员)可处理;
+     * 未绑定邀请人的旧数据 → 回退为原家庭管理员规则。
+     */
+    private void checkApprovePermission(FamilyJoinRequest request, Long approverUserId) {
+        User approver = userMapper.selectById(approverUserId);
+        if (approver == null) {
+            throw new RuntimeException("用户不存在");
+        }
+        Family family = familyMapper.selectById(request.getTargetFamilyId());
+        if (family == null) {
+            throw new RuntimeException("目标家庭不存在");
+        }
+        boolean isMember = approver.getFamilyId() != null && approver.getFamilyId().equals(request.getTargetFamilyId());
+        boolean allowed;
+        if (request.getInviterUserId() != null) {
+            allowed = request.getInviterUserId().equals(approverUserId) && isMember;
+        } else {
+            // 旧数据兼容:未绑定邀请人时按原家庭管理员规则
+            allowed = isMember && Boolean.TRUE.equals(approver.getIsFamilyAdmin());
+        }
+        if (!allowed) {
+            throw new RuntimeException(request.getInviterUserId() != null
+                    ? "只有邀请人才能处理该申请"
+                    : "只有家庭管理员才能审批");
+        }
+    }
 }

+ 71 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberAttributeService.java

@@ -11,10 +11,14 @@ import org.springframework.stereotype.Service;
 import javax.annotation.Resource;
 import java.util.*;
 import java.util.Calendar;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @Service
 public class FamilyMemberAttributeService extends ServiceImpl<FamilyMemberAttributesMapper, FamilyMemberAttributes> {
 
+    private static final Logger log = LoggerFactory.getLogger(FamilyMemberAttributeService.class);
+
     @Resource
     private ZodiacConfigMapper zodiacConfigMapper;
 
@@ -30,6 +34,12 @@ public class FamilyMemberAttributeService extends ServiceImpl<FamilyMemberAttrib
     @Resource
     private NumSoulConfigMapper numSoulConfigMapper;
 
+    @Resource
+    private InnatePortraitConfigMapper innatePortraitConfigMapper;
+
+    @Resource
+    private NumSoulDetailConfigMapper numSoulDetailConfigMapper;
+
     // ========== Chinese Zodiac constants ==========
     private static final String[] ZODIAC_CODES = {"rat", "ox", "tiger", "rabbit", "dragon", "snake",
             "horse", "sheep", "monkey", "rooster", "dog", "pig"};
@@ -288,9 +298,54 @@ public class FamilyMemberAttributeService extends ServiceImpl<FamilyMemberAttrib
             }
         }
 
-        // --- Weighted sum: zodiac×50% + bazi×30% + blood×20% ---
-        // Weights in bps: 50% = 5000, 30% = 3000, 20% = 2000
-        Integer result = (zodiacScore * 5000 + baziScore * 3000 + bloodScore * 2000) / 10000;
+        // --- Weighted sum with configurable weights from innate_portrait_config ---
+        // 1) Read enabled sources + weights
+        List<InnatePortraitConfig> configs = innatePortraitConfigMapper.selectList(
+                new LambdaQueryWrapper<InnatePortraitConfig>().eq(InnatePortraitConfig::getEnabled, 1)
+                        .orderByAsc(InnatePortraitConfig::getSortOrder));
+        if (configs.isEmpty()) {
+            // Fallback to legacy fixed weights: zodiac 50%, bazi 30%, blood 20%
+            Integer result = (zodiacScore * 5000 + baziScore * 3000 + bloodScore * 2000) / 10000;
+            if ("mind".equals(dimensionCode)) {
+                attrs.setMindBaseScore(result);
+            } else {
+                attrs.setWisdomBaseScore(result);
+            }
+            this.updateById(attrs);
+            return result;
+        }
+
+        // 2) Build source score map (sourceType -> score)
+        Map<String, Integer> sourceScores = new LinkedHashMap<>();
+        sourceScores.put("zodiac", zodiacScore);
+        sourceScores.put("bazi", baziScore);
+        sourceScores.put("blood", bloodScore);
+        // numsoul contribution
+        Integer numsoulScore = 0;
+        if ("mind".equals(dimensionCode)) {
+            if (attrs.getBirthDatetime() != null) {
+                Calendar cal2 = Calendar.getInstance();
+                cal2.setTime(attrs.getBirthDatetime());
+                int lifePath = NumSoulCalculator.calcLifePath(cal2.get(Calendar.YEAR), cal2.get(Calendar.MONTH) + 1, cal2.get(Calendar.DAY_OF_MONTH));
+                NumSoulDetailConfig nsCfg = numSoulDetailConfigMapper.selectOne(
+                        new LambdaQueryWrapper<NumSoulDetailConfig>()
+                                .eq(NumSoulDetailConfig::getNumberType, "life_path")
+                                .eq(NumSoulDetailConfig::getNumberValue, lifePath));
+                numsoulScore = nsCfg != null && nsCfg.getMindBase() != null ? nsCfg.getMindBase() : 0;
+            }
+        }
+        sourceScores.put("numsoul", numsoulScore);
+
+        // 3) Weighted sum
+        long numerator = 0;
+        long denominator = 0;
+        for (InnatePortraitConfig cfg : configs) {
+            int weight = cfg.getWeight() != null ? cfg.getWeight() : 2500;
+            int score = sourceScores.getOrDefault(cfg.getSourceType(), 0);
+            numerator += (long) score * weight;
+            denominator += weight;
+        }
+        int result = denominator > 0 ? (int) Math.round((double) numerator / denominator) : 0;
 
         // Cache the result
         if ("mind".equals(dimensionCode)) {
@@ -484,4 +539,17 @@ public class FamilyMemberAttributeService extends ServiceImpl<FamilyMemberAttrib
         }
         return map;
     }
+
+    /**
+     * 清除所有成员的先天分缓存(权重配置变更后调用)
+     */
+    public void invalidateAllInnateScores() {
+        List<FamilyMemberAttributes> all = this.list();
+        for (FamilyMemberAttributes attr : all) {
+            if (attr.getMindBaseScore() != null) attr.setMindBaseScore(null);
+            if (attr.getWisdomBaseScore() != null) attr.setWisdomBaseScore(null);
+            this.updateById(attr);
+        }
+        log.info("已清除 {} 个成员的先天分缓存", all.size());
+    }
 }

+ 19 - 10
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java

@@ -153,15 +153,9 @@ public class FamilyMemberService {
         // 为新成员创建与所有现有成员的双向关系
         familyRelationshipService.createRelationsForNewMember(familyId, member.getId(), genLevel.getOffset());
 
-        // 发放家庭人口券(POPULATION):有账号发到成员自己,无账号发到家庭创建者
+        // 发放家庭人口券(POPULATION):发到家庭维度
         try {
-            Long targetUserId = member.getUserId();
-            if (targetUserId == null) {
-                targetUserId = user.getFamilyId() != null
-                        ? familyMapper.selectById(user.getFamilyId()).getCreatorId()
-                        : userId;
-            }
-            couponService.grantPopulationCoupons(targetUserId, member.getId());
+            couponService.grantFamilyPopulationCoupons(familyId, member.getId());
         } catch (Exception e) {
             log.error("发放家庭人口券异常: memberId={}", member.getId(), e);
         }
@@ -304,11 +298,12 @@ public class FamilyMemberService {
             familyRelationshipService.deleteRelationsForMember(memberId);
             familyMemberMapper.deleteById(memberId);
             logMemberAction(operator.getFamilyId(), memberId, "kick", member.getNickname(), null, userId);
-            // 如果关联了 User,清除 familyId
+            // 如果关联了 User:优先恢复到其自己创建的家庭否则清除 familyId
             if (member.getUserId() != null) {
                 User targetUser = userMapper.selectById(member.getUserId());
                 if (targetUser != null && operator.getFamilyId().equals(targetUser.getFamilyId())) {
-                    targetUser.setFamilyId(null);
+                    Long ownFamilyId = findOwnFamilyId(targetUser.getId());
+                    targetUser.setFamilyId(ownFamilyId);
                     targetUser.setUpdatedAt(new Date());
                     userMapper.updateById(targetUser);
                 }
@@ -324,6 +319,20 @@ public class FamilyMemberService {
         throw new RuntimeException("成员不存在");
     }
 
+    /**
+     * 查找用户自己创建的家庭ID(families.creator_id = userId),不存在返回 null
+     */
+    private Long findOwnFamilyId(Long userId) {
+        try {
+            Family own = familyMapper.selectOne(
+                    new LambdaQueryWrapper<Family>().eq(Family::getCreatorId, userId).last("LIMIT 1"));
+            return own != null ? own.getId() : null;
+        } catch (Exception e) {
+            log.warn("查找用户创建的家庭异常: userId={}", userId, e);
+            return null;
+        }
+    }
+
     /**
      * 判断成员是否可编辑
      * 规则:有关联 userId 或有 phone 且 phone 能匹配到 User 的成员不可编辑

+ 5 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyPlatformPointsService.java

@@ -214,6 +214,9 @@ public class FamilyPlatformPointsService {
         if (coupon == null) {
             throw new RuntimeException("优惠券不存在");
         }
+        if (!"ACTIVE".equals(coupon.getStatus())) {
+            throw new RuntimeException("优惠券已停用");
+        }
         Integer pointsPrice = coupon.getPointsPrice();
         if (pointsPrice == null || pointsPrice <= 0) {
             throw new RuntimeException("该优惠券不支持CF值兑换");
@@ -264,8 +267,8 @@ public class FamilyPlatformPointsService {
         spend(familyId, pointsPrice, "coupon_exchange", couponId,
               "兑换优惠券: " + (coupon.getName() != null ? coupon.getName() : "优惠券"));
 
-        // 5. 发券给操作用户
-        couponService.grant(refUserId, couponId, "CF_EXCHANGE", null, "family:" + familyId);
+        // 5. 发券给家庭
+        couponService.grantFamily(familyId, couponId, "CF_EXCHANGE", null, "family:" + familyId);
 
         // 6. 落兑换记录
         FamilyPlatformExchangeRecord record = new FamilyPlatformExchangeRecord();

+ 16 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/FinanceCheckinService.java

@@ -36,6 +36,9 @@ public class FinanceCheckinService {
     @Resource
     private SysConfigService sysConfigService;
 
+    @Resource
+    private ChallengeService challengeService;
+
     public List<FinanceCheckin> getCheckins(Long userId, Long memberId, String yearMonth) {
         LambdaQueryWrapper<FinanceCheckin> wrapper = new LambdaQueryWrapper<FinanceCheckin>()
                 .eq(FinanceCheckin::getUserId, userId);
@@ -51,7 +54,7 @@ public class FinanceCheckinService {
         return financeCheckinMapper.selectById(id);
     }
 
-    @Transactional
+@Transactional
     public FinanceCheckin createCheckin(FinanceCheckin checkin) {
         checkin.setCreatedAt(new Date());
         if (checkin.getEarnedEnergy() == null) {
@@ -86,8 +89,8 @@ public class FinanceCheckinService {
                     }
                     task.setCreatorId(checkin.getUserId());
                     task.setExecutorType("child");
-task.setExecutorId(memberId);
-        task.setChildId(memberId);
+                    task.setExecutorId(memberId);
+                    task.setChildId(memberId);
                     task.setTitle("理财打卡");
                     task.setDescription("自动生成 - 理财打卡签到");
                     task.setPoints(points);
@@ -150,6 +153,16 @@ task.setExecutorId(memberId);
             log.warn("理财打卡能量发放失败: checkinId={}, error={}", checkin.getId(), e.getMessage());
         }
 
+        // 同步闯关连续打卡进度(健康启航计划)
+        try {
+            Long memberId = checkin.getChildId();
+            if (memberId != null) {
+                challengeService.syncStreakProgress(memberId);
+            }
+        } catch (Exception e) {
+            log.warn("同步闯关进度失败,不影响打卡记录: {}", e.getMessage());
+        }
+
         return checkin;
     }
 

+ 241 - 36
cfc-backend/src/main/java/com/etotem/cfc/service/FiveDimensionSelfCheckService.java

@@ -5,8 +5,12 @@ import com.etotem.cfc.dto.SelfCheckQuestionVO;
 import com.etotem.cfc.dto.SelfCheckResultVO;
 import com.etotem.cfc.dto.SubmitSelfCheckDTO;
 import com.etotem.cfc.dto.WuxingSourcingAdviceVO;
+import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.FiveDimensionSelfCheck;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.FiveDimensionSelfCheckMapper;
+import com.etotem.cfc.entity.SelfCheckIgnore;
+import com.etotem.cfc.mapper.SelfCheckIgnoreMapper;
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import org.slf4j.Logger;
@@ -18,7 +22,9 @@ import javax.annotation.Resource;
 import java.math.BigDecimal;
 import java.math.RoundingMode;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.Date;
+import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
@@ -29,7 +35,7 @@ import com.etotem.cfc.util.SortUtil;
  * 对应书稿《终章-绘制你家的幸福全景图》15题自检问卷
  *
  * 计分:A=3分,B=2分,C=1分,D=0分,每维度 3 题满分 9 分
- * 结果解读:≥7 健康 / 4-6 留意 / ≤3 紧绷(紧绷维度触发五行相生寻源建议)
+ * 结果解读:≥7 健康 / 4-6 留意 / ≤3 紧绷(紧绷维度触发 AI 建议)
  */
 @Service
 public class FiveDimensionSelfCheckService {
@@ -39,12 +45,21 @@ public class FiveDimensionSelfCheckService {
     @Resource
     private FiveDimensionSelfCheckMapper selfCheckMapper;
 
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
     @Resource
     private WuxingSourcingService wuxingSourcingService;
 
     @Resource
     private OnboardingService onboardingService;
 
+    @Resource
+    private SelfCheckIgnoreMapper ignoreMapper;
+
+    @Resource
+    private SelfCheckAnalysisService selfCheckAnalysisService;
+
     private final ObjectMapper objectMapper = new ObjectMapper();
 
     /** 维度中文名与五行 */
@@ -63,6 +78,58 @@ public class FiveDimensionSelfCheckService {
     /** 子维度附加题库(行6维 + 心·自驱力/自我概念 + 智·成长型思维 + 富·家族传承) */
     private static final List<SelfCheckQuestionVO> SUB_DIMENSION_QUESTION_BANK = buildSubDimensionQuestionBank();
 
+    /** 合并题库:主题库(15题) + 子维度附加库(19题),用于轮换抽题 */
+    private static final List<SelfCheckQuestionVO> FULL_QUESTION_POOL = buildFullPool();
+
+    @SuppressWarnings("unchecked")
+    private static List<SelfCheckQuestionVO> buildFullPool() {
+        List<SelfCheckQuestionVO> pool = new ArrayList<>(QUESTION_BANK);
+        pool.addAll(SUB_DIMENSION_QUESTION_BANK);
+        return pool;
+    }
+
+    /** 按 dimension 分组的合并题库 Map<dimension, List<SelfCheckQuestionVO>> */
+    private Map<String, List<SelfCheckQuestionVO>> groupByDimension(List<SelfCheckQuestionVO> pool) {
+        Map<String, List<SelfCheckQuestionVO>> groups = new LinkedHashMap<>();
+        for (SelfCheckQuestionVO q : pool) {
+            String dim = q.getDimension();
+            if (dim == null) continue;
+            groups.computeIfAbsent(dim, k -> new ArrayList<>()).add(q);
+        }
+        return groups;
+    }
+
+    /** 随机抽题:从 pool 中抽 count 题,优先排除 excludeIds */
+    private List<SelfCheckQuestionVO> pickQuestions(List<SelfCheckQuestionVO> pool, List<Integer> excludeIds, int count) {
+        List<Integer> excludeSet = new ArrayList<>(excludeIds != null ? excludeIds : Collections.emptyList());
+        List<SelfCheckQuestionVO> candidates = new ArrayList<>();
+        for (SelfCheckQuestionVO q : pool) {
+            if (!excludeSet.contains(q.getId())) {
+                candidates.add(q);
+            }
+        }
+        // 洗牌
+        Collections.shuffle(candidates);
+        List<SelfCheckQuestionVO> result = new ArrayList<>();
+        int need = Math.min(count, candidates.size());
+        for (int i = 0; i < need; i++) {
+            result.add(candidates.get(i));
+        }
+        // 不足时从 excludeSet 补足(保持顺序)
+        if (result.size() < count) {
+            for (SelfCheckQuestionVO q : pool) {
+                if (result.size() >= count) break;
+                if (!excludeSet.contains(q.getId())) continue;
+                boolean alreadyIn = false;
+                for (SelfCheckQuestionVO r : result) {
+                    if (r.getId().equals(q.getId())) { alreadyIn = true; break; }
+                }
+                if (!alreadyIn) result.add(q);
+            }
+        }
+        return result;
+    }
+
     private static List<SelfCheckQuestionVO> buildQuestionBank() {
         List<SelfCheckQuestionVO> bank = new ArrayList<>();
         // 身(1-3)
@@ -223,6 +290,42 @@ public class FiveDimensionSelfCheckService {
         return QUESTION_BANK;
     }
 
+    /**
+     * 返回轮换后的题库(用于再次自检):
+     * 合并题库按维度分组,每维度随机抽 3 题,优先排除上次使用的题号。
+     * body 维度仅 3 题,若排除后不足 3 题则允许复用。
+     */
+    public List<SelfCheckQuestionVO> getQuestionsForRetake(Long userId) {
+        // 查最近一次自检的题号
+        LambdaQueryWrapper<FiveDimensionSelfCheck> wrapper = new LambdaQueryWrapper<FiveDimensionSelfCheck>()
+                .eq(FiveDimensionSelfCheck::getUserId, userId)
+                .orderByDesc(FiveDimensionSelfCheck::getCreatedAt)
+                .last("LIMIT 1");
+        SortUtil.applySort(wrapper);
+        FiveDimensionSelfCheck lastRecord = selfCheckMapper.selectOne(wrapper);
+        List<Integer> lastQuestionIds = parseQuestionIds(lastRecord != null ? lastRecord.getQuestionIdsJson() : null);
+
+        // 按维度分组抽题
+        Map<String, List<SelfCheckQuestionVO>> dimGroups = groupByDimension(FULL_QUESTION_POOL);
+        String[] dimOrder = {"body", "wisdom", "wealth", "action", "mind"};
+        List<SelfCheckQuestionVO> result = new ArrayList<>();
+        for (String dim : dimOrder) {
+            List<SelfCheckQuestionVO> pool = dimGroups.getOrDefault(dim, Collections.emptyList());
+            result.addAll(pickQuestions(pool, lastQuestionIds, 3));
+        }
+        return result;
+    }
+
+    private List<Integer> parseQuestionIds(String json) {
+        if (json == null || json.isEmpty()) return Collections.emptyList();
+        try {
+            return objectMapper.readValue(json, objectMapper.getTypeFactory().constructCollectionType(List.class, Integer.class));
+        } catch (Exception e) {
+            log.warn("解析 questionIdsJson 失败: {}", e.getMessage());
+            return Collections.emptyList();
+        }
+    }
+
     /** 获取子维度附加题库 */
     public List<SelfCheckQuestionVO> getSubDimensionQuestions() {
         return SUB_DIMENSION_QUESTION_BANK;
@@ -268,7 +371,7 @@ public class FiveDimensionSelfCheckService {
         // 保存到现有记录(复用 scoresJson,以 sub_ 前缀区分)
         FiveDimensionSelfCheck record = new FiveDimensionSelfCheck();
         record.setUserId(userId);
-        record.setFamilyMemberId(dto.getFamilyMemberId());
+        record.setFamilyMemberId(resolveFamilyMemberId(dto.getFamilyMemberId(), userId));
         record.setAnswersJson(toJson(dto.getAnswers()));
         // 合并到现有 scoresJson(保留 dimension 级 key + 新增 sub_ 前缀 key)
         try {
@@ -340,55 +443,55 @@ public class FiveDimensionSelfCheckService {
             answerMap.put(item.getQuestionId(), item.getAnswer().trim().toUpperCase());
         }
 
-        // 逐维度计分(A=3,B=2,C=1,D=0;E=跳过不计入)
+        // 按维度分组计分
+        Map<String, List<SelfCheckQuestionVO>> dimGroups = groupByDimension(FULL_QUESTION_POOL);
         List<SelfCheckResultVO.DimensionScoreVO> dimensions = new ArrayList<>();
         int totalScore = 0;
+        List<Integer> submittedQuestionIds = new ArrayList<>();
         for (Map.Entry<String, String[]> entry : DIMENSION_META.entrySet()) {
             String dim = entry.getKey();
-            List<SelfCheckQuestionVO> dimQuestions = new ArrayList<>();
-            for (SelfCheckQuestionVO qv : QUESTION_BANK) {
-                if (dim.equals(qv.getDimension())) {
-                    dimQuestions.add(qv);
-                }
-            }
+            List<SelfCheckQuestionVO> dimQuestions = dimGroups.getOrDefault(dim, Collections.emptyList());
             int sum = 0;
             int answered = 0;
             for (SelfCheckQuestionVO qv : dimQuestions) {
                 String ans = answerMap.get(qv.getId());
-                if (ans == null || "E".equals(ans)) {
-                    continue;
-                }
-                int score = scoreOf(ans);
-                sum += score;
+                if (ans == null || "E".equals(ans)) continue;
+                submittedQuestionIds.add(qv.getId());
+                sum += scoreOf(ans);
                 answered++;
             }
             if (answered == 0) {
                 throw new IllegalArgumentException("维度[" + dim + "]至少回答1题");
             }
-            // 按实际答题数折算到 9 分制(跳过1题时仍按比例折算)
             int dimScore = new BigDecimal(sum * 3.0 / answered).setScale(0, RoundingMode.HALF_UP).intValue();
-            if (dimScore > 9) {
-                dimScore = 9;
-            }
+            if (dimScore > 9) dimScore = 9;
             totalScore += dimScore;
             dimensions.add(buildDimensionScoreVO(dim, dimScore));
         }
+        Collections.sort(submittedQuestionIds);
 
-        // 生成寻源建议(仅 ≤3 紧绷维度
+        // 生成 AI 建议(AI 优先,失败降级为空建议
         Map<String, Integer> scoreMap = new LinkedHashMap<>();
         for (SelfCheckResultVO.DimensionScoreVO dvo : dimensions) {
             scoreMap.put(dvo.getDimension(), dvo.getScore());
         }
-        List<WuxingSourcingAdviceVO> advices = wuxingSourcingService.getAdvicesForLowScores(scoreMap);
+        String adviceJson = null;
+        try {
+            Map<String, Object> adviceResult = selfCheckAnalysisService.generateAdvice(userId, scoreMap, submittedQuestionIds);
+            adviceJson = (String) adviceResult.get("adviceJson");
+        } catch (Exception e) {
+            log.warn("AI 建议生成异常,使用空建议: {}", e.getMessage());
+        }
 
         // 保存记录
         FiveDimensionSelfCheck record = new FiveDimensionSelfCheck();
         record.setUserId(userId);
-        record.setFamilyMemberId(dto.getFamilyMemberId());
+        record.setFamilyMemberId(resolveFamilyMemberId(dto.getFamilyMemberId(), userId));
         record.setAnswersJson(toJson(dto.getAnswers()));
         record.setScoresJson(toJson(scoreMap));
         record.setTotalScore(totalScore);
-        record.setAdviceJson(toJson(advices));
+        record.setAdviceJson(adviceJson != null ? adviceJson : "[]");
+        record.setQuestionIdsJson(toJson(submittedQuestionIds));
         record.setCreatedAt(new Date());
         selfCheckMapper.insert(record);
 
@@ -405,7 +508,7 @@ public class FiveDimensionSelfCheckService {
         vo.setFamilyMemberId(record.getFamilyMemberId());
         vo.setDimensions(dimensions);
         vo.setTotalScore(totalScore);
-        vo.setAdvices(advices);
+        vo.setAdvices(parseAdvices(adviceJson));
         vo.setCreatedAt(record.getCreatedAt());
         return vo;
     }
@@ -439,6 +542,87 @@ public class FiveDimensionSelfCheckService {
         return result;
     }
 
+    /**
+     * 返回自检状态:是否有记录、是否可检、距下次提醒天数、上次题号
+     */
+    @SuppressWarnings("unchecked")
+    public Map<String, Object> getStatus(Long userId) {
+        Map<String, Object> result = new HashMap<>();
+        // 最近一次自检
+        LambdaQueryWrapper<FiveDimensionSelfCheck> checkWrapper = new LambdaQueryWrapper<FiveDimensionSelfCheck>()
+                .eq(FiveDimensionSelfCheck::getUserId, userId)
+                .orderByDesc(FiveDimensionSelfCheck::getCreatedAt)
+                .last("LIMIT 1");
+        SortUtil.applySort(checkWrapper);
+        FiveDimensionSelfCheck lastCheck = selfCheckMapper.selectOne(checkWrapper);
+        result.put("hasCheck", lastCheck != null);
+        result.put("lastResult", lastCheck != null ? toVO(lastCheck) : null);
+        result.put("lastQuestionIds", parseQuestionIds(lastCheck != null ? lastCheck.getQuestionIdsJson() : null));
+
+        // 最近一次忽略
+        LambdaQueryWrapper<SelfCheckIgnore> ignoreWrapper = new LambdaQueryWrapper<SelfCheckIgnore>()
+                .eq(SelfCheckIgnore::getUserId, userId)
+                .orderByDesc(SelfCheckIgnore::getCreatedAt)
+                .last("LIMIT 1");
+        SelfCheckIgnore lastIgnore = ignoreMapper.selectOne(ignoreWrapper);
+
+        // 最近决定性时间
+        Date lastDecisiveTime = null;
+        if (lastCheck != null) lastDecisiveTime = lastCheck.getCreatedAt();
+        if (lastIgnore != null && (lastDecisiveTime == null || lastIgnore.getCreatedAt().after(lastDecisiveTime))) {
+            lastDecisiveTime = lastIgnore.getCreatedAt();
+        }
+
+        if (lastDecisiveTime == null) {
+            // 从未自检/忽略
+            result.put("canCheck", true);
+            result.put("daysLeft", 0);
+        } else {
+            long diffDays = (System.currentTimeMillis() - lastDecisiveTime.getTime()) / (1000L * 60 * 60 * 24);
+            boolean canCheck = diffDays >= 15;
+            int daysLeft = canCheck ? 0 : (int) (15 - diffDays);
+            result.put("canCheck", canCheck);
+            result.put("daysLeft", daysLeft);
+        }
+        return result;
+    }
+
+    /**
+     * 记录忽略动作(幂等:15 天内重复忽略不插入)
+     */
+    public Date ignoreSelfCheck(Long userId, Long checkId) {
+        // 查最近一次忽略(15 天内)
+        LambdaQueryWrapper<SelfCheckIgnore> wrapper = new LambdaQueryWrapper<SelfCheckIgnore>()
+                .eq(SelfCheckIgnore::getUserId, userId)
+                .orderByDesc(SelfCheckIgnore::getCreatedAt)
+                .last("LIMIT 1");
+        SelfCheckIgnore lastIgnore = ignoreMapper.selectOne(wrapper);
+        if (lastIgnore != null) {
+            long diffDays = (System.currentTimeMillis() - lastIgnore.getCreatedAt().getTime()) / (1000L * 60 * 60 * 24);
+            if (diffDays < 15) {
+                return lastIgnore.getCreatedAt(); // 幂等,返回已有忽略时间
+            }
+        }
+        SelfCheckIgnore ignore = new SelfCheckIgnore();
+        ignore.setUserId(userId);
+        ignore.setCheckId(checkId);
+        ignore.setCreatedAt(new Date());
+        ignoreMapper.insert(ignore);
+        return ignore.getCreatedAt();
+    }
+
+    /**
+     * 获取自检趋势分析结果(含历史列表)
+     */
+    public Map<String, Object> getTrendAnalysis(Long userId) {
+        Map<String, Object> trend = selfCheckAnalysisService.generateTrend(userId);
+        Map<String, Object> resp = new HashMap<>();
+        resp.put("history", selfCheckAnalysisService.getRecentHistory(userId, 3));
+        resp.put("aiInsight", trend.getOrDefault("aiInsight", ""));
+        resp.put("trendSummary", trend.getOrDefault("trendSummary", ""));
+        return resp;
+    }
+
     // === 内部方法 ===
 
     private SelfCheckResultVO.DimensionScoreVO buildDimensionScoreVO(String dimension, int score) {
@@ -474,6 +658,26 @@ public class FiveDimensionSelfCheckService {
         return 0;
     }
 
+    /**
+     * 解析自检关联的家庭成员ID。
+     * familyMemberId 未传时默认解析为当前用户本人对应的家庭成员(注册时已自动创建),
+     * 保证前端"加入今日微行动"等后续联动能拿到有效的 memberId。
+     */
+    private Long resolveFamilyMemberId(Long memberId, Long userId) {
+        if (memberId != null) {
+            return memberId;
+        }
+        if (userId == null) {
+            return null;
+        }
+        FamilyMember own = familyMemberMapper.selectOne(
+                new LambdaQueryWrapper<FamilyMember>()
+                        .eq(FamilyMember::getUserId, userId)
+                        .last("LIMIT 1")
+        );
+        return own != null ? own.getId() : null;
+    }
+
     private String toJson(Object obj) {
         try {
             return objectMapper.writeValueAsString(obj);
@@ -483,6 +687,19 @@ public class FiveDimensionSelfCheckService {
         }
     }
 
+    private List<WuxingSourcingAdviceVO> parseAdvices(String adviceJson) {
+        if (adviceJson == null || adviceJson.isEmpty() || "[]".equals(adviceJson)) {
+            return new ArrayList<>();
+        }
+        try {
+            return objectMapper.readValue(adviceJson,
+                    objectMapper.getTypeFactory().constructCollectionType(List.class, WuxingSourcingAdviceVO.class));
+        } catch (Exception e) {
+            log.warn("解析 AI 建议 JSON 失败: {}", e.getMessage());
+            return new ArrayList<>();
+        }
+    }
+
     @SuppressWarnings("unchecked")
     private SelfCheckResultVO toVO(FiveDimensionSelfCheck record) {
         SelfCheckResultVO vo = new SelfCheckResultVO();
@@ -511,19 +728,7 @@ public class FiveDimensionSelfCheckService {
         vo.setDimensions(dimensions);
 
         // 解析寻源建议
-        if (record.getAdviceJson() != null) {
-            try {
-                List<WuxingSourcingAdviceVO> advices = objectMapper.readValue(
-                        record.getAdviceJson(),
-                        objectMapper.getTypeFactory().constructCollectionType(List.class, WuxingSourcingAdviceVO.class));
-                vo.setAdvices(advices);
-            } catch (Exception e) {
-                log.warn("解析adviceJson失败: {}", e.getMessage());
-                vo.setAdvices(new ArrayList<>());
-            }
-        } else {
-            vo.setAdvices(new ArrayList<>());
-        }
+        vo.setAdvices(parseAdvices(record.getAdviceJson()));
         return vo;
     }
 }

+ 49 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/GrowthPlanService.java

@@ -1,13 +1,20 @@
 package com.etotem.cfc.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.GrowthPlan;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.GrowthPlanMapper;
+import com.etotem.cfc.mapper.UserMapper;
 import org.springframework.stereotype.Service;
 
 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 com.etotem.cfc.util.SortUtil;
 
 @Service
@@ -16,6 +23,12 @@ public class GrowthPlanService {
     @Resource
     private GrowthPlanMapper growthPlanMapper;
 
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Resource
+    private UserMapper userMapper;
+
     public GrowthPlan createPlan(GrowthPlan plan) {
         if (plan.getStatus() == null) {
             plan.setStatus("active");
@@ -65,6 +78,42 @@ public class GrowthPlanService {
         return growthPlanMapper.updateById(plan) > 0;
     }
 
+    /**
+     * 成长方案列表视图:admin 查看全部,teacher 查看自己创建的方案。
+     * 组装前端列表需要的 childName(family_members → users 兜底解析)。
+     */
+    public List<Map<String, Object>> listVos(boolean isAdmin, Long teacherId) {
+        List<GrowthPlan> plans;
+        if (isAdmin) {
+            LambdaQueryWrapper<GrowthPlan> w = new LambdaQueryWrapper<>();
+            w.orderByDesc(GrowthPlan::getCreatedAt);
+            SortUtil.applySort(w);
+            plans = growthPlanMapper.selectList(w);
+        } else {
+            plans = getByTeacherId(teacherId);
+        }
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (GrowthPlan p : plans) {
+            Map<String, Object> m = new HashMap<>();
+            m.put("id", p.getId());
+            m.put("title", p.getPlanTitle());
+            m.put("childId", p.getChildId());
+            m.put("childName", resolveChildName(p.getChildId()));
+            m.put("status", p.getStatus());
+            m.put("createdAt", p.getCreatedAt());
+            result.add(m);
+        }
+        return result;
+    }
+
+    private String resolveChildName(Long childId) {
+        if (childId == null) return null;
+        FamilyMember member = familyMemberMapper.selectById(childId);
+        if (member != null && member.getNickname() != null) return member.getNickname();
+        User user = userMapper.selectById(childId);
+        return user != null ? user.getNickname() : null;
+    }
+
     public boolean reviewPlan(Long id) {
         GrowthPlan plan = growthPlanMapper.selectById(id);
         if (plan == null) return false;

+ 7 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/GrowthTaskService.java

@@ -10,6 +10,7 @@ import com.etotem.cfc.mapper.GrowthTaskLogMapper;
 import com.etotem.cfc.mapper.GrowthTaskMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
 import org.springframework.transaction.annotation.Transactional;
 
 import javax.annotation.Resource;
@@ -106,9 +107,14 @@ item.put("dimension", task.getDimension());
 
     /**
      * Update progress for a task. Auto-marks completed when progress >= targetValue.
+     * Uses REQUIRES_NEW so failures never mark the caller transaction rollback-only.
      */
-    @Transactional
+    @Transactional(propagation = Propagation.REQUIRES_NEW)
     public void updateProgress(Long userId, String taskKey, int increment) {
+        if (userId == null) {
+            // 未绑定登录账号的家庭成员(如仅建档的孩子)无成长任务进度
+            return;
+        }
         GrowthTask task = growthTaskMapper.selectOne(
                 new LambdaQueryWrapper<GrowthTask>()
                         .eq(GrowthTask::getTaskKey, taskKey)

+ 17 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java

@@ -1,6 +1,8 @@
 package com.etotem.cfc.service;
 
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.entity.HealthPlan;
+import com.etotem.cfc.entity.Task;
 import java.util.List;
 import java.util.Map;
 
@@ -9,6 +11,8 @@ public interface HealthPlanService {
     Long confirmPlan(Long planId);
     List<HealthPlan> getPlanList(Long familyId);
     HealthPlan getPlanDetail(Long planId);
+    /** 管理端跨家庭分页查询健康方案 */
+    Page<HealthPlan> adminListPlans(Long familyId, String status, String keyword, int page, int size);
     String generatePlan(Long familyId, String memberIds, String dimensions, String goal);
     String regenerateSection(Long familyId, String section, String feedback, String planJsonStr);
     /** 规划师查看待审核方案列表 */
@@ -21,4 +25,17 @@ public interface HealthPlanService {
     HealthPlan rejectPlan(Long planId, Long reviewedBy, String comment);
     /** 获取家庭可用的规划师列表(已绑定或同团队) */
     List<Map<String, Object>> getAvailableTeachers(Long familyId);
+
+    /**
+     * 报告确认入库后自动生成健康方案(status=draft,不分解任务;
+     * 用户在方案页点击「确定」后由 confirmPlan 发布并分解任务)
+     * @return 方案 ID,生成失败时返回 null
+     */
+    Long autoGenerateAndPublish(Long familyId, Long memberId, String reportType);
+
+    /** 查询方案分解后的任务列表(source_type=plan, source_id=planId) */
+    List<Task> getPlanTasks(Long planId);
+
+    /** 基于自检低分维度自动生成 draft 计划 */
+    Long generateFromSelfCheck(Long userId, Long checkId);
 }

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

@@ -19,7 +19,7 @@ import java.util.Date;
 public class HealthReportDraftService {
 
     /** 草稿有效期(毫秒) */
-    public static final long DRAFT_TTL_MS = 24L * 60 * 60 * 1000;
+    public static final long DRAFT_TTL_MS = 72L * 60 * 60 * 1000;
 
     @Resource
     private HealthReportDraftMapper draftMapper;

+ 120 - 13
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java

@@ -22,6 +22,7 @@ import com.etotem.cfc.mapper.ReportFoodSuitabilityMapper;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.dao.DuplicateKeyException;
+import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -48,6 +49,9 @@ public class HealthReportService {
 
     @Resource
     private HealthReportMapper healthReportMapper;
+    
+    @Resource
+    protected JdbcTemplate jdbcTemplate;
 
     @Resource
     private HealthIndicatorMapper healthIndicatorMapper;
@@ -196,14 +200,64 @@ public class HealthReportService {
 
     /**
      * 获取全家所有健康报告(用于方案页选择已有报告)
+     * 优先通过家庭所有成员(subject_id/user_id)反查报告,兼容报告表 family_id 未填充的历史数据;
+     * 同时补充按报告表 family_id 匹配的报告,合并去重。
      */
     public List<HealthReport> getFamilyReports(Long familyId) {
-        return healthReportMapper.selectList(
+        if (familyId == null) {
+            return Collections.emptyList();
+        }
+        // 1. 查家庭所有成员 id(family_members.id + user_id)
+        Set<Long> memberIds = new HashSet<>();
+        try {
+            List<FamilyMember> members = familyMemberMapper.selectList(
+                    new LambdaQueryWrapper<FamilyMember>().eq(FamilyMember::getFamilyId, familyId));
+            for (FamilyMember m : members) {
+                memberIds.add(m.getId());
+                if (m.getUserId() != null) {
+                    memberIds.add(m.getUserId());
+                }
+            }
+        } catch (Exception e) {
+            log.warn("查询家庭{}成员失败: {}", familyId, e.getMessage());
+        }
+
+        // 2. 按成员反查报告(subject_id/user_id 匹配)
+        List<HealthReport> reports = new ArrayList<>();
+        if (!memberIds.isEmpty()) {
+            reports = healthReportMapper.selectList(
+                    new LambdaQueryWrapper<HealthReport>()
+                            .and(w -> w.in(HealthReport::getSubjectId, memberIds)
+                                    .or(w2 -> w2.isNull(HealthReport::getSubjectId)
+                                            .in(HealthReport::getUserId, memberIds)))
+                            .eq(HealthReport::getStatus, "active")
+                            .orderByDesc(HealthReport::getReportDate));
+        }
+
+        // 3. 补充按报告表 family_id 匹配的报告(去重)
+        List<HealthReport> byFamily = healthReportMapper.selectList(
                 new LambdaQueryWrapper<HealthReport>()
                         .eq(HealthReport::getFamilyId, familyId)
                         .eq(HealthReport::getStatus, "active")
-                        .orderByDesc(HealthReport::getReportDate)
-        );
+                        .orderByDesc(HealthReport::getReportDate));
+        Set<Long> seen = new HashSet<>();
+        for (HealthReport r : reports) {
+            seen.add(r.getId());
+        }
+        for (HealthReport r : byFamily) {
+            if (seen.add(r.getId())) {
+                reports.add(r);
+            }
+        }
+        // 按报告日期倒序
+        reports.sort((a, b) -> {
+            java.util.Date da = a.getReportDate();
+            java.util.Date db = b.getReportDate();
+            if (da == null) return 1;
+            if (db == null) return -1;
+            return db.compareTo(da);
+        });
+        return reports;
     }
 
     /**
@@ -479,6 +533,8 @@ public class HealthReportService {
             detail.put("indicators", indicators);
 
             List<HealthGutFlora> gutFlora = getGutFloraByReportId(reportId);
+            // 读回归一化:兼容历史数据(归一化前入库的 category='菌X构成' + level='门/纲/...')
+            normalizeGutFloraRead(gutFlora);
             detail.put("gutFlora", gutFlora);
 
             List<HealthDiseaseRisk> diseaseRisks = getDiseaseRisksByReportId(reportId);
@@ -487,6 +543,21 @@ public class HealthReportService {
             // 通用展示块(blocks 为空时前端回退旧字段)
             detail.put("blocks", reportBlockService.getBlocks(report.getReportType(), reportId));
 
+            // 报告解读(前端 gut-flora-detail 读取 reportInterpretation.gutAgeAssessment)
+            Map<String, Object> interp = new HashMap<>();
+            StringBuilder sb = new StringBuilder();
+            if (report.getGutAge() != null && !report.getGutAge().isEmpty()) {
+                sb.append("您的肠道年龄为 ").append(report.getGutAge()).append(" 岁");
+            }
+            if (report.getGutType() != null && !report.getGutType().isEmpty()) {
+                if (sb.length() > 0) sb.append(",");
+                sb.append("肠道类型为 ").append(report.getGutType());
+            }
+            if (sb.length() > 0) {
+                interp.put("gutAgeAssessment", sb.toString());
+                detail.put("reportInterpretation", interp);
+            }
+
             // 编辑回填: payload_json 快照 → 嵌套 payload 结构
             if (report.getPayloadJson() != null && !report.getPayloadJson().isEmpty()) {
                 try {
@@ -1068,10 +1139,13 @@ public class HealthReportService {
     }
 
     public void bindReportToMember(Long reportId, Long subjectId) {
-        HealthReport report = healthReportMapper.selectById(reportId);
-        if (report != null) {
-            report.setSubjectId(subjectId);
-            healthReportMapper.updateById(report);
+        // 使用 JdbcTemplate 直接 UPDATE,避免 MyBatis-Plus NOT_NULL 策略导致 NULL 值不更新
+        String sql = "UPDATE health_reports SET subject_id = ? WHERE id = ?";
+        int updatedRows = jdbcTemplate.update(sql, subjectId, reportId);
+        if (updatedRows == 0) {
+            log.warn("reportId={}, subjectId={} 更新失败 - 报告不存在", reportId, subjectId);
+        } else {
+            log.info("已绑定报告 {} 到被检测人 {}", reportId, subjectId);
         }
     }
 
@@ -1378,11 +1452,42 @@ public class HealthReportService {
         gf.setPopulationLevel(pf.getPopulationLevel());
         gf.setDetectionRate(pf.getDetectionRate());
         gf.setDescription(pf.getDescription());
-        gf.setCategory(pf.getCategory());
-        gf.setLevel(pf.getLevel());
+        gf.setCategory(normalizeFloraCategory(pf.getCategory()));
+        gf.setLevel(normalizeFloraLevel(pf.getCategory(), pf.getLevel()));
         return gf;
     }
 
+    private String normalizeFloraCategory(String category) {
+        if (category == null) return null;
+        if (category.endsWith("构成")) return HealthGutFlora.CATEGORY_TAXONOMY;
+        return category;
+    }
+
+    private String normalizeFloraLevel(String category, String level) {
+        if (level == null) return null;
+        String normalized = level;
+        if ("门".equals(level)) normalized = HealthGutFlora.LEVEL_PHYLUM;
+        else if ("纲".equals(level)) normalized = HealthGutFlora.LEVEL_CLASS;
+        else if ("目".equals(level)) normalized = HealthGutFlora.LEVEL_ORDER;
+        else if ("科".equals(level)) normalized = HealthGutFlora.LEVEL_FAMILY;
+        else if ("属".equals(level)) normalized = HealthGutFlora.LEVEL_GENUS;
+        else if ("种".equals(level)) normalized = HealthGutFlora.LEVEL_SPECIES;
+        return normalized;
+    }
+
+    /**
+     * 读回归一化:兼容历史数据(归一化前入库的 category='菌X构成' + level='门/纲/...')。
+     * 仅转换,不落库(避免读操作触发写)。
+     */
+    private void normalizeGutFloraRead(List<HealthGutFlora> gutFlora) {
+        if (gutFlora == null) return;
+        for (HealthGutFlora gf : gutFlora) {
+            String rawCategory = gf.getCategory();
+            gf.setCategory(normalizeFloraCategory(rawCategory));
+            gf.setLevel(normalizeFloraLevel(rawCategory, gf.getLevel()));
+        }
+    }
+
     List<HealthDiseaseRisk> buildDiseaseRisksFromPayload(ParsedReportPayload.Payload payload) {
         List<ParsedReportPayload.DiseaseRisk> list = payload.getDiseaseRisks();
         if (list == null || list.isEmpty()) return new ArrayList<>();
@@ -1536,10 +1641,11 @@ public class HealthReportService {
                         ? item.get("bacteriaValue").toString() : null);
                 flora.setNormalRange((String) item.get("normalRange"));
                 flora.setStatus((String) item.get("status"));
-                flora.setCategory((String) item.get("category"));
+                flora.setCategory(normalizeFloraCategory((String) item.get("category")));
                 flora.setPopulationLevel((String) item.get("populationLevel"));
                 flora.setDetectionRate((String) item.get("detectionRate"));
-                flora.setLevel((String) item.get("level"));
+                flora.setLevel(normalizeFloraLevel((String) item.get("category"),
+                        (String) item.get("level")));
                 flora.setSortOrder(i);
                 healthGutFloraMapper.insert(flora);
             }
@@ -1594,8 +1700,9 @@ public class HealthReportService {
         // 7. 编辑后重新组装展示块(第一套键 Map → 标准 Payload → blocks)
         try {
             ParsedReportPayload.Payload editedPayload = reportBlockAssembler.fromMap(payload);
-            reportBlockService.save("gut_flora", reportId,
-                    reportBlockAssembler.assembleGutFlora(editedPayload));
+            String reportType = oldReport.getReportType();
+            reportBlockService.save(reportType, reportId,
+                    reportBlockAssembler.assembleWithTemplate(reportType, editedPayload));
         } catch (Exception e) {
             log.warn("编辑后刷新展示块失败 reportId={}: {}", reportId, e.getMessage());
         }

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthStatusService.java

@@ -9,4 +9,10 @@ public interface HealthStatusService {
 
     /** 保存/更新(upsert:有则更新,无则插入;filledAt 仅首次赋值) */
     HealthStatus save(Long userId, HealthStatus status);
+
+    /** 查询指定家庭成员的健康现状(memberId 为空时退化为账号级) */
+    HealthStatus getByMemberId(Long userId, Long memberId);
+
+    /** 按成员保存/更新健康现状(memberId 为空时退化为账号级) */
+    HealthStatus saveForMember(Long userId, Long memberId, HealthStatus status);
 }

+ 290 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/InnatePortraitService.java

@@ -0,0 +1,290 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.dto.InnatePortraitReportVO;
+import com.etotem.cfc.dto.InnatePortraitVO;
+import com.etotem.cfc.dto.InnateTrajectoryVO;
+import com.etotem.cfc.dto.NumSoulDetailVO;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.*;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+@Service
+public class InnatePortraitService {
+
+    private static final Logger log = LoggerFactory.getLogger(InnatePortraitService.class);
+
+    @Resource
+    private FamilyMemberAttributeService familyMemberAttributeService;
+
+    @Resource
+    private InnatePortraitConfigMapper innatePortraitConfigMapper;
+
+    @Resource
+    private InnatePortraitReportMapper innatePortraitReportMapper;
+
+    @Resource
+    private NumSoulDetailConfigMapper numSoulDetailConfigMapper;
+
+    @Resource
+    private AiGateway aiGateway;
+
+    @Resource
+    private EnergyBalanceMapper energyBalanceMapper;
+
+    @Resource
+    private EnergyDimensionMapper energyDimensionMapper;
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    public InnatePortraitVO getInnatePortrait(Long memberId, String memberType) {
+        FamilyMemberAttributes attrs = familyMemberAttributeService.getByMember(memberId, memberType);
+        if (attrs == null) {
+            return null;
+        }
+
+        InnatePortraitVO vo = new InnatePortraitVO();
+        vo.setMemberId(memberId);
+        vo.setMemberType(memberType);
+        vo.setEightCharacters(parseJsonString(attrs.getEightCharacters()));
+        vo.setWuxingElements(parseJsonInt(attrs.getWuxingElements()));
+        vo.setZodiac(attrs.getZodiac());
+        vo.setBloodType(attrs.getBloodType());
+        vo.setMindBaseScore(familyMemberAttributeService.calcInnateScore(memberId, memberType, "mind"));
+        vo.setWisdomBaseScore(familyMemberAttributeService.calcInnateScore(memberId, memberType, "wisdom"));
+
+        if (attrs.getBirthDatetime() != null) {
+            Calendar cal = Calendar.getInstance();
+            cal.setTime(attrs.getBirthDatetime());
+            vo.setNumSoul(calculateNumSoul(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH)));
+        }
+
+        vo.setSourceContributions(listSourceContributions());
+        return vo;
+    }
+
+    public NumSoulDetailVO calculateNumSoul(int year, int month, int day) {
+        NumSoulDetailVO vo = new NumSoulDetailVO();
+        int lifePath = NumSoulCalculator.calcLifePath(year, month, day);
+        int[] talent = NumSoulCalculator.calcTalent(year, month, day);
+        int birthday = NumSoulCalculator.calcBirthday(day);
+        int destiny = NumSoulCalculator.calcDestiny(year, month, day);
+
+        vo.setLifePath(lifePath);
+        vo.setTalent1(talent[0]);
+        vo.setTalent2(talent[1]);
+        vo.setBirthday(birthday);
+        vo.setDestiny(destiny);
+
+        NumSoulDetailConfig lifeCfg = getNumSoulConfig("life_path", lifePath);
+        if (lifeCfg != null) {
+            vo.setLifePathTitle(lifeCfg.getTitle());
+            vo.setLifePathKeywords(lifeCfg.getKeywords());
+            vo.setLifePathAdvice(lifeCfg.getMindAdvice());
+            vo.setColorHex(lifeCfg.getColorHex());
+        }
+        NumSoulDetailConfig talentCfg = getNumSoulConfig("talent", talent[0]);
+        if (talentCfg != null) {
+            vo.setTalentTitle(talentCfg.getTitle());
+        }
+        NumSoulDetailConfig birthdayCfg = getNumSoulConfig("birthday", birthday);
+        if (birthdayCfg != null) {
+            vo.setBirthdayTitle(birthdayCfg.getTitle());
+        }
+        NumSoulDetailConfig destinyCfg = getNumSoulConfig("destiny", destiny);
+        if (destinyCfg != null) {
+            vo.setDestinyTitle(destinyCfg.getTitle());
+        }
+        return vo;
+    }
+
+    private NumSoulDetailConfig getNumSoulConfig(String type, int value) {
+        LambdaQueryWrapper<NumSoulDetailConfig> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(NumSoulDetailConfig::getNumberType, type)
+               .eq(NumSoulDetailConfig::getNumberValue, value);
+        return numSoulDetailConfigMapper.selectOne(wrapper);
+    }
+
+    private List<Map<String, Object>> listSourceContributions() {
+        List<Map<String, Object>> list = new ArrayList<>();
+        List<InnatePortraitConfig> configs = innatePortraitConfigMapper.selectList(
+                new LambdaQueryWrapper<InnatePortraitConfig>().orderByAsc(InnatePortraitConfig::getSortOrder));
+        for (InnatePortraitConfig cfg : configs) {
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("sourceType", cfg.getSourceType());
+            item.put("sourceName", cfg.getSourceName());
+            item.put("weight", cfg.getWeight());
+            item.put("enabled", cfg.getEnabled());
+            list.add(item);
+        }
+        return list;
+    }
+
+    public InnatePortraitReportVO generateAiReading(Long memberId, String memberType, Long familyId) {
+        InnatePortraitReport report = innatePortraitReportMapper.selectOne(
+                new LambdaQueryWrapper<InnatePortraitReport>()
+                        .eq(InnatePortraitReport::getMemberId, memberId)
+                        .eq(InnatePortraitReport::getMemberType, memberType));
+        if (report != null && report.getAiReading() != null && !report.getAiReading().isEmpty()) {
+            InnatePortraitReportVO cached = new InnatePortraitReportVO();
+            cached.setPortrait(getInnatePortrait(memberId, memberType));
+            cached.setAiReading(report.getAiReading());
+            cached.setGeneratedAt(String.valueOf(report.getGeneratedAt()));
+            cached.setFromCache(true);
+            cached.setFallbackUsed(false);
+            return cached;
+        }
+
+        InnatePortraitVO portrait = getInnatePortrait(memberId, memberType);
+        if (portrait == null) {
+            return null;
+        }
+
+        String aiReading = null;
+        try {
+            Map<String, Object> result = aiGateway.generateInnateReading(portrait);
+            if (result != null && result.get("reading") != null) {
+                aiReading = (String) result.get("reading");
+            }
+        } catch (Exception e) {
+            log.warn("AI解读生成失败,降级模板: {}", e.getMessage());
+        }
+
+        boolean fallbackUsed = false;
+        if (aiReading == null || aiReading.isEmpty()) {
+            aiReading = buildFallbackReading(portrait);
+            fallbackUsed = true;
+        }
+
+        InnatePortraitReport newReport = new InnatePortraitReport();
+        newReport.setMemberId(memberId);
+        newReport.setMemberType(memberType);
+        newReport.setFamilyId(familyId);
+        newReport.setPortraitJson(safeToJson(portrait));
+        newReport.setAiReading(aiReading);
+        newReport.setGeneratedAt(new Date());
+        if (report != null) {
+            newReport.setId(report.getId());
+            innatePortraitReportMapper.updateById(newReport);
+        } else {
+            innatePortraitReportMapper.insert(newReport);
+        }
+
+        InnatePortraitReportVO vo = new InnatePortraitReportVO();
+        vo.setPortrait(portrait);
+        vo.setAiReading(aiReading);
+        vo.setGeneratedAt(String.valueOf(newReport.getGeneratedAt()));
+        vo.setFromCache(false);
+        vo.setFallbackUsed(fallbackUsed);
+        return vo;
+    }
+
+    private String buildFallbackReading(InnatePortraitVO portrait) {
+        StringBuilder sb = new StringBuilder();
+        if (portrait.getZodiac() != null) {
+            sb.append("生肖").append(portrait.getZodiac()).append(",");
+        }
+        if (portrait.getNumSoul() != null && portrait.getNumSoul().getLifePath() != null) {
+            sb.append("生命灵数").append(portrait.getNumSoul().getLifePath());
+            if (portrait.getNumSoul().getLifePathTitle() != null) {
+                sb.append("(").append(portrait.getNumSoul().getLifePathTitle()).append(")");
+            }
+            sb.append(",");
+        }
+        sb.append("心先天基础分 ").append(portrait.getMindBaseScore()).append("。");
+        sb.append("建议通过情绪打卡、家庭互动与阅读持续滋养心能量。");
+        return sb.toString();
+    }
+
+    public InnateTrajectoryVO getTrajectory(Long memberId, String memberType, Long familyId) {
+        InnateTrajectoryVO vo = new InnateTrajectoryVO();
+        InnatePortraitVO portrait = getInnatePortrait(memberId, memberType);
+        if (portrait == null) {
+            return null;
+        }
+        vo.setInnatePortrait(portrait);
+
+        Map<String, Integer> innateScores = new LinkedHashMap<>();
+        innateScores.put("mind", portrait.getMindBaseScore());
+        innateScores.put("wisdom", portrait.getWisdomBaseScore());
+        vo.setInnateDimensionScores(innateScores);
+
+        Map<String, Object> currentEnergy = new LinkedHashMap<>();
+        Map<String, Object> innateVsCurrent = new LinkedHashMap<>();
+        List<EnergyDimension> allDims = energyDimensionMapper.selectList(null);
+        Integer mindCurrent = null;
+        for (EnergyDimension dim : allDims) {
+            if (dim.getStatus() != null && dim.getStatus() != 1) continue;
+            EnergyBalance balance = energyBalanceMapper.selectOne(
+                    new LambdaQueryWrapper<EnergyBalance>()
+                            .eq(EnergyBalance::getChildId, memberId)
+                            .eq(EnergyBalance::getDimensionId, dim.getId()));
+            int bal = balance != null && balance.getBalance() != null ? balance.getBalance() : 0;
+            currentEnergy.put(dim.getCode() + "Balance", bal);
+            if (balance != null && balance.getTotalEarned() != null) {
+                currentEnergy.put(dim.getCode() + "TotalEarned", balance.getTotalEarned());
+            }
+            if ("mind".equals(dim.getCode())) {
+                mindCurrent = bal;
+            }
+        }
+        vo.setCurrentEnergy(currentEnergy);
+
+        int mindInnate = portrait.getMindBaseScore() != null ? portrait.getMindBaseScore() : 0;
+        int mindCur = mindCurrent != null ? mindCurrent : 0;
+        Map<String, Object> vs = new LinkedHashMap<>();
+        vs.put("mindInnate", mindInnate);
+        vs.put("mindCurrent", mindCur);
+        vs.put("growth", mindCur >= mindInnate ? "+" + (mindCur - mindInnate) : String.valueOf(mindCur - mindInnate));
+        vo.setInnateVsCurrent(vs);
+
+        if (mindCur >= mindInnate) {
+            vo.setGrowthTrend("先天心能量基础分 " + mindInnate + ",后天通过情绪打卡/家庭互动成长至 " + mindCur + ",持续向好。");
+        } else {
+            vo.setGrowthTrend("先天心能量基础分 " + mindInnate + ",当前能量 " + mindCur + ",建议增加情绪打卡与家庭共处时间。");
+        }
+        vo.setAdvice("保持情绪打卡频率,增加家庭共处时间,通过阅读与亲子对话持续滋养心能量。");
+        return vo;
+    }
+
+    private Map<String, String> parseJsonString(String json) {
+        Map<String, Object> raw = parseJson(json);
+        Map<String, String> result = new LinkedHashMap<>();
+        for (Map.Entry<String, Object> e : raw.entrySet()) {
+            result.put(e.getKey(), String.valueOf(e.getValue()));
+        }
+        return result;
+    }
+
+    private Map<String, Object> parseJson(String json) {
+        if (json == null || json.isEmpty()) return new HashMap<>();
+        try {
+            return objectMapper.readValue(json, Map.class);
+        } catch (Exception e) {
+            return new HashMap<>();
+        }
+    }
+
+    private Map<String, Integer> parseJsonInt(String json) {
+        Map<String, Object> raw = parseJson(json);
+        Map<String, Integer> result = new LinkedHashMap<>();
+        for (Map.Entry<String, Object> e : raw.entrySet()) {
+            result.put(e.getKey(), e.getValue() instanceof Number ? ((Number) e.getValue()).intValue() : 0);
+        }
+        return result;
+    }
+
+    private String safeToJson(Object obj) {
+        try {
+            return objectMapper.writeValueAsString(obj);
+        } catch (Exception e) {
+            return "{}";
+        }
+    }
+}

+ 215 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java

@@ -10,13 +10,16 @@ import com.etotem.cfc.mapper.*;
 import com.fasterxml.jackson.core.type.TypeReference;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.Lazy;
 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.UUID;
 import java.util.stream.Collectors;
 
@@ -52,6 +55,7 @@ public class MembershipService implements MembershipServiceInterface {
     private MembershipBenefitsMapper benefitsMapper;
 
     @Resource
+    @Lazy
     private CouponService couponService;
 
     @Resource
@@ -60,6 +64,16 @@ public class MembershipService implements MembershipServiceInterface {
     @Resource
     private VirtualGoodsConfigService virtualGoodsConfigService;
 
+    // @Lazy 打破 MembershipService ↔ VirtualPayService 循环依赖:
+    // MembershipService.createOrder → VirtualPayService.generateMembershipPayParams → MembershipService.getByOrderNo,
+    // 运行时才解析代理,启动期不再强制互相实例化
+    @Resource
+    @Lazy
+    private VirtualPayService virtualPayService;
+
+    @Resource
+    private PaymentService paymentService;
+
     // ==================== 基础查询 ====================
 
     /**
@@ -445,6 +459,186 @@ public class MembershipService implements MembershipServiceInterface {
         return dto;
     }
 
+    /**
+     * 创建会员订单并按 member_pay_virtual_enabled 开关决定支付方式。
+     *
+     * @param sessionKey 当前用户 sessionKey(虚拟支付签名用;普通支付时忽略)
+     * @param openid     普通支付时需要(JSAPI payer.openid);虚拟支付时可为 null
+     * @return 统一响应 map,含 payType("virtual"/"wechat"/"none")及对应字段;
+     *         虚拟支付道具未配置时返回 null(调用方转 46001)
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public Map<String, Object> createOrderWithPayType(Long userId, Long familyId, String levelCode,
+            String paymentType, String period, Long userCouponId, String sessionKey, String openid) {
+
+        // === 内联订单创建(复制 createOrder() 核心逻辑,splitEnabled 由开关控制)===
+        if (familyId == null) {
+            throw new RuntimeException("请先创建或加入家庭后再购买会员");
+        }
+        MembershipLevel level = levelMapper.selectOne(
+                new LambdaQueryWrapper<MembershipLevel>()
+                        .eq(MembershipLevel::getLevelCode, levelCode)
+                        .last("LIMIT 1"));
+        if (level == null) {
+            throw new RuntimeException("会员等级不存在");
+        }
+
+        // 根据 period 确定费用(分)— 与 createOrder() 逻辑完全一致
+        Integer amount;
+        String effectivePeriod = period != null ? period : "yearly";
+        if ("FAMILY".equals(levelCode)) {
+            effectivePeriod = "yearly";
+            String feeStr = sysConfigService.getValue("member_fee_family_yearly");
+            if (feeStr == null) { feeStr = sysConfigService.getValue("member_fee_family"); }
+            amount = feeStr != null ? Integer.parseInt(feeStr) : 131400;
+        } else if ("PROVIDER".equals(levelCode)) {
+            String configKey = "member_fee_standard_" + effectivePeriod;
+            String feeStr = sysConfigService.getValue(configKey);
+            if (feeStr == null) { feeStr = sysConfigService.getValue("member_fee_standard"); }
+            amount = feeStr != null ? Integer.parseInt(feeStr) : 1314;
+        } else if ("PREMIUM".equals(levelCode)) {
+            if ("monthly".equals(effectivePeriod)) {
+                String feeStr = sysConfigService.getValue("member_fee_premium_monthly");
+                amount = feeStr != null ? Integer.parseInt(feeStr) : 131400;
+            } else {
+                String feeStr = sysConfigService.getValue("member_fee_premium");
+                amount = feeStr != null ? Integer.parseInt(feeStr) : 1316800;
+            }
+        } else {
+            if ("monthly".equals(effectivePeriod)) {
+                amount = level.getPriceMonthly();
+            } else if ("quarterly".equals(effectivePeriod)) {
+                amount = level.getPriceQuarterly() != null ? level.getPriceQuarterly() : level.getPriceMonthly() * 3;
+            } else {
+                amount = level.getPriceYearly();
+            }
+        }
+
+        int originalAmount = amount;
+        int finalAmount = originalAmount;
+        Long appliedCouponId = null;
+
+        if (!"trial".equals(paymentType)) {
+            if (userCouponId != null) {
+                Integer discount = couponService.apply(userId, userCouponId, "MEMBERSHIP", originalAmount);
+                if (discount != null && discount > 0) {
+                    finalAmount = Math.max(0, originalAmount - discount);
+                    appliedCouponId = userCouponId;
+                }
+            }
+        } else {
+            finalAmount = 0;
+        }
+
+        // 开关判定:缺省/null/"1"=虚拟支付(拆单开启);"0"=普通支付(拆单关闭)
+        boolean virtualEnabled = !"0".equals(sysConfigService.getValue("member_pay_virtual_enabled"));
+
+        // 拆单:查 MEMBERSHIP 道具配置的 goods_price,按整倍数判断(virtualEnabled=false 时跳过)
+        String groupNo = null;
+        int totalPeriods = 1;
+        Integer splitPeriodAmount = null;
+        if (!"trial".equals(paymentType) && virtualEnabled) {
+            VirtualGoodsConfig goodsConfig = virtualGoodsConfigService.getEnabled("MEMBERSHIP", levelCode);
+            Integer goodsPrice = goodsConfig != null ? goodsConfig.getGoodsPrice() : null;
+            if (goodsPrice != null && goodsPrice > 0) {
+                if (finalAmount % goodsPrice != 0) {
+                    throw new RuntimeException("支付金额必须是道具价格的整数倍,无法支付");
+                }
+                totalPeriods = finalAmount / goodsPrice;
+                if (totalPeriods > 1) {
+                    splitPeriodAmount = goodsPrice;
+                    groupNo = OrderNoGenerator.generate("G");
+                } else {
+                    splitPeriodAmount = goodsPrice;
+                }
+            }
+        }
+
+        // 创建订单
+        PaymentOrder mainOrder = null;
+        List<PaymentOrder> allOrders = new ArrayList<>();
+        if (groupNo != null) {
+            for (int i = 1; i <= totalPeriods; i++) {
+                PaymentOrder sub = new PaymentOrder();
+                sub.setOrderNo(generateOrderNo());
+                sub.setFamilyId(familyId);
+                sub.setLevelCode(levelCode);
+                sub.setPaymentType(paymentType);
+                sub.setPeriod(effectivePeriod);
+                sub.setAmount(splitPeriodAmount);
+                sub.setUserCouponId(i == 1 ? appliedCouponId : null);
+                sub.setStatus("pending");
+                sub.setGroupNo(groupNo);
+                sub.setPeriodNo(i);
+                sub.setCreatedAt(new Date());
+                sub.setUpdatedAt(new Date());
+                paymentOrderMapper.insert(sub);
+                allOrders.add(sub);
+                if (i == 1) { mainOrder = sub; }
+            }
+        } else {
+            PaymentOrder order = new PaymentOrder();
+            order.setOrderNo(generateOrderNo());
+            order.setFamilyId(familyId);
+            order.setLevelCode(levelCode);
+            order.setPaymentType(paymentType);
+            order.setPeriod(effectivePeriod);
+            order.setAmount(finalAmount);
+            order.setUserCouponId(appliedCouponId);
+            order.setStatus("trial".equals(paymentType) ? "paid" : "pending");
+            order.setPeriodNo(1);
+            order.setCreatedAt(new Date());
+            order.setUpdatedAt(new Date());
+            paymentOrderMapper.insert(order);
+            allOrders.add(order);
+            mainOrder = order;
+        }
+
+        if ("trial".equals(paymentType)) {
+            activateTrialMembership(userId, familyId, mainOrder);
+        }
+
+        // === 构造响应 ===
+        Map<String, Object> resp = new HashMap<>();
+        resp.put("orderNo", mainOrder.getOrderNo());
+        resp.put("status", mainOrder.getStatus());
+        resp.put("levelCode", mainOrder.getLevelCode());
+        resp.put("amount", mainOrder.getAmount());
+        resp.put("groupNo", mainOrder.getGroupNo());
+        resp.put("periodNo", mainOrder.getPeriodNo());
+        resp.put("totalPeriods", totalPeriods);
+
+        // trial 单已直接开通,无需支付
+        if ("trial".equals(paymentType)) {
+            resp.put("payType", "none");
+            return resp;
+        }
+
+        if (virtualEnabled) {
+            VirtualPayParamsDTO dto = virtualPayService.generateMembershipPayParams(mainOrder.getOrderNo(), sessionKey);
+            if (dto == null) {
+                return null; // 道具未配置,调用方转 46001
+            }
+            resp.put("payType", "virtual");
+            resp.put("orderInfo", dto.getOrderInfo());
+            resp.put("extInfo", dto.getExtInfo());
+            resp.put("sign", dto.getSign());
+            resp.put("signature", dto.getSignature());
+            resp.put("env", dto.getEnv());
+            resp.put("signType", dto.getSignType());
+            resp.put("currentPeriod", dto.getCurrentPeriod());
+            return resp;
+        }
+
+        // 普通微信支付(v3 JSAPI)
+        String description = "浠艾福-" + levelCode + "会员";
+        Map<String, Object> payParams = paymentService.createWechatPrepay(
+                mainOrder.getOrderNo(), description, mainOrder.getAmount(), openid);
+        resp.put("payType", "wechat");
+        resp.put("wechatPayParams", payParams);
+        return resp;
+    }
+
     /**
      * 处理支付回调
      */
@@ -581,7 +775,7 @@ public class MembershipService implements MembershipServiceInterface {
 
             // 发放JOIN会员券(按等级匹配grantType=JOIN的券模板)
             try {
-                couponService.grantJoinCoupons(adminUserId, order.getLevelCode(), order.getOrderNo());
+                couponService.grantFamilyJoinCoupons(order.getFamilyId(), order.getLevelCode(), order.getOrderNo());
             } catch (Exception e) {
                 log.error("发放JOIN会员券异常: userId={}, levelCode={}", adminUserId, order.getLevelCode(), e);
             }
@@ -801,6 +995,25 @@ public class MembershipService implements MembershipServiceInterface {
         return userIds.stream().distinct().collect(Collectors.toList());
     }
 
+    /**
+     * 查询指定会员等级当前有效的家庭 ID 列表(PERIODIC 发券用,按家庭维度)
+     */
+    public List<Long> listActiveMemberFamilyIds(String levelCode) {
+        Date now = new Date();
+        List<FamilyMembership> memberships = membershipMapper.selectList(
+                new LambdaQueryWrapper<FamilyMembership>()
+                        .eq(FamilyMembership::getLevelCode, levelCode)
+                        .eq(FamilyMembership::getPaymentStatus, "paid")
+                        .gt(FamilyMembership::getEndDate, now));
+        List<Long> familyIds = new ArrayList<>();
+        for (FamilyMembership m : memberships) {
+            if (m.getFamilyId() != null) {
+                familyIds.add(m.getFamilyId());
+            }
+        }
+        return familyIds.stream().distinct().collect(Collectors.toList());
+    }
+
     /**
      * 应用会员折扣,返回折后价格(分)
      */
@@ -901,7 +1114,7 @@ public class MembershipService implements MembershipServiceInterface {
         memberUpgradeRecordMapper.insert(record);
         // 发放JOIN会员券(赠送会员同样享有加入赠券)
         try {
-            couponService.grantJoinCoupons(adminUserId, levelCode, sourceOrderNo);
+            couponService.grantFamilyJoinCoupons(familyId, levelCode, sourceOrderNo);
         } catch (Exception e) {
             log.error("发放JOIN会员券异常(赠送): userId={}, levelCode={}", adminUserId, levelCode, e);
         }

+ 66 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/NumSoulCalculator.java

@@ -0,0 +1,66 @@
+package com.etotem.cfc.service;
+
+import org.springframework.stereotype.Component;
+
+@Component
+public class NumSoulCalculator {
+
+    /** 主数(大师数),不化简 */
+    private static final int[] MASTER_NUMBERS = {11, 22, 33};
+
+    /**
+     * 生命灵数:年月日所有数字相加直至个位(主数11/22/33保留)
+     */
+    public static int calcLifePath(int year, int month, int day) {
+        int sum = digitSum(year) + digitSum(month) + digitSum(day);
+        return reduceToSingleOrMaster(sum);
+    }
+
+    /**
+     * 天赋数:年化简、月+日合并化简,返回两个两位数
+     * 例: 1990-05-15 -> 年=19, 日+月=5+6=11 -> [19, 11]
+     */
+    public static int[] calcTalent(int year, int month, int day) {
+        int yearNum = digitSum(year);
+        int monthDayNum = digitSum(month) + digitSum(day);
+        return new int[]{yearNum, monthDayNum};
+    }
+
+    /**
+     * 生日数:出生日化简至个位
+     */
+    public static int calcBirthday(int day) {
+        return reduceToSingleOrMaster(day);
+    }
+
+    /**
+     * 命运数(简化版,不含姓名):出生年月日总和化简
+     */
+    public static int calcDestiny(int year, int month, int day) {
+        int sum = digitSum(year) + digitSum(month) + digitSum(day);
+        return reduceToSingleOrMaster(sum);
+    }
+
+    private static int digitSum(int n) {
+        int sum = 0;
+        while (n > 0) {
+            sum += n % 10;
+            n /= 10;
+        }
+        return sum;
+    }
+
+    private static int reduceToSingleOrMaster(int n) {
+        while (n >= 10 && !isMasterNumber(n)) {
+            n = digitSum(n);
+        }
+        return n;
+    }
+
+    private static boolean isMasterNumber(int n) {
+        for (int m : MASTER_NUMBERS) {
+            if (m == n) return true;
+        }
+        return false;
+    }
+}

+ 10 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/PaymentService.java

@@ -7,6 +7,7 @@ import com.etotem.cfc.entity.PackageOrder;
 import com.etotem.cfc.entity.ProductOrder;
 import com.etotem.cfc.mapper.PackageOrderMapper;
 import com.etotem.cfc.mapper.ProductOrderMapper;
+import com.etotem.cfc.service.MembershipService;
 import com.etotem.cfc.service.ProductOrderService;
 import com.etotem.cfc.service.api.PaymentServiceInterface;
 import okhttp3.MediaType;
@@ -60,6 +61,10 @@ public class PaymentService implements PaymentServiceInterface {
     @Resource
     private ProductOrderService productOrderService;
 
+    @Lazy
+    @Resource
+    private MembershipService membershipService;
+
     @Value("${wechat.appid}")
     private String appid;
 
@@ -124,7 +129,7 @@ public class PaymentService implements PaymentServiceInterface {
         return createWechatPrepay(orderNo, description, amount, "");
     }
 
-    public void saveContactInfo(String orderNo, String name, String phone, String address) {
+    public void saveContactInfo(String orderNo, String name, String phone, String address, Long userId) {
         try {
             PackageOrder order = packageOrderMapper.selectOne(
                 new LambdaQueryWrapper<PackageOrder>().eq(PackageOrder::getOrderNo, orderNo));
@@ -147,6 +152,7 @@ public class PaymentService implements PaymentServiceInterface {
             // 同步创建ProductOrder,以便在管理端订单管理中查看
             try {
                 ProductOrder po = new ProductOrder();
+                po.setBuyerId(userId);
                 po.setOrderNo(orderNo);
                 po.setProductId(0L);
                 po.setProductName("肠道菌群基因检测");
@@ -327,7 +333,9 @@ public class PaymentService implements PaymentServiceInterface {
             log.info("微信支付通知: orderNo={}, transactionId={}", orderNo, transactionId);
 
             // Route to appropriate handler based on order prefix
-            if (orderNo != null && orderNo.startsWith("PO")) {
+            if (orderNo != null && orderNo.startsWith("ORD")) {
+                membershipService.processPaymentCallback(orderNo, transactionId, "wechat");
+            } else if (orderNo != null && orderNo.startsWith("PO")) {
                 productOrderService.handlePaymentSuccess(orderNo, transactionId);
             } else {
                 handlePaymentCallback(orderNo, transactionId, "wechat");

+ 85 - 28
cfc-backend/src/main/java/com/etotem/cfc/service/PlanActivationService.java

@@ -1,10 +1,15 @@
 package com.etotem.cfc.service;
 
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.Task;
 import com.etotem.cfc.entity.TaskPlanInstance;
+import com.etotem.cfc.entity.TaskTemplateItem;
 import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.TaskMapper;
 import com.etotem.cfc.mapper.TaskPlanInstanceMapper;
+import com.etotem.cfc.mapper.TaskTemplateItemMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -14,6 +19,7 @@ import org.springframework.transaction.annotation.Transactional;
 import javax.annotation.Resource;
 import java.util.Calendar;
 import java.util.Date;
+import java.util.List;
 
 /**
  * 方案激活服务。
@@ -33,11 +39,18 @@ public class PlanActivationService {
     @Resource
     private UserMapper userMapper;
 
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Resource
+    private TaskTemplateItemMapper templateItemMapper;
+
     /**
      * 激活方案:
      * 1. 校验状态必须为 reviewed
      * 2. 生成一条引导性任务(方案概览/首日任务)
-     * 3. 标记方案为 active
+     * 3. 若方案关联了模板包(packageId),按模板项批量生成日常任务(任务分解)
+     * 4. 标记方案为 active
      */
     @Transactional
     public void activate(Long planId, Long operatorId) {
@@ -46,34 +59,70 @@ public class PlanActivationService {
         if (!"reviewed".equals(plan.getStatus())) {
             throw new IllegalStateException("方案状态异常,需要先审核通过");
         }
-        User child = userMapper.selectById(plan.getChildId());
-        if (child == null) throw new IllegalStateException("孩子不存在");
+        // childId 兼容 user 或 family_members 两种 ID 域,任一存在即可激活
+        boolean childExists = userMapper.selectById(plan.getChildId()) != null
+                || familyMemberMapper.selectById(plan.getChildId()) != null;
+        if (!childExists) throw new IllegalStateException("孩子不存在");
 
         // 生成首日引导任务
-        Task task = new Task();
-        task.setFamilyId(plan.getFamilyId());
-        task.setChildId(plan.getChildId());
-        task.setCreatorId(operatorId);
-        task.setExecutorType("child");
-        task.setExecutorId(plan.getChildId());
-        task.setTitle("方案启动: " + plan.getName());
-        task.setDescription("请阅读并开始执行测评方案");
-        task.setPoints(2);
-        task.setCategory("成长");
-        task.setStatus("pending");
-        task.setSourceType("plan");
-        task.setSourceId(plan.getId());
-        task.setNeedReview(0);
-        task.setIsTemplate(0);
-        task.setMemberOnly(1); // B20: 方案/套餐生成的任务为会员专属
-        Calendar cal = Calendar.getInstance();
-        cal.set(Calendar.HOUR_OF_DAY, 23);
-        cal.set(Calendar.MINUTE, 59);
-        cal.set(Calendar.SECOND, 59);
-        task.setDeadline(cal.getTime());
-        task.setCreatedAt(new Date());
-        task.setUpdatedAt(new Date());
-        taskMapper.insert(task);
+        Task guideTask = new Task();
+        guideTask.setFamilyId(plan.getFamilyId());
+        guideTask.setChildId(plan.getChildId());
+        guideTask.setFamilyMemberId(plan.getChildId()); // B21: 补设 familyMemberId 供规划师统计查询
+        guideTask.setCreatorId(operatorId);
+        guideTask.setExecutorType("child");
+        guideTask.setExecutorId(plan.getChildId());
+        guideTask.setTitle("方案启动: " + plan.getName());
+        guideTask.setDescription("请阅读并开始执行测评方案");
+        guideTask.setPoints(2);
+        guideTask.setCategory("成长");
+        guideTask.setStatus("pending");
+        guideTask.setSourceType("plan");
+        guideTask.setSourceId(plan.getId());
+        guideTask.setNeedReview(0);
+        guideTask.setIsTemplate(0);
+        guideTask.setMemberOnly(1); // B20: 方案/套餐生成的任务为会员专属
+        fillDeadline(guideTask);
+        guideTask.setCreatedAt(new Date());
+        guideTask.setUpdatedAt(new Date());
+        taskMapper.insert(guideTask);
+
+        // 按模板包任务项批量生成日常任务(任务分解)
+        if (plan.getPackageId() != null) {
+            List<TaskTemplateItem> items = templateItemMapper.selectList(
+                    new LambdaQueryWrapper<TaskTemplateItem>()
+                            .eq(TaskTemplateItem::getPackageId, plan.getPackageId())
+                            .orderByAsc(TaskTemplateItem::getSortOrder));
+            int itemCount = 0;
+            for (TaskTemplateItem item : items) {
+                if (item.getTaskName() == null || item.getTaskName().isEmpty()) continue;
+                Task t = new Task();
+                t.setFamilyId(plan.getFamilyId());
+                t.setChildId(plan.getChildId());
+                t.setFamilyMemberId(plan.getChildId()); // B21: 补设 familyMemberId 供规划师统计查询
+                t.setCreatorId(operatorId);
+                t.setExecutorType("child");
+                t.setExecutorId(plan.getChildId());
+                t.setTitle(item.getTaskName());
+                t.setDescription(item.getDescription());
+                t.setPoints(item.getPoints() != null ? item.getPoints() : 2);
+                t.setCategory("成长");
+                t.setStatus("pending");
+                t.setSourceType("plan");
+                t.setSourceId(plan.getId());
+                t.setNeedReview(0);
+                t.setIsTemplate(0);
+                t.setMemberOnly(1);
+                t.setFrequency(item.getFrequency() != null ? item.getFrequency() : "daily");
+                t.setDuration(item.getDuration());
+                fillDeadline(t);
+                t.setCreatedAt(new Date());
+                t.setUpdatedAt(new Date());
+                taskMapper.insert(t);
+                itemCount++;
+            }
+            log.info("方案{}激活按模板包{}生成{}条任务", planId, plan.getPackageId(), itemCount);
+        }
 
         // 标记方案已激活
         plan.setStatus("active");
@@ -83,6 +132,14 @@ public class PlanActivationService {
         plan.setUpdatedAt(new Date());
         planMapper.updateById(plan);
 
-        log.info("方案激活 planId={}, childId={}, taskId={}", planId, plan.getChildId(), task.getId());
+        log.info("方案激活 planId={}, childId={}, taskId={}", planId, plan.getChildId(), guideTask.getId());
+    }
+
+    private void fillDeadline(Task task) {
+        Calendar cal = Calendar.getInstance();
+        cal.set(Calendar.HOUR_OF_DAY, 23);
+        cal.set(Calendar.MINUTE, 59);
+        cal.set(Calendar.SECOND, 59);
+        task.setDeadline(cal.getTime());
     }
 }

+ 9 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/PointsExchangeService.java

@@ -181,6 +181,9 @@ public class PointsExchangeService {
         if (coupon == null) {
             throw new RuntimeException("优惠券不存在");
         }
+        if (!"ACTIVE".equals(coupon.getStatus())) {
+            throw new RuntimeException("优惠券已停用");
+        }
         if (coupon.getPointsPrice() == null || coupon.getPointsPrice() <= 0) {
             throw new RuntimeException("该优惠券不支持积分兑换");
         }
@@ -272,8 +275,12 @@ public class PointsExchangeService {
             throw new RuntimeException("积分不足");
         }
 
-        // 发券 + 流水(EXCHANGE,period=null)
-        couponService.grant(userId, couponId, "EXCHANGE", null, null);
+        // 发券 + 流水(EXCHANGE,period=null)— 发到用户所在家庭
+        Long targetFamilyId = null;
+        FamilyMember targetMember = familyMemberMapper.selectById(targetFamilyMemberId);
+        if (targetMember != null) targetFamilyId = targetMember.getFamilyId();
+        if (targetFamilyId == null) throw new RuntimeException("未找到目标成员所在家庭");
+        couponService.grantFamily(targetFamilyId, couponId, "EXCHANGE", null, null);
 
         String redeemCode = UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase();
         PointsExchangeRecord record = new PointsExchangeRecord();

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików