Procházet zdrojové kódy

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

李宇 před 1 měsícem
rodič
revize
a53e0ecf42
100 změnil soubory, kde provedl 4959 přidání a 484 odebrání
  1. 363 0
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 32 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/CircleChallengeController.java
  3. 67 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/DailyFeedbackController.java
  4. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/DanAssessmentController.java
  5. 43 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/FamilyChallengeController.java
  6. 26 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/FamilyHealthController.java
  7. 39 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthAlertController.java
  8. 58 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthCircleController.java
  9. 31 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthInteractionController.java
  10. 39 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthScoreController.java
  11. 31 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthTimelineController.java
  12. 84 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/MicroActionController.java
  13. 56 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/PromotionCommissionController.java
  14. 61 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/PromotionController.java
  15. 8 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/ShareController.java
  16. 99 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminController.java
  17. 0 43
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/RelationshipTypeAdminController.java
  18. 41 14
      cfc-backend/src/main/java/com/etotem/cfc/controller/assessment/AssessmentAppointmentController.java
  19. 0 8
      cfc-backend/src/main/java/com/etotem/cfc/controller/family/FamilyMembersController.java
  20. 0 5
      cfc-backend/src/main/java/com/etotem/cfc/dto/AddFamilyMemberDTO.java
  21. 1 3
      cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyMemberInfoDTO.java
  22. 0 9
      cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyMemberVO.java
  23. 2 1
      cfc-backend/src/main/java/com/etotem/cfc/dto/HealthAlertVO.java
  24. 2 1
      cfc-backend/src/main/java/com/etotem/cfc/dto/RelationshipScoreVO.java
  25. 5 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/AssessmentAppointment.java
  26. 37 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/CircleChallenge.java
  27. 37 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/Commission.java
  28. 43 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/DailyFeedback.java
  29. 1 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/DanAssessmentExecution.java
  30. 39 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyChallenge.java
  31. 29 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyChallengeProgress.java
  32. 36 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyCommission.java
  33. 0 6
      cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyMember.java
  34. 46 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthAlert.java
  35. 31 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthCircle.java
  36. 31 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthCircleMember.java
  37. 29 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthInteraction.java
  38. 20 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthLevel.java
  39. 23 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthTimelineEvent.java
  40. 37 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/MicroAction.java
  41. 37 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/MicroActionRecord.java
  42. 29 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/PromotionBinding.java
  43. 0 43
      cfc-backend/src/main/java/com/etotem/cfc/entity/RelationshipType.java
  44. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/CircleChallengeMapper.java
  45. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/CommissionMapper.java
  46. 2 2
      cfc-backend/src/main/java/com/etotem/cfc/mapper/DailyFeedbackMapper.java
  47. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/FamilyChallengeMapper.java
  48. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/FamilyChallengeProgressMapper.java
  49. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/FamilyCommissionMapper.java
  50. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthAlertMapper.java
  51. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthCircleMapper.java
  52. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthCircleMemberMapper.java
  53. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthInteractionMapper.java
  54. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthLevelMapper.java
  55. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthTimelineEventMapper.java
  56. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/MicroActionMapper.java
  57. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/MicroActionRecordMapper.java
  58. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/PromotionBindingMapper.java
  59. 14 4
      cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentAppointmentService.java
  60. 79 0
      cfc-backend/src/main/java/com/etotem/cfc/service/CircleChallengeService.java
  61. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/service/CompatibilityService.java
  62. 129 0
      cfc-backend/src/main/java/com/etotem/cfc/service/DailyFeedbackService.java
  63. 2 1
      cfc-backend/src/main/java/com/etotem/cfc/service/DanAssessmentExecutionService.java
  64. 175 0
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyChallengeService.java
  65. 88 0
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyCommissionService.java
  66. 100 0
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyHealthScoreService.java
  67. 119 204
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java
  68. 280 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthAlertEngine.java
  69. 99 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthAlertService.java
  70. 208 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthCircleService.java
  71. 61 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthInteractionService.java
  72. 98 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthLevelService.java
  73. 0 1
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java
  74. 122 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthScoreService.java
  75. 86 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthTimelineService.java
  76. 242 0
      cfc-backend/src/main/java/com/etotem/cfc/service/MicroActionService.java
  77. 54 0
      cfc-backend/src/main/java/com/etotem/cfc/service/PromotionBindingService.java
  78. 45 0
      cfc-backend/src/main/java/com/etotem/cfc/service/PromotionCommissionService.java
  79. 10 3
      cfc-backend/src/main/java/com/etotem/cfc/service/RelationshipQualityService.java
  80. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/service/RelationshipQuestionnaireService.java
  81. 46 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ShareService.java
  82. 1 4
      cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java
  83. 9 7
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/FortuneServiceImpl.java
  84. 207 20
      cfc-backend/src/main/resources/schema.sql
  85. 13 28
      cfc-backend/src/test/java/com/etotem/cfc/controller/family/FamilyMembersControllerTest.java
  86. 9 49
      cfc-backend/src/test/java/com/etotem/cfc/integration/FamilyMemberManagementFlowTest.java
  87. 0 9
      cfc-frontend/.postcssrc.js
  88. 2 0
      cfc-frontend/components/BaseLoading.vue
  89. 116 0
      cfc-frontend/components/BodyWealthAlert.vue
  90. 70 0
      cfc-frontend/components/CircleRanking.vue
  91. 3 0
      cfc-frontend/components/ConfettiCelebration.vue
  92. 1 1
      cfc-frontend/components/ContactCard.vue
  93. 84 0
      cfc-frontend/components/DailySummary.vue
  94. 117 0
      cfc-frontend/components/FamilyChallengeCard.vue
  95. 104 0
      cfc-frontend/components/FamilyHealthRanking.vue
  96. 30 15
      cfc-frontend/components/FamilyRelationGraph.vue
  97. 68 0
      cfc-frontend/components/FeedbackToast.vue
  98. 167 0
      cfc-frontend/components/GenerationPicker.vue
  99. 87 0
      cfc-frontend/components/HealthAlertBanner.vue
  100. 102 0
      cfc-frontend/components/HealthBadge.vue

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

@@ -5387,8 +5387,304 @@ try {
     } catch (Exception e) {
         log.warn("修改users.role失败(可能已修改): " + e.getMessage());
     }
+
+    // 迁移100: assessment_appointments表添加assessor_id(测评师角色分离)
+    ensureColumn("assessment_appointments", "assessor_id",
+            "BIGINT COMMENT '测评师ID(供应商体系), 替代guideId语义'");
+    // 迁移100: dan_assessment_executions表添加appointment_id
+    ensureColumn("dan_assessment_executions", "appointment_id",
+            "BIGINT COMMENT '关联assessment_appointments.id'");
+    log.info("迁移100: 已添加assessor_id和appointment_id字段");
+
+    // 迁移101: 创建 micro_actions 表(12个预定义1秒微行动)
+    try {
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS micro_actions (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "code VARCHAR(32) NOT NULL COMMENT '行动编码: DRINK_WATER, DEEP_BREATH, ...', " +
+            "name VARCHAR(50) NOT NULL COMMENT '行动名称', " +
+            "icon VARCHAR(10) COMMENT '图标emoji', " +
+            "description VARCHAR(200) COMMENT '行动描述', " +
+            "sort_order INT DEFAULT 0 COMMENT '排序号', " +
+            "status TINYINT DEFAULT 1 COMMENT '状态: 1启用 0禁用', " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+            "UNIQUE KEY uk_code (code), " +
+            "INDEX idx_status (status)" +
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微行动定义(12个1秒健康行动)'");
+        log.info("已创建micro_actions表");
+
+        // 插入12个预定义微行动
+        jdbcTemplate.execute("INSERT IGNORE INTO micro_actions (code, name, icon, description, sort_order) VALUES " +
+            "('DRINK_WATER', '喝一杯水', '☕️', '喝一杯温开水,补充水分', 1), " +
+            "('DEEP_BREATH', '深呼吸3次', '🫁', '深吸慢呼3次,放松身心', 2), " +
+            "('STRETCH', '站起来伸懒腰', '🧘', '站起来伸个懒腰,活动筋骨', 3), " +
+            "('LOOK_FAR', '看远处10秒', '🌿', '看远处绿色植物10秒,缓解眼疲劳', 4), " +
+            "('SMILE', '微笑一下', '😊', '对自己微笑,心情会变好', 5), " +
+            "('NECK_ROLL', '转转头颈5圈', '🔄', '缓慢转动头颈5圈,放松颈椎', 6), " +
+            "('FIST_RELAX', '握拳放松3次', '✊', '用力握拳3秒后放松,重复3次', 7), " +
+            "('SIP_WATER', '喝口水润喉', '💧', '小口喝水润润喉咙', 8), " +
+            "('BLINK', '眨眨眼5次', '👁️', '快速眨眼5次,湿润眼球', 9), " +
+            "('ACUPRESSURE', '按揉合谷穴3下', '🖐️', '按揉虎口合谷穴3下,提神醒脑', 10), " +
+            "('TIPTOE', '踮脚尖5次', '⬆️', '踮起脚尖再放下,重复5次', 11), " +
+            "('SHRUG', '耸肩放松3次', '↕️', '耸肩至耳朵再放松,重复3次', 12)");
+        log.info("已初始化12个微行动");
+    } catch (Exception e) {
+        log.warn("创建micro_actions表或初始化数据失败: {}", e.getMessage());
+    }
+
+    // 迁移102: 创建 micro_action_records 表(微行动完成记录)
+    try {
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS micro_action_records (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "user_id BIGINT NOT NULL COMMENT '用户ID', " +
+            "child_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
+            "action_id BIGINT NOT NULL COMMENT '行动ID', " +
+            "completed_date DATETIME NOT NULL COMMENT '完成日期', " +
+            "earned_energy INT DEFAULT 1 COMMENT '获得能量值', " +
+            "note VARCHAR(200) COMMENT '备注', " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+            "INDEX idx_child_date (child_id, completed_date), " +
+            "INDEX idx_user_id (user_id)" +
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微行动完成记录'");
+        log.info("已创建micro_action_records表");
+    } catch (Exception e) {
+        log.warn("创建micro_action_records表失败: {}", e.getMessage());
+    }
+
+    // 迁移103: 创建 daily_feedback 表(AI/预设每日健康反馈)
+    try {
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS daily_feedback (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "user_id BIGINT NOT NULL COMMENT '用户ID', " +
+            "child_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
+            "feedback_date DATETIME NOT NULL COMMENT '反馈日期', " +
+            "feedback_type VARCHAR(20) DEFAULT 'daily' COMMENT '反馈类型: daily/weekly/milestone', " +
+            "content VARCHAR(500) NOT NULL COMMENT 'AI生成的反馈文本', " +
+            "milestone_event VARCHAR(100) COMMENT '关联的里程碑事件', " +
+            "is_read TINYINT DEFAULT 0 COMMENT '是否已读: 0未读 1已读', " +
+            "source VARCHAR(20) DEFAULT 'preset' COMMENT '来源: ai_generated / preset', " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+            "INDEX idx_child_date (child_id, feedback_date), " +
+            "INDEX idx_feedback_type (feedback_type)" +
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='每日健康反馈'");
+        log.info("已创建daily_feedback表");
+    } catch (Exception e) {
+        log.warn("创建daily_feedback表失败: {}", e.getMessage());
+    }
+
+    // 迁移104: 创建 health_alerts 表(健康行为智能预警)
+    try {
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_alerts (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "user_id BIGINT COMMENT '用户ID', " +
+            "child_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
+            "rule_code VARCHAR(10) NOT NULL COMMENT '规则编码: R001-R006', " +
+            "alert_text VARCHAR(200) NOT NULL COMMENT '预警文案', " +
+            "action_suggestion VARCHAR(200) COMMENT '行动建议', " +
+            "severity VARCHAR(10) DEFAULT 'medium' COMMENT '严重程度: high/medium/low', " +
+            "dismissed TINYINT DEFAULT 0 COMMENT '是否已关闭: 0未关闭 1已关闭', " +
+            "dismissed_at DATETIME COMMENT '关闭时间', " +
+            "alert_date DATETIME NOT NULL COMMENT '预警日期', " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+            "INDEX idx_child_date (child_id, alert_date), " +
+            "INDEX idx_rule (rule_code)" +
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康预警记录'");
+        log.info("已创建health_alerts表");
+    } catch (Exception e) {
+        log.warn("创建health_alerts表失败: {}", e.getMessage());
+    }
+
+    // 迁移105: 创建 health_level 表(身份重塑等级定义)
+    try {
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_level (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "level_num INT NOT NULL COMMENT '等级编号 1-5', " +
+            "title VARCHAR(50) NOT NULL COMMENT '称号', " +
+            "badge_icon VARCHAR(20) COMMENT '徽章图标', " +
+            "min_score INT DEFAULT 0 COMMENT '所需最低健康积分', " +
+            "max_score INT DEFAULT 99999 COMMENT '最高积分上限'" +
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康等级定义'");
+        log.info("已创建health_level表");
+    } catch (Exception e) {
+        log.warn("创建health_level表失败: {}", e.getMessage());
+    }
+
+    // 迁移106: 创建 health_timeline_event 表(健康故事时间线)
+    try {
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_timeline_event (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "user_id BIGINT COMMENT '用户ID', " +
+            "child_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
+            "event_type VARCHAR(30) NOT NULL COMMENT '事件类型: streak_7/level_2/first_report', " +
+            "event_text VARCHAR(200) NOT NULL COMMENT '事件描述', " +
+            "ref_id VARCHAR(50) COMMENT '关联ID', " +
+            "event_date DATETIME NOT NULL COMMENT '事件日期', " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+            "INDEX idx_child_date (child_id, event_date), " +
+            "INDEX idx_event_type (event_type)" +
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康时间线事件'");
+        log.info("已创建health_timeline_event表");
+    } catch (Exception e) {
+        log.warn("创建health_timeline_event表失败: {}", e.getMessage());
+    }
+
+    // 迁移107: 创建 family_challenge 表(P2-2家庭挑战)
+    try {
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_challenge (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "family_id BIGINT NOT NULL COMMENT '家庭ID', " +
+            "challenge_type VARCHAR(50) NOT NULL COMMENT 'full_checkin/sports/health_week', " +
+            "title VARCHAR(200) NOT NULL COMMENT '挑战标题', " +
+            "description TEXT COMMENT '挑战描述', " +
+            "duration_days INT DEFAULT 7 COMMENT '持续天数', " +
+            "reward_points INT DEFAULT 0 COMMENT '奖励积分', " +
+            "reward_badge VARCHAR(100) COMMENT '奖励徽章', " +
+            "start_date DATE COMMENT '开始日期', " +
+            "end_date DATE COMMENT '结束日期', " +
+            "status VARCHAR(20) DEFAULT 'active' COMMENT 'active/completed/cancelled', " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
+            "INDEX idx_family_id (family_id)" +
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭挑战'");
+        log.info("已创建family_challenge表");
+    } catch (Exception e) {
+        log.warn("创建family_challenge表失败: {}", e.getMessage());
+    }
+
+    // 迁移108: 创建 family_challenge_progress 表(P2-2挑战进度)
+    try {
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_challenge_progress (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "challenge_id BIGINT NOT NULL COMMENT '挑战ID', " +
+            "child_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
+            "progress_value INT DEFAULT 0 COMMENT '进度值', " +
+            "completed TINYINT(1) DEFAULT 0 COMMENT '是否完成', " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
+            "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', " +
+            "INDEX idx_challenge_id (challenge_id)" +
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭挑战进度'");
+        log.info("已创建family_challenge_progress表");
+    } catch (Exception e) {
+        log.warn("创建family_challenge_progress表失败: {}", e.getMessage());
+    }
+
+    // 迁移109: 创建 health_interaction 表(P2-2点赞记录)
+    try {
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_interaction (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "family_id BIGINT NOT NULL COMMENT '家庭ID', " +
+            "from_user_id BIGINT NOT NULL COMMENT '点赞用户ID', " +
+            "to_user_id BIGINT NOT NULL COMMENT '被赞用户ID', " +
+            "action_type VARCHAR(50) NOT NULL COMMENT '行为类型', " +
+            "action_id BIGINT COMMENT '关联行为记录ID', " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
+            "INDEX idx_family_action (family_id, action_type, action_id)" +
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康互动记录(点赞)'");
+        log.info("已创建health_interaction表");
+    } catch (Exception e) {
+        log.warn("创建health_interaction表失败: {}", e.getMessage());
+    }
+
+    // 迁移110: 创建 health_circle 表(P3 健康圈)
+    try {
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_circle (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "name VARCHAR(100) NOT NULL COMMENT '圈名称', " +
+            "description VARCHAR(500) COMMENT '圈描述', " +
+            "creator_family_id BIGINT COMMENT '创建家庭ID', " +
+            "invite_code VARCHAR(32) COMMENT '邀请码', " +
+            "member_limit INT DEFAULT 50 COMMENT '成员上限', " +
+            "status VARCHAR(20) DEFAULT 'active' COMMENT '状态 active/closed', " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
+            "INDEX idx_invite_code (invite_code), " +
+            "INDEX idx_creator (creator_family_id)" +
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康圈'");
+        log.info("已创建health_circle表");
+    } catch (Exception e) {
+        log.warn("创建health_circle表失败: {}", e.getMessage());
+    }
+
+    // 迁移111: 创建 health_circle_member 表(P3 健康圈成员)
+    try {
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_circle_member (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "circle_id BIGINT NOT NULL COMMENT '圈ID', " +
+            "family_id BIGINT NOT NULL COMMENT '家庭ID', " +
+            "role VARCHAR(20) DEFAULT 'member' COMMENT '角色 member/admin', " +
+            "joined_at DATETIME COMMENT '加入时间', " +
+            "left_at DATETIME COMMENT '退出时间', " +
+            "status VARCHAR(20) DEFAULT 'active' COMMENT '状态 active/inactive', " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
+            "INDEX idx_circle_id (circle_id), " +
+            "INDEX idx_family_id (family_id)" +
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康圈成员'");
+        log.info("已创建health_circle_member表");
+    } catch (Exception e) {
+        log.warn("创建health_circle_member表失败: {}", e.getMessage());
+    }
+
+    // 迁移112: 创建 circle_challenge 表(P3 圈挑战)
+    try {
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS circle_challenge (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "circle_id BIGINT NOT NULL COMMENT '圈ID', " +
+            "challenge_type VARCHAR(50) NOT NULL COMMENT '挑战类型 checkin/step/meditation', " +
+            "title VARCHAR(200) NOT NULL COMMENT '挑战标题', " +
+            "description VARCHAR(500) COMMENT '挑战描述', " +
+            "duration_days INT DEFAULT 7 COMMENT '持续天数', " +
+            "period VARCHAR(20) DEFAULT 'weekly' COMMENT '周期 weekly/monthly', " +
+            "start_date DATETIME COMMENT '开始日期', " +
+            "end_date DATETIME COMMENT '结束日期', " +
+            "status VARCHAR(20) DEFAULT 'active' COMMENT '状态 active/completed/cancelled', " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
+            "INDEX idx_circle_id (circle_id), " +
+            "INDEX idx_status (status)" +
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='圈挑战'");
+    log.info("已创建circle_challenge表");
+    } catch (Exception e) {
+        log.warn("创建circle_challenge表失败: {}", e.getMessage());
     }
 
+    // 迁移113: 创建 family_commission 表(P2 推广佣金)
+    try {
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_commission (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "promoter_family_id BIGINT NOT NULL COMMENT '推广方家庭ID', " +
+            "buyer_family_id BIGINT NOT NULL COMMENT '购买方家庭ID', " +
+            "order_id BIGINT COMMENT '关联订单ID', " +
+            "amount DECIMAL(10,2) DEFAULT 0.00 COMMENT '订单金额', " +
+            "commission_rate DECIMAL(5,2) DEFAULT 10.00 COMMENT '佣金比例%', " +
+            "commission_amount DECIMAL(10,2) DEFAULT 0.00 COMMENT '佣金金额', " +
+            "status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending/paid/cancelled', " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
+            "paid_at DATETIME COMMENT '支付时间', " +
+            "INDEX idx_promoter (promoter_family_id), " +
+            "INDEX idx_buyer (buyer_family_id), " +
+            "INDEX idx_order (order_id)" +
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭推广佣金记录'");
+        log.info("已创建family_commission表");
+    } catch (Exception e) {
+        log.warn("创建family_commission表失败: {}", e.getMessage());
+    }
+
+    // 迁移114: 创建 promotion_binding 表(P2 推广绑定)
+    try {
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS promotion_binding (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "promoter_family_id BIGINT NOT NULL COMMENT '推广方家庭ID', " +
+            "invitee_family_id BIGINT NOT NULL COMMENT '被邀请方家庭ID', " +
+            "invite_code VARCHAR(32) COMMENT '使用的邀请码', " +
+            "binding_type VARCHAR(20) DEFAULT 'direct' COMMENT 'direct/indirect', " +
+            "status VARCHAR(20) DEFAULT 'active' COMMENT 'active/inactive', " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
+            "UNIQUE KEY uk_promoter_invitee (promoter_family_id, invitee_family_id), " +
+            "INDEX idx_promoter (promoter_family_id), " +
+            "INDEX idx_invitee (invitee_family_id)" +
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='推广绑定关系'");
+        log.info("已创建promotion_binding表");
+    } catch (Exception e) {
+        log.warn("创建promotion_binding表失败: {}", e.getMessage());
+    }
+}
+
     private void runMigration82() {
         try {
             jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS ai_conversation_summary (" +
@@ -6048,4 +6344,71 @@ try {
         log.error("迁移95 Step2 失败: " + e.getMessage());
     }
     }
+
+private void runMigration97() {
+	// 迁移93: users表添加roles字段
+	ensureColumn("users", "roles", "VARCHAR(100) COMMENT '角色列表'");
+}
+
+private void runMigration96() {
+	// 迁移96: 修复assessment_appointments.status列类型为TINYINT(原库可能为VARCHAR/ENUM导致Data truncated)
+	try {
+		jdbcTemplate.execute("ALTER TABLE assessment_appointments MODIFY COLUMN status TINYINT DEFAULT 0 COMMENT '0=待确认,1=已确认,2=已完成,3=已取消,4=未到'");
+		log.info("已修复assessment_appointments.status列类型为TINYINT");
+	} catch (Exception e) {
+		log.warn("Migration 96 skipped: {}", e.getMessage());
+	}
+}
+
+private void runMigration98() {
+	// 迁移98: family_members表新增辈分字段(TASK-P0-001 辈分重构)
+	try {
+		jdbcTemplate.execute("ALTER TABLE family_members ADD COLUMN generation INT COMMENT '辈分值(0=家庭创建者,+n=向上,-n=向下)'");
+		log.info("已添加generation列到family_members表");
+	} catch (Exception e) {
+		// 列已存在,忽略错误
+	}
+	try {
+		jdbcTemplate.execute("ALTER TABLE family_members ADD COLUMN is_spouse TINYINT(1) DEFAULT 0 COMMENT '是否配偶(同辈中)'");
+		log.info("已添加is_spouse列到family_members表");
+	} catch (Exception e) {
+		// 列已存在,忽略错误
+	}
+}
+
+private void runMigration99() {
+	// 迁移99: 旧关系类型数据回填到辈分字段(TASK-P0-001 辈分重构)
+	try {
+		jdbcTemplate.execute("UPDATE family_members SET generation = 0, is_spouse = 1 WHERE relationship_type = 'spouse'");
+		jdbcTemplate.execute("UPDATE family_members SET generation = 1 WHERE relationship_type = 'parent'");
+		jdbcTemplate.execute("UPDATE family_members SET generation = -1 WHERE relationship_type = 'child'");
+		jdbcTemplate.execute("UPDATE family_members SET generation = 0 WHERE relationship_type = 'sibling'");
+		jdbcTemplate.execute("UPDATE family_members SET generation = 0 WHERE relationship_type IS NULL");
+		log.info("迁移99: 已回填generation/is_spouse旧数据");
+	} catch (Exception e) {
+		log.error("迁移99 失败: " + e.getMessage());
+	}
+}
+
+private void runMigration100() {
+	// 迁移100: 删除废弃列和关系类型配置表(TASK-P0-001 辈分重构)
+	try {
+		jdbcTemplate.execute("ALTER TABLE family_members DROP COLUMN relationship_type");
+		log.info("已删除family_members.relationship_type列");
+	} catch (Exception e) {
+		// 列已不存在,忽略
+	}
+	try {
+		jdbcTemplate.execute("ALTER TABLE family_members DROP COLUMN role_override");
+		log.info("已删除family_members.role_override列");
+	} catch (Exception e) {
+		// 列已不存在,忽略
+	}
+	try {
+		jdbcTemplate.execute("DROP TABLE IF EXISTS relationship_types");
+		log.info("已删除relationship_types表");
+	} catch (Exception e) {
+		// 表已不存在,忽略
+	}
+}
 }

+ 32 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/CircleChallengeController.java

@@ -0,0 +1,32 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.CircleChallenge;
+import com.etotem.cfc.service.CircleChallengeService;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/health/circle/challenge")
+public class CircleChallengeController {
+
+    @Resource
+    private CircleChallengeService circleChallengeService;
+
+    @PostMapping("/list")
+    public Result<List<CircleChallenge>> getList(@RequestBody Map<String, Object> params) {
+        Long circleId = params.get("circleId") != null
+                ? Long.valueOf(params.get("circleId").toString()) : null;
+        return Result.success(circleChallengeService.getActiveChallenges(circleId));
+    }
+
+    @PostMapping("/history")
+    public Result<List<CircleChallenge>> getHistory(@RequestBody Map<String, Object> params) {
+        Long circleId = params.get("circleId") != null
+                ? Long.valueOf(params.get("circleId").toString()) : null;
+        return Result.success(circleChallengeService.getChallengeHistory(circleId));
+    }
+}

+ 67 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/DailyFeedbackController.java

@@ -0,0 +1,67 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.DailyFeedback;
+import com.etotem.cfc.service.DailyFeedbackService;
+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.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 每日反馈接口 — 即时多巴胺反馈(L2: AI/预设深度反馈)
+ */
+@Tag(name = "每日反馈", description = "AI/预设每日健康反馈")
+@RestController
+@RequestMapping("/api/feedback")
+public class DailyFeedbackController {
+
+    @Resource
+    private DailyFeedbackService dailyFeedbackService;
+
+    @Operation(summary = "获取今日反馈")
+    @PostMapping("/today")
+    public Result<Map<String, Object>> today(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId) {
+        Long childId = params.get("childId") != null
+                ? Long.valueOf(params.get("childId").toString()) : null;
+        DailyFeedback feedback = dailyFeedbackService.getTodayFeedback(childId);
+
+        // Auto-generate if none exists
+        if (feedback == null && childId != null) {
+            feedback = dailyFeedbackService.generatePresetFeedback(userId, childId);
+        }
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("feedback", feedback);
+        result.put("hasFeedback", feedback != null);
+        return Result.success(result);
+    }
+
+    @Operation(summary = "标记反馈为已读")
+    @PostMapping("/read")
+    public Result<String> markAsRead(@RequestBody Map<String, Object> params) {
+        Long id = params.get("id") != null
+                ? Long.valueOf(params.get("id").toString()) : null;
+        if (id == null) {
+            return Result.error("id不能为空");
+        }
+        dailyFeedbackService.markAsRead(id);
+        return Result.success("ok");
+    }
+
+    @Operation(summary = "获取最近反馈列表")
+    @PostMapping("/recent")
+    public Result<List<DailyFeedback>> recent(@RequestBody Map<String, Object> params) {
+        Long childId = params.get("childId") != null
+                ? Long.valueOf(params.get("childId").toString()) : null;
+        int limit = params.get("limit") != null
+                ? Integer.parseInt(params.get("limit").toString()) : 7;
+        return Result.success(dailyFeedbackService.getRecentFeedbacks(childId, limit));
+    }
+}

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

@@ -174,7 +174,7 @@ public class DanAssessmentController {
         String appointmentTime = (String) body.get("appointmentTime");
         String notes = (String) body.get("notes");
         AssessmentAppointment appointment = appointmentService.createAppointment(
-                userId, childId, guideId, packageId, appointmentDate, appointmentTime, notes);
+                userId, childId, guideId, null, packageId, appointmentDate, appointmentTime, notes);
         return Result.success(appointment);
     }
 

+ 43 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/FamilyChallengeController.java

@@ -0,0 +1,43 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.FamilyChallenge;
+import com.etotem.cfc.service.FamilyChallengeService;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/health/challenge")
+public class FamilyChallengeController {
+
+    @Resource
+    private FamilyChallengeService familyChallengeService;
+
+    @PostMapping("/list")
+    public Result<List<FamilyChallenge>> getList(@RequestBody Map<String, Object> params) {
+        Long familyId = params.get("familyId") != null
+                ? Long.valueOf(params.get("familyId").toString()) : null;
+        return Result.success(familyChallengeService.getActiveChallenges(familyId));
+    }
+
+    @PostMapping("/history")
+    public Result<List<FamilyChallenge>> getHistory(@RequestBody Map<String, Object> params) {
+        Long familyId = params.get("familyId") != null
+                ? Long.valueOf(params.get("familyId").toString()) : null;
+        return Result.success(familyChallengeService.getChallengeHistory(familyId));
+    }
+
+    @PostMapping("/progress")
+    public Result<Void> updateProgress(@RequestBody Map<String, Object> params) {
+        Long challengeId = params.get("challengeId") != null
+                ? Long.valueOf(params.get("challengeId").toString()) : null;
+        Long childId = params.get("childId") != null
+                ? Long.valueOf(params.get("childId").toString()) : null;
+        int delta = params.get("delta") != null
+                ? Integer.parseInt(params.get("delta").toString()) : 0;
+        return familyChallengeService.updateProgress(challengeId, childId, delta);
+    }
+}

+ 26 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/FamilyHealthController.java

@@ -0,0 +1,26 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.FamilyHealthScoreService;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/health/family")
+public class FamilyHealthController {
+
+    @Resource
+    private FamilyHealthScoreService familyHealthScoreService;
+
+    @PostMapping("/ranking")
+    public Result<Map<String, Object>> getRanking(@RequestBody Map<String, Object> params,
+                                                   @RequestAttribute("userId") Long userId,
+                                                   @RequestAttribute("role") String role) {
+        Long familyId = params.get("familyId") != null
+                ? Long.valueOf(params.get("familyId").toString()) : null;
+        Map<String, Object> ranking = familyHealthScoreService.getFamilyRanking(familyId, userId, role);
+        return Result.success(ranking);
+    }
+}

+ 39 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthAlertController.java

@@ -0,0 +1,39 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.HealthAlert;
+import com.etotem.cfc.service.HealthAlertService;
+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 = "P1-2 健康行为智能预警")
+@RestController
+@RequestMapping("/api/health/alert")
+public class HealthAlertController {
+
+    @Resource
+    private HealthAlertService healthAlertService;
+
+    @Operation(summary = "获取活跃预警列表")
+    @PostMapping("/list")
+    public Result<List<HealthAlert>> list(@RequestBody Map<String, Object> params) {
+        Long childId = params.get("childId") != null
+                ? Long.valueOf(params.get("childId").toString()) : null;
+        return Result.success(healthAlertService.getActiveAlerts(childId));
+    }
+
+    @Operation(summary = "关闭预警")
+    @PostMapping("/dismiss")
+    public Result<String> dismiss(@RequestBody Map<String, Object> params) {
+        Long id = params.get("id") != null
+                ? Long.valueOf(params.get("id").toString()) : null;
+        if (id == null) return Result.error("id不能为空");
+        healthAlertService.dismiss(id);
+        return Result.success("ok");
+    }
+}

+ 58 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthCircleController.java

@@ -0,0 +1,58 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.HealthCircle;
+import com.etotem.cfc.service.HealthCircleService;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/health/circle")
+public class HealthCircleController {
+
+    @Resource
+    private HealthCircleService healthCircleService;
+
+    @PostMapping("/list")
+    public Result<List<HealthCircle>> list(@RequestBody Map<String, Object> params) {
+        Long familyId = params.get("familyId") != null
+                ? Long.valueOf(params.get("familyId").toString()) : null;
+        return Result.success(healthCircleService.getUserCircles(familyId));
+    }
+
+    @PostMapping("/create")
+    public Result<HealthCircle> create(@RequestBody Map<String, Object> params) {
+        String name = params.get("name") != null ? params.get("name").toString() : null;
+        String description = params.get("description") != null ? params.get("description").toString() : null;
+        Long familyId = params.get("familyId") != null
+                ? Long.valueOf(params.get("familyId").toString()) : null;
+        return healthCircleService.createCircle(name, description, familyId);
+    }
+
+    @PostMapping("/join")
+    public Result<Void> join(@RequestBody Map<String, Object> params) {
+        String inviteCode = params.get("inviteCode") != null ? params.get("inviteCode").toString() : null;
+        Long familyId = params.get("familyId") != null
+                ? Long.valueOf(params.get("familyId").toString()) : null;
+        return healthCircleService.joinCircle(inviteCode, familyId);
+    }
+
+    @PostMapping("/leave")
+    public Result<Void> leave(@RequestBody Map<String, Object> params) {
+        Long circleId = params.get("circleId") != null
+                ? Long.valueOf(params.get("circleId").toString()) : null;
+        Long familyId = params.get("familyId") != null
+                ? Long.valueOf(params.get("familyId").toString()) : null;
+        return healthCircleService.leaveCircle(circleId, familyId);
+    }
+
+    @PostMapping("/ranking")
+    public Result<Map<String, Object>> ranking(@RequestBody Map<String, Object> params) {
+        Long circleId = params.get("circleId") != null
+                ? Long.valueOf(params.get("circleId").toString()) : null;
+        return Result.success(healthCircleService.getCircleRanking(circleId));
+    }
+}

+ 31 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthInteractionController.java

@@ -0,0 +1,31 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.HealthInteractionService;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/health/interact")
+public class HealthInteractionController {
+
+    @Resource
+    private HealthInteractionService healthInteractionService;
+
+    @PostMapping("/like")
+    public Result<Void> like(@RequestBody Map<String, Object> params,
+                             @RequestAttribute("userId") Long userId,
+                             @RequestAttribute("role") String role) {
+        Long toUserId = params.get("toUserId") != null
+                ? Long.valueOf(params.get("toUserId").toString()) : null;
+        String actionType = params.get("actionType") != null
+                ? params.get("actionType").toString() : null;
+        Long actionId = params.get("actionId") != null
+                ? Long.valueOf(params.get("actionId").toString()) : null;
+        Long familyId = params.get("familyId") != null
+                ? Long.valueOf(params.get("familyId").toString()) : null;
+        return healthInteractionService.like(userId, toUserId, actionType, actionId, familyId);
+    }
+}

+ 39 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthScoreController.java

@@ -0,0 +1,39 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.HealthLevelService;
+import com.etotem.cfc.service.HealthScoreService;
+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.Map;
+
+@Tag(name = "健康积分", description = "P2-1 身份重塑系统")
+@RestController
+@RequestMapping("/api/health/score")
+public class HealthScoreController {
+
+    @Resource
+    private HealthScoreService healthScoreService;
+
+    @Resource
+    private HealthLevelService healthLevelService;
+
+    @Operation(summary = "获取成员健康积分和等级")
+    @PostMapping("/info")
+    public Result<Map<String, Object>> info(@RequestBody Map<String, Object> params) {
+        Long childId = params.get("childId") != null
+                ? Long.valueOf(params.get("childId").toString()) : null;
+        return Result.success(healthLevelService.getMemberLevelInfo(childId));
+    }
+
+    @Operation(summary = "获取连续打卡天数")
+    @PostMapping("/streak")
+    public Result<Integer> streak(@RequestBody Map<String, Object> params) {
+        Long childId = params.get("childId") != null
+                ? Long.valueOf(params.get("childId").toString()) : null;
+        return Result.success(healthScoreService.calculateStreakDays(childId));
+    }
+}

+ 31 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthTimelineController.java

@@ -0,0 +1,31 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.HealthTimelineEvent;
+import com.etotem.cfc.service.HealthTimelineService;
+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 = "P2-1 我的健康故事")
+@RestController
+@RequestMapping("/api/health/timeline")
+public class HealthTimelineController {
+
+    @Resource
+    private HealthTimelineService healthTimelineService;
+
+    @Operation(summary = "获取健康时间线")
+    @PostMapping("/list")
+    public Result<List<HealthTimelineEvent>> list(@RequestBody Map<String, Object> params) {
+        Long childId = params.get("childId") != null
+                ? Long.valueOf(params.get("childId").toString()) : null;
+        int limit = params.get("limit") != null
+                ? Integer.parseInt(params.get("limit").toString()) : 20;
+        return Result.success(healthTimelineService.getTimeline(childId, limit));
+    }
+}

+ 84 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/MicroActionController.java

@@ -0,0 +1,84 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.MicroAction;
+import com.etotem.cfc.entity.MicroActionRecord;
+import com.etotem.cfc.service.MicroActionService;
+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.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 微行动接口 — 有效启动:最小行为单元(1秒完成)
+ */
+@Tag(name = "微行动", description = "1秒微行动:健康行为最小启动单元")
+@RestController
+@RequestMapping("/api/micro-action")
+public class MicroActionController {
+
+    @Resource
+    private MicroActionService microActionService;
+
+    @Operation(summary = "获取所有微行动列表")
+    @PostMapping("/list")
+    public Result<List<MicroAction>> list() {
+        return Result.success(microActionService.getAllActions());
+    }
+
+    @Operation(summary = "获取今日推荐微行动")
+    @PostMapping("/today")
+    public Result<Map<String, Object>> today(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId) {
+        Long childId = params.get("childId") != null
+                ? Long.valueOf(params.get("childId").toString()) : null;
+        MicroAction action = microActionService.getTodayAction(childId);
+        boolean completed = childId != null && microActionService.hasCompletedToday(childId);
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("action", action);
+        result.put("completed", completed);
+        return Result.success(result);
+    }
+
+    @Operation(summary = "完成微行动")
+    @PostMapping("/complete")
+    public Result<MicroActionRecord> complete(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId) {
+        Long childId = params.get("childId") != null
+                ? Long.valueOf(params.get("childId").toString()) : null;
+        Long actionId = params.get("actionId") != null
+                ? Long.valueOf(params.get("actionId").toString()) : null;
+        if (childId == null) {
+            return Result.error("childId不能为空");
+        }
+        if (actionId == null) {
+            return Result.error("actionId不能为空");
+        }
+        try {
+            MicroActionRecord record = microActionService.completeAction(userId, childId, actionId);
+            return Result.success(record);
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @Operation(summary = "检查今日是否已完成")
+    @PostMapping("/check-today")
+    public Result<Map<String, Object>> checkToday(
+            @RequestBody Map<String, Object> params) {
+        Long childId = params.get("childId") != null
+                ? Long.valueOf(params.get("childId").toString()) : null;
+        boolean completed = childId != null && microActionService.hasCompletedToday(childId);
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("completed", completed);
+        return Result.success(result);
+    }
+}

+ 56 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/PromotionCommissionController.java

@@ -0,0 +1,56 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.Commission;
+import com.etotem.cfc.service.PromotionCommissionService;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+
+@RestController
+@RequestMapping("/api/promotion/commission")
+public class PromotionCommissionController {
+
+    @Resource
+    private PromotionCommissionService promotionCommissionService;
+
+    /**
+     * 记录佣金
+     * @param commission 佣金对象
+     * @return 是否成功
+     */
+    @PostMapping("/record")
+    public Result<Boolean> recordCommission(@RequestBody Commission commission) {
+        boolean success = promotionCommissionService.recordCommission(commission);
+        return Result.success(success);
+    }
+
+    /**
+     * 获取家庭总佣金
+     * @param request 包含familyId的请求对象
+     * @return 总佣金金额
+     */
+    @PostMapping("/family-total")
+    public Result<Integer> getFamilyTotal(@RequestBody FamilyIdRequest request) {
+        Integer total = promotionCommissionService.getFamilyTotal(request.getFamilyId());
+        return Result.success(total);
+    }
+
+    /**
+     * 简单请求对象,仅包含familyId
+     */
+    public static class FamilyIdRequest {
+        private Long familyId;
+
+        public Long getFamilyId() {
+            return familyId;
+        }
+
+        public void setFamilyId(Long familyId) {
+            this.familyId = familyId;
+        }
+    }
+}

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

@@ -0,0 +1,61 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.FamilyCommissionService;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/promotion")
+public class PromotionController {
+
+    @Resource
+    private FamilyCommissionService familyCommissionService;
+
+    @PostMapping("/bind")
+    public Result<Map<String, Object>> bind(@RequestBody Map<String, Object> params) {
+        String inviteCode = params.get("inviteCode") != null ? params.get("inviteCode").toString() : null;
+        Long promoterFamilyId = params.get("promoterFamilyId") != null
+                ? Long.valueOf(params.get("promoterFamilyId").toString()) : null;
+        if (inviteCode == null || promoterFamilyId == null) {
+            return Result.error("邀请码和推广方家庭ID不能为空");
+        }
+        var binding = familyCommissionService.bindByInviteCode(promoterFamilyId, inviteCode);
+        if (binding == null) {
+            return Result.error("绑定失败,请检查邀请码");
+        }
+        return Result.success(binding);
+    }
+
+    @PostMapping("/commission/list")
+    public Result<Map<String, Object>> commissionList(@RequestBody Map<String, Object> params) {
+        Long familyId = params.get("familyId") != null
+                ? Long.valueOf(params.get("familyId").toString()) : null;
+        int page = params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : 1;
+        int size = params.get("size") != null ? Integer.valueOf(params.get("size").toString()) : 20;
+        if (familyId == null) {
+            return Result.error("家庭ID不能为空");
+        }
+        var pageResult = familyCommissionService.getCommissionList(familyId, page, size);
+        Map<String, Object> result = new java.util.HashMap<>();
+        result.put("records", pageResult.getRecords());
+        result.put("total", pageResult.getTotal());
+        result.put("page", pageResult.getCurrent());
+        result.put("size", pageResult.getSize());
+        return Result.success(result);
+    }
+
+    @PostMapping("/bindings/list")
+    public Result<List> bindingsList(@RequestBody Map<String, Object> params) {
+        Long familyId = params.get("familyId") != null
+                ? Long.valueOf(params.get("familyId").toString()) : null;
+        if (familyId == null) {
+            return Result.error("家庭ID不能为空");
+        }
+        List bindings = familyCommissionService.getBindings(familyId);
+        return Result.success(bindings);
+    }
+}

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/ShareController.java

@@ -100,4 +100,12 @@ public class ShareController {
         Map<String, Object> stats = shareService.getShareStats(userId);
         return Result.success(stats);
     }
+
+    @Operation(summary = "获取家庭健康足迹(P3分享页)")
+    @PostMapping("/family-footprint")
+    public Result<Map<String, Object>> familyFootprint(@RequestBody Map<String, Object> params) {
+        Long familyId = params.get("familyId") != null
+                ? Long.valueOf(params.get("familyId").toString()) : null;
+        return Result.success(shareService.getFamilyFootprint(familyId));
+    }
 }

+ 99 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminController.java

@@ -63,6 +63,9 @@ public class AdminController {
     @Resource
     private MemberUpgradeRecordMapper memberUpgradeRecordMapper;
 
+    @Resource
+    private AssessmentAppointmentMapper assessmentAppointmentMapper;
+
     // 用户管理
     @PostMapping("/users")
     public Result<Page<User>> getUsers(@RequestBody Map<String, Object> params) {
@@ -978,4 +981,100 @@ public class AdminController {
                         .orderByDesc(MemberUpgradeRecord::getCreatedAt));
         return Result.success(result);
     }
+
+    // ========== 测评待分配规划师 ==========
+
+    @PostMapping("/assessment/pending-list")
+    public Result<Map<String, Object>> getPendingAssessmentList(@RequestBody Map<String, Object> params) {
+        int page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
+        int size = params.get("size") != null ? ((Number) params.get("size")).intValue() : 10;
+        Page<AssessmentAppointment> pageParam = new Page<>(page, size);
+
+        Page<AssessmentAppointment> result = assessmentAppointmentMapper.selectPage(pageParam,
+                new LambdaQueryWrapper<AssessmentAppointment>()
+                        .isNull(AssessmentAppointment::getGuideId)
+                        .orderByDesc(AssessmentAppointment::getCreatedAt));
+
+        java.util.List<Map<String, Object>> list = new java.util.ArrayList<>();
+        for (AssessmentAppointment app : result.getRecords()) {
+            Map<String, Object> item = new HashMap<>();
+            item.put("id", app.getId());
+            item.put("familyId", app.getFamilyId());
+            item.put("childId", app.getChildId());
+            item.put("appointmentDate", app.getAppointmentDate());
+            item.put("appointmentTime", app.getAppointmentTime());
+            item.put("durationMinutes", app.getDurationMinutes());
+            item.put("status", app.getStatus());
+            item.put("appointmentType", app.getAppointmentType());
+            item.put("notes", app.getNotes());
+            item.put("contactPhone", app.getContactPhone());
+            item.put("createdAt", app.getCreatedAt());
+
+            // 关联查询用户昵称
+            User user = userMapper.selectById(app.getUserId());
+            item.put("userName", user != null ? user.getNickname() : "未知用户");
+
+            // 关联查询家庭成员名称(孩子)
+            if (app.getChildId() != null) {
+                FamilyMember child = familyMemberMapper.selectById(app.getChildId());
+                item.put("childName", child != null ? child.getNickname() : "未知");
+            } else {
+                item.put("childName", "");
+            }
+
+            list.add(item);
+        }
+
+        Map<String, Object> resultMap = new HashMap<>();
+        resultMap.put("records", list);
+        resultMap.put("total", result.getTotal());
+        resultMap.put("current", result.getCurrent());
+        resultMap.put("size", result.getSize());
+        return Result.success(resultMap);
+    }
+
+    @PostMapping("/assessment/available-guides")
+    public Result<java.util.List<Map<String, Object>>> getAvailableGuides() {
+        java.util.List<User> teachers = userMapper.selectList(
+                new LambdaQueryWrapper<User>()
+                        .eq(User::getRole, "teacher")
+                        .eq(User::getTeacherStatus, "approved")
+                        .select(User::getId, User::getNickname, User::getRealName, User::getPhone, User::getTeacherNo));
+        java.util.List<Map<String, Object>> list = new java.util.ArrayList<>();
+        for (User t : teachers) {
+            Map<String, Object> item = new HashMap<>();
+            item.put("id", t.getId());
+            item.put("nickname", t.getNickname());
+            item.put("realName", t.getRealName());
+            item.put("phone", t.getPhone());
+            item.put("teacherNo", t.getTeacherNo());
+            list.add(item);
+        }
+        return Result.success(list);
+    }
+
+    @PostMapping("/assessment/assign-guide")
+    public Result<Void> assignGuide(@RequestBody Map<String, Object> body) {
+        Long appointmentId = body.get("appointmentId") != null ? Long.valueOf(body.get("appointmentId").toString()) : null;
+        Long guideId = body.get("guideId") != null ? Long.valueOf(body.get("guideId").toString()) : null;
+        if (appointmentId == null || guideId == null) {
+            return Result.error("appointmentId 和 guideId 不能为空");
+        }
+
+        AssessmentAppointment appointment = assessmentAppointmentMapper.selectById(appointmentId);
+        if (appointment == null) {
+            return Result.error("预约记录不存在");
+        }
+
+        User guide = userMapper.selectById(guideId);
+        if (guide == null || !"teacher".equals(guide.getRole())) {
+            return Result.error("规划师不存在或角色不正确");
+        }
+
+        appointment.setGuideId(guideId);
+        appointment.setTeacherId(guideId);
+        appointment.setStatus(1); // 已确认
+        assessmentAppointmentMapper.updateById(appointment);
+        return Result.success(null);
+    }
 }

+ 0 - 43
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/RelationshipTypeAdminController.java

@@ -1,43 +0,0 @@
-package com.etotem.cfc.controller.admin;
-
-import com.etotem.cfc.common.Result;
-import com.etotem.cfc.entity.RelationshipType;
-import com.etotem.cfc.service.FamilyMemberService;
-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;
-
-@Tag(name = "关系类型管理(管理员)", description = "关系类型CRUD")
-@RestController
-@RequestMapping("/api/admin/relationship-type")
-public class RelationshipTypeAdminController {
-
-    @Resource
-    private FamilyMemberService familyMemberService;
-
-    @Operation(summary = "获取所有关系类型")
-    @PostMapping("/list")
-    public Result<List<RelationshipType>> list() {
-        return Result.success(familyMemberService.listAllRelationshipTypes());
-    }
-
-    @Operation(summary = "保存关系类型(新增或更新)")
-    @PostMapping("/save")
-    public Result<RelationshipType> save(@RequestBody RelationshipType type) {
-        return Result.success(familyMemberService.saveRelationshipType(type));
-    }
-
-    @Operation(summary = "删除关系类型")
-    @PostMapping("/delete")
-    public Result<String> delete(@RequestBody java.util.Map<String, Long> body) {
-        Long id = body.get("id");
-        if (id == null) {
-            return Result.error("缺少ID");
-        }
-        familyMemberService.deleteRelationshipType(id);
-        return Result.success("删除成功");
-    }
-}

+ 41 - 14
cfc-backend/src/main/java/com/etotem/cfc/controller/assessment/AssessmentAppointmentController.java

@@ -2,13 +2,13 @@ package com.etotem.cfc.controller.assessment;
 
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.AssessmentAppointment;
-import com.etotem.cfc.entity.AssessmentExecution;
 import com.etotem.cfc.entity.AssessmentQuota;
+import com.etotem.cfc.entity.DanAssessmentExecution;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.service.AssessmentAppointmentService;
-import com.etotem.cfc.service.AssessmentExecutionService;
 import com.etotem.cfc.service.AssessmentQuotaService;
+import com.etotem.cfc.service.DanAssessmentExecutionService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.web.bind.annotation.*;
@@ -31,12 +31,12 @@ public class AssessmentAppointmentController {
     private AssessmentQuotaService quotaService;
 
     @Resource
-    private AssessmentExecutionService executionService;
+    private DanAssessmentExecutionService danAssessmentExecutionService;
 
     @Resource
     private UserMapper userMapper;
 
-    @Operation(summary = "创建评估预约")
+    @Operation(summary = "创建评估预约(含新执行记录)")
     @PostMapping("/create")
     public Result<Map<String, Object>> createAppointment(
             @RequestAttribute("userId") Long userId,
@@ -44,6 +44,7 @@ public class AssessmentAppointmentController {
 
         Long childId = body.get("childId") != null ? Long.valueOf(body.get("childId").toString()) : null;
         Long guideId = body.get("guideId") != null ? Long.valueOf(body.get("guideId").toString()) : null;
+        Long assessorId = body.get("assessorId") != null ? Long.valueOf(body.get("assessorId").toString()) : null;
         String appointmentDate = body.get("appointmentDate") != null ? body.get("appointmentDate").toString() : null;
         String appointmentTime = body.get("appointmentTime") != null ? body.get("appointmentTime").toString() : null;
         String notes = body.get("notes") != null ? body.get("notes").toString() : null;
@@ -52,6 +53,9 @@ public class AssessmentAppointmentController {
         if (childId == null || quotaId == null) {
             return Result.error("childId和quotaId不能为空");
         }
+        if (assessorId == null && guideId == null) {
+            return Result.error("assessorId或guideId必须提供一个");
+        }
 
         User user = userMapper.selectById(userId);
         if (user == null) return Result.error("用户不存在");
@@ -62,11 +66,25 @@ public class AssessmentAppointmentController {
             return Result.error("额度不足或已过期");
         }
 
-        AssessmentAppointment appointment = appointmentService.createAppointment(
-                userId, childId, guideId, null, appointmentDate, appointmentTime, notes);
+        // 获取额度信息(含 familyId, productOrderId)
+        AssessmentQuota quota = quotaService.getById(quotaId);
+        if (quota == null) return Result.error("额度记录不存在");
 
-        // 创建执行记录
-        AssessmentExecution exec = executionService.create(quotaId, childId, appointment.getId());
+        // 创建预约
+        AssessmentAppointment appointment = appointmentService.createAppointment(
+                userId, childId, guideId, assessorId, null, appointmentDate, appointmentTime, notes);
+
+        // 创建新执行记录(dan_assessment_executions,含 assessor 角色)
+        Long finalAssessorId = assessorId != null ? assessorId : guideId;
+        DanAssessmentExecution exec = danAssessmentExecutionService.createExecution(
+                quota.getProductOrderId(), // assessmentOrderId 复用 shop productOrderId
+                childId,
+                quota.getFamilyId(),
+                quotaId,
+                finalAssessorId,
+                null, // plannerId(可选)
+                appointment.getId()
+        );
 
         Map<String, Object> result = new HashMap<>();
         result.put("appointmentId", appointment.getId());
@@ -76,17 +94,16 @@ public class AssessmentAppointmentController {
         return Result.success(result);
     }
 
-    @Operation(summary = "确认预约")
+    @Operation(summary = "确认预约(由指派的测评师确认)")
     @PostMapping("/confirm/{id}")
     public Result<Boolean> confirmAppointment(
             @RequestAttribute("userId") Long userId,
-            @RequestAttribute("role") String role,
             @PathVariable Long id) {
-        if (!"teacher".equals(role)) {
-            return Result.error("只有成长规划师可以确认预约");
-        }
         boolean success = appointmentService.confirmAppointment(id, userId);
-        return Result.success(success);
+        if (!success) {
+            return Result.error("确认失败,您不是被指派的测评师");
+        }
+        return Result.success(true);
     }
 
     @Operation(summary = "取消预约")
@@ -117,11 +134,21 @@ public class AssessmentAppointmentController {
         return Result.success(result);
     }
 
+    @Operation(summary = "测评师的预约列表")
+    @PostMapping("/assessor-list")
+    public Result<List<Map<String, Object>>> getAssessorAppointments(
+            @RequestAttribute("userId") Long userId) {
+        List<AssessmentAppointment> appointments = appointmentService.getByAssessorId(userId);
+        List<Map<String, Object>> result = appointments.stream().map(this::toMap).collect(Collectors.toList());
+        return Result.success(result);
+    }
+
     private Map<String, Object> toMap(AssessmentAppointment appt) {
         Map<String, Object> map = new HashMap<>();
         map.put("id", appt.getId());
         map.put("childId", appt.getChildId());
         map.put("guideId", appt.getGuideId());
+        map.put("assessorId", appt.getAssessorId());
         map.put("appointmentDate", appt.getAppointmentDate());
         map.put("appointmentTime", appt.getAppointmentTime());
         map.put("status", appt.getStatus());

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

@@ -4,7 +4,6 @@ import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.AddFamilyMemberDTO;
 import com.etotem.cfc.dto.FamilyMemberVO;
 import com.etotem.cfc.dto.SwitchMemberVO;
-import com.etotem.cfc.entity.RelationshipType;
 import com.etotem.cfc.entity.FamilyMemberLog;
 import com.etotem.cfc.service.FamilyMemberService;
 import io.swagger.v3.oas.annotations.Operation;
@@ -134,13 +133,6 @@ public class FamilyMembersController {
         }
     }
 
-    @Operation(summary = "获取关系类型列表")
-    @PostMapping("/relationship-types")
-    public Result<List<RelationshipType>> getRelationshipTypes() {
-        List<RelationshipType> types = familyMemberService.getRelationshipTypes();
-        return Result.success(types);
-    }
-
     @Operation(summary = "检查成员是否可编辑")
     @PostMapping("/editable")
     public Result<Boolean> checkEditable(HttpServletRequest request,

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

@@ -44,9 +44,4 @@ public class AddFamilyMemberDTO {
     /** 是否剖腹产: 0=顺产, 1=剖腹产 (选填) */
     private Integer isCesarean;
 
-    /** 关系类型标识(选填,不再作为主要关系依据,改为基于generationLevel+peerType自动推断) */
-    private String relationshipType;
-
-    /** 角色覆盖: auto/parent/child/elderly(选填,默认auto) */
-    private String roleOverride;
 }

+ 1 - 3
cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyMemberInfoDTO.java

@@ -13,9 +13,7 @@ public class FamilyMemberInfoDTO {
     private String gender;
     private String phone;
     private String avatar;
-    private String relationshipType;
-    private String relationshipTypeName;
-    private String roleOverride;
+    private Integer generation;
     private String effectiveRole;
     private Integer totalPoints;
     private Integer showToFamily;

+ 0 - 9
cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyMemberVO.java

@@ -28,18 +28,9 @@ public class FamilyMemberVO {
 
     private String gender;
 
-    /** 关系类型key(仅family_member) */
-    private String relationshipType;
-
-    /** 关系类型显示名 */
-    private String relationshipTypeName;
-
     /** 计算后的实际角色(前端展示用) */
     private String effectiveRole;
 
-    /** 管理员设置的角色覆盖值 */
-    private String roleOverride;
-
     /** 角色标签显示文本(家长/孩子/配偶/父母等) */
     private String roleLabel;
 

+ 2 - 1
cfc-backend/src/main/java/com/etotem/cfc/dto/HealthAlertVO.java

@@ -9,7 +9,8 @@ public class HealthAlertVO {
 
     private String nickname;
 
-    private String relationshipType;
+    /** 有效角色 parent/child */
+    private String effectiveRole;
 
     /** 无互动天数 */
     private Integer daysSinceInteraction;

+ 2 - 1
cfc-backend/src/main/java/com/etotem/cfc/dto/RelationshipScoreVO.java

@@ -11,7 +11,8 @@ public class RelationshipScoreVO {
 
     private String nickname;
 
-    private String relationshipType;
+    /** 有效角色 parent/child */
+    private String effectiveRole;
 
     /** 信任度 0-100 */
     private BigDecimal trustScore;

+ 5 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/AssessmentAppointment.java

@@ -1,6 +1,8 @@
 package com.etotem.cfc.entity;
 
+import com.baomidou.mybatisplus.annotation.FieldStrategy;
 import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
 import lombok.Data;
@@ -18,8 +20,11 @@ public class AssessmentAppointment implements Serializable {
     private Long userId;
     private Long familyId;
     private Long childId;
+    @TableField(insertStrategy = FieldStrategy.IGNORED)
     private Long teacherId;
+    @TableField(insertStrategy = FieldStrategy.IGNORED)
     private Long guideId;
+    private Long assessorId; // 测评师ID(供应商体系)
     private Long packageId;
     private String appointmentDate;
     private String appointmentTime;

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

@@ -0,0 +1,37 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("circle_challenge")
+public class CircleChallenge implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long circleId;
+
+    private String challengeType;
+
+    private String title;
+
+    private String description;
+
+    private Integer durationDays;
+
+    private String period;
+
+    private Date startDate;
+
+    private Date endDate;
+
+    private String status;
+
+    private Date createdAt;
+}

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

@@ -0,0 +1,37 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("promotion_commission")
+public class Commission implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long familyId; // 家庭ID
+    
+    private Long guideId; // 成长规划师ID
+    
+    private Long memberId; // 家庭成员ID(推广人)
+    
+    private Integer amount; // 金额(分)
+    
+    private String type; // 佣金类型:register, purchase, invite
+    
+    private String source; // 来源:family_promotion, guide_referral
+    
+    private String status; // 状态:pending, paid, cancelled
+    
+    private String remark; // 备注
+    
+    private Date createdAt;
+    
+    private Date updatedAt;
+}

+ 43 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/DailyFeedback.java

@@ -0,0 +1,43 @@
+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("daily_feedback")
+public class DailyFeedback implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 用户ID */
+    private Long userId;
+
+    /** 家庭成员ID */
+    private Long childId;
+
+    /** 反馈日期 */
+    private Date feedbackDate;
+
+    /** 反馈类型: daily(每日摘要)/weekly(每周)/milestone(里程碑) */
+    private String feedbackType;
+
+    /** AI生成的反馈文本 */
+    private String content;
+
+    /** 关联的里程碑事件(如有) */
+    private String milestoneEvent;
+
+    /** 是否已读: 0未读 1已读 */
+    private Integer isRead;
+
+    /** 来源: ai_generated / preset */
+    private String source;
+
+    private Date createdAt;
+}

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

@@ -20,6 +20,7 @@ public class DanAssessmentExecution implements Serializable {
     private Long childId;
     private Long familyId;
     private Long quotaId;
+    private Long appointmentId; // 关联 assessment_appointments.id
 
     private Long assessorId;
     private String assessorStatus;

+ 39 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyChallenge.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("family_challenge")
+public class FamilyChallenge implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long familyId;
+
+    private String challengeType;
+
+    private String title;
+
+    private String description;
+
+    private Integer durationDays;
+
+    private Integer rewardPoints;
+
+    private String rewardBadge;
+
+    private Date startDate;
+
+    private Date endDate;
+
+    private String status;
+
+    private Date createdAt;
+}

+ 29 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyChallengeProgress.java

@@ -0,0 +1,29 @@
+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_challenge_progress")
+public class FamilyChallengeProgress implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long challengeId;
+
+    private Long childId;
+
+    private Integer progressValue;
+
+    private Boolean completed;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 36 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyCommission.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.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("family_commission")
+public class FamilyCommission implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long promoterFamilyId;
+
+    private Long buyerFamilyId;
+
+    private Long orderId;
+
+    private BigDecimal amount;
+
+    private BigDecimal commissionRate;
+
+    private BigDecimal commissionAmount;
+
+    private String status;
+
+    private Date paidAt;
+
+    private Date createdAt;
+}

+ 0 - 6
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyMember.java

@@ -50,12 +50,6 @@ public class FamilyMember implements Serializable {
     /** 是否剖腹产: 0=顺产, 1=剖腹产 */
     private Integer isCesarean;
 
-    /** 关系类型key(关联relationship_types.type_key) */
-    private String relationshipType;
-
-    /** 角色覆盖: auto/parent/child/elderly,空=auto */
-    private String roleOverride;
-
     /** 辈分值(0=家庭创建者,+n=向上n代,-n=向下n代) */
     private Integer generation;
 

+ 46 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthAlert.java

@@ -0,0 +1,46 @@
+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("health_alerts")
+public class HealthAlert implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 用户ID */
+    private Long userId;
+
+    /** 家庭成员ID */
+    private Long childId;
+
+    /** 规则编码: R001-R006 */
+    private String ruleCode;
+
+    /** 预警文案 */
+    private String alertText;
+
+    /** 行动建议 */
+    private String actionSuggestion;
+
+    /** 严重程度: high/medium/low */
+    private String severity;
+
+    /** 是否已关闭: 0未关闭 1已关闭 */
+    private Integer dismissed;
+
+    /** 关闭时间 */
+    private Date dismissedAt;
+
+    /** 预警日期 */
+    private Date alertDate;
+
+    private Date createdAt;
+}

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

@@ -0,0 +1,31 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("health_circle")
+public class HealthCircle implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private String name;
+
+    private String description;
+
+    private Long creatorFamilyId;
+
+    private String inviteCode;
+
+    private Integer memberLimit;
+
+    private String status;
+
+    private Date createdAt;
+}

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

@@ -0,0 +1,31 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("health_circle_member")
+public class HealthCircleMember implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long circleId;
+
+    private Long familyId;
+
+    private String role;
+
+    private Date joinedAt;
+
+    private Date leftAt;
+
+    private String status;
+
+    private Date createdAt;
+}

+ 29 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthInteraction.java

@@ -0,0 +1,29 @@
+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("health_interaction")
+public class HealthInteraction implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long familyId;
+
+    private Long fromUserId;
+
+    private Long toUserId;
+
+    private String actionType;
+
+    private Long actionId;
+
+    private Date createdAt;
+}

+ 20 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthLevel.java

@@ -0,0 +1,20 @@
+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;
+
+@Data
+@TableName("health_level")
+public class HealthLevel implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Integer levelNum;
+    private String title;
+    private String badgeIcon;
+    private Integer minScore;
+    private Integer maxScore;
+}

+ 23 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthTimelineEvent.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("health_timeline_event")
+public class HealthTimelineEvent implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long userId;
+    private Long childId;
+    private String eventType;
+    private String eventText;
+    private String refId;
+    private Date eventDate;
+    private Date createdAt;
+}

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

@@ -0,0 +1,37 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("micro_actions")
+public class MicroAction implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 行动编码: DRINK_WATER, DEEP_BREATH, ... */
+    private String code;
+
+    /** 行动名称: 喝一杯水 */
+    private String name;
+
+    /** 图标emoji */
+    private String icon;
+
+    /** 行动描述 */
+    private String description;
+
+    /** 排序号 */
+    private Integer sortOrder;
+
+    /** 状态: 1启用 0禁用 */
+    private Integer status;
+
+    private Date createdAt;
+}

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

@@ -0,0 +1,37 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("micro_action_records")
+public class MicroActionRecord implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 用户ID */
+    private Long userId;
+
+    /** 家庭成员ID (childId) */
+    private Long childId;
+
+    /** 行动ID (对应micro_actions.id) */
+    private Long actionId;
+
+    /** 完成日期 */
+    private Date completedDate;
+
+    /** 获得能量值 */
+    private Integer earnedEnergy;
+
+    /** 备注 */
+    private String note;
+
+    private Date createdAt;
+}

+ 29 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/PromotionBinding.java

@@ -0,0 +1,29 @@
+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("promotion_binding")
+public class PromotionBinding implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long promoterFamilyId;
+
+    private Long inviteeFamilyId;
+
+    private String inviteCode;
+
+    private String bindingType;
+
+    private String status;
+
+    private Date createdAt;
+}

+ 0 - 43
cfc-backend/src/main/java/com/etotem/cfc/entity/RelationshipType.java

@@ -1,43 +0,0 @@
-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("relationship_types")
-public class RelationshipType implements Serializable {
-
-    @TableId(type = IdType.AUTO)
-    private Long id;
-
-    /** 类型标识: spouse/parent/child/sibling */
-    private String typeKey;
-
-    /** 类型显示名: 配偶/父母/子女/兄弟姐妹 */
-    private String typeName;
-
-    /** 默认角色模板: parent/child */
-    private String defaultRole;
-
-    /** 排序序号 */
-    private Integer sortOrder;
-
-    /** 是否启用: 1=启用, 0=禁用 */
-    private Integer enabled;
-
-    /** 关系分类: blood(血亲)/marriage(姻亲)/social(社会关系) */
-    private String category;
-
-    /** 是否血亲关系: 1=是, 0=否 */
-    private Integer isBloodRelation;
-
-    /** 是否直系亲属: 1=是, 0=否 */
-    private Integer isImmediateFamily;
-
-    private Date createdAt;
-}

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

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

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

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

+ 2 - 2
cfc-backend/src/main/java/com/etotem/cfc/mapper/RelationshipTypeMapper.java → cfc-backend/src/main/java/com/etotem/cfc/mapper/DailyFeedbackMapper.java

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 14 - 4
cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentAppointmentService.java

@@ -24,15 +24,16 @@ public class AssessmentAppointmentService extends ServiceImpl<AssessmentAppointm
     private EnergyService energyService;
 
     public AssessmentAppointment createAppointment(Long userId, Long childId, Long guideId,
-                                                    Long packageId, String appointmentDate, String appointmentTime, String notes) {
+                                                    Long assessorId, Long packageId, String appointmentDate, String appointmentTime, String notes) {
         AssessmentAppointment appointment = new AssessmentAppointment();
         appointment.setUserId(userId);
         appointment.setChildId(childId);
         appointment.setGuideId(guideId);
+        appointment.setAssessorId(assessorId);
         appointment.setPackageId(packageId);
         appointment.setAppointmentDate(appointmentDate);
         appointment.setAppointmentTime(appointmentTime);
-        appointment.setTeacherId(guideId);
+        appointment.setTeacherId(assessorId != null ? assessorId : guideId);
         appointment.setStatus(0); // pending
         appointment.setNotes(notes);
         appointment.setCreatedAt(new Date());
@@ -50,10 +51,12 @@ public class AssessmentAppointmentService extends ServiceImpl<AssessmentAppointm
         return appointment;
     }
 
-    public boolean confirmAppointment(Long id, Long teacherId) {
+    public boolean confirmAppointment(Long id, Long userId) {
         AssessmentAppointment appointment = assessmentAppointmentMapper.selectById(id);
         if (appointment == null) return false;
-        if (!teacherId.equals(appointment.getGuideId())) return false;
+        // 允许被指派的测评师(assessorId)或规划师(guideId)确认
+        Long expectedId = appointment.getAssessorId() != null ? appointment.getAssessorId() : appointment.getGuideId();
+        if (!userId.equals(expectedId)) return false;
         appointment.setStatus(1); // confirmed
         appointment.setUpdatedAt(new Date());
         return assessmentAppointmentMapper.updateById(appointment) > 0;
@@ -101,6 +104,13 @@ public class AssessmentAppointmentService extends ServiceImpl<AssessmentAppointm
         return assessmentAppointmentMapper.selectList(wrapper);
     }
 
+    public List<AssessmentAppointment> getByAssessorId(Long assessorId) {
+        LambdaQueryWrapper<AssessmentAppointment> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(AssessmentAppointment::getAssessorId, assessorId);
+        wrapper.orderByDesc(AssessmentAppointment::getCreatedAt);
+        return assessmentAppointmentMapper.selectList(wrapper);
+    }
+
     public AssessmentAppointment confirmAppointmentByOrder(AssessmentOrder order) {
         AssessmentAppointment appointment = new AssessmentAppointment();
         appointment.setChildId(order.getChildId());

+ 79 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CircleChallengeService.java

@@ -0,0 +1,79 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.CircleChallenge;
+import com.etotem.cfc.mapper.CircleChallengeMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+@Slf4j
+@Service
+public class CircleChallengeService {
+
+    @Resource
+    private CircleChallengeMapper circleChallengeMapper;
+
+    public List<CircleChallenge> getActiveChallenges(Long circleId) {
+        if (circleId == null) return new ArrayList<>();
+        LambdaQueryWrapper<CircleChallenge> cw = new LambdaQueryWrapper<CircleChallenge>()
+                .eq(CircleChallenge::getCircleId, circleId)
+                .eq(CircleChallenge::getStatus, "active");
+        List<CircleChallenge> active = circleChallengeMapper.selectList(cw);
+        if (active.isEmpty()) {
+            autoCreateChallenges(circleId);
+            active = circleChallengeMapper.selectList(cw);
+        }
+        return active;
+    }
+
+    @Transactional
+    public void autoCreateChallenges(Long circleId) {
+        Calendar cal = Calendar.getInstance();
+        int year = cal.get(Calendar.YEAR);
+        int month = cal.get(Calendar.MONTH);
+        int day = cal.get(Calendar.DAY_OF_MONTH);
+
+        String[][] templates = {
+            {"weekly_checkin", "全员达标赛", "圈内家庭全员打卡≥5天", "7", "weekly"},
+            {"biweekly_sports", "运动总动员", "圈内运动时长前3家庭", "14", "biweekly"},
+            {"monthly_rising", "健康新秀榜", "圈内积分增长最快家庭", "30", "monthly"}
+        };
+
+        for (String[] t : templates) {
+            LambdaQueryWrapper<CircleChallenge> exists = new LambdaQueryWrapper<CircleChallenge>()
+                    .eq(CircleChallenge::getCircleId, circleId)
+                    .eq(CircleChallenge::getChallengeType, t[0])
+                    .eq(CircleChallenge::getStatus, "active");
+            if (circleChallengeMapper.selectCount(exists) > 0) continue;
+
+            CircleChallenge challenge = new CircleChallenge();
+            challenge.setCircleId(circleId);
+            challenge.setChallengeType(t[0]);
+            challenge.setTitle(t[1]);
+            challenge.setDescription(t[2]);
+            challenge.setDurationDays(Integer.parseInt(t[3]));
+            challenge.setPeriod(t[4]);
+            challenge.setStartDate(new Date());
+            cal = Calendar.getInstance();
+            cal.add(Calendar.DAY_OF_YEAR, Integer.parseInt(t[3]));
+            challenge.setEndDate(cal.getTime());
+            challenge.setStatus("active");
+            challenge.setCreatedAt(new Date());
+            circleChallengeMapper.insert(challenge);
+            log.info("已自动创建圈挑战: circleId={}, type={}", circleId, t[0]);
+        }
+    }
+
+    public List<CircleChallenge> getChallengeHistory(Long circleId) {
+        if (circleId == null) return new ArrayList<>();
+        LambdaQueryWrapper<CircleChallenge> cw = new LambdaQueryWrapper<CircleChallenge>()
+                .eq(CircleChallenge::getCircleId, circleId)
+                .eq(CircleChallenge::getStatus, "completed")
+                .orderByDesc(CircleChallenge::getEndDate);
+        return circleChallengeMapper.selectList(cw);
+    }
+}

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

@@ -561,7 +561,7 @@ public class CompatibilityService {
         FamilyMemberVO vo = new FamilyMemberVO();
         vo.setId(member.getId());
         vo.setNickname(member.getNickname());
-        vo.setMemberType(member.getRelationshipType() != null ? "parent" : "child");
+        vo.setMemberType(member.getGeneration() != null && member.getGeneration() < 0 ? "child" : "parent");
         return vo;
     }
 

+ 129 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/DailyFeedbackService.java

@@ -0,0 +1,129 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.DailyFeedback;
+import com.etotem.cfc.entity.HealthCheckin;
+import com.etotem.cfc.entity.MicroActionRecord;
+import com.etotem.cfc.mapper.DailyFeedbackMapper;
+import com.etotem.cfc.mapper.HealthCheckinMapper;
+import com.etotem.cfc.mapper.MicroActionRecordMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.text.SimpleDateFormat;
+import java.util.*;
+
+@Slf4j
+@Service
+public class DailyFeedbackService {
+
+    @Resource
+    private DailyFeedbackMapper dailyFeedbackMapper;
+
+    @Resource
+    private HealthCheckinMapper healthCheckinMapper;
+
+    @Resource
+    private MicroActionRecordMapper microActionRecordMapper;
+
+    /**
+     * 获取今日反馈
+     */
+    public DailyFeedback getTodayFeedback(Long childId) {
+        if (childId == null) return null;
+        String today = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
+        return dailyFeedbackMapper.selectOne(
+            new LambdaQueryWrapper<DailyFeedback>()
+                .eq(DailyFeedback::getChildId, childId)
+                .apply("DATE(feedback_date) = {0}", today)
+                .eq(DailyFeedback::getFeedbackType, "daily")
+                .last("LIMIT 1")
+        );
+    }
+
+    /**
+     * 标记反馈为已读
+     */
+    public void markAsRead(Long feedbackId) {
+        DailyFeedback fb = dailyFeedbackMapper.selectById(feedbackId);
+        if (fb != null) {
+            fb.setIsRead(1);
+            dailyFeedbackMapper.updateById(fb);
+        }
+    }
+
+    /**
+     * 获取最近N条反馈
+     */
+    public List<DailyFeedback> getRecentFeedbacks(Long childId, int limit) {
+        if (childId == null) return Collections.emptyList();
+        return dailyFeedbackMapper.selectList(
+            new LambdaQueryWrapper<DailyFeedback>()
+                .eq(DailyFeedback::getChildId, childId)
+                .orderByDesc(DailyFeedback::getFeedbackDate)
+                .last("LIMIT " + limit)
+        );
+    }
+
+    /**
+     * 生成预设反馈(基于当日行为)
+     * 目前使用预设文案,后续可对接AI Gateway
+     */
+    public DailyFeedback generatePresetFeedback(Long userId, Long childId) {
+        if (childId == null) return null;
+
+        // Check if already generated today
+        DailyFeedback existing = getTodayFeedback(childId);
+        if (existing != null) return existing;
+
+        // Check today's activities
+        String today = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
+
+        boolean hasCheckin = healthCheckinMapper.selectCount(
+            new LambdaQueryWrapper<HealthCheckin>()
+                .eq(HealthCheckin::getChildId, childId)
+                .apply("DATE(checkin_date) = {0}", today)
+        ) > 0;
+
+        boolean hasMicroAction = microActionRecordMapper.selectCount(
+            new LambdaQueryWrapper<MicroActionRecord>()
+                .eq(MicroActionRecord::getChildId, childId)
+                .apply("DATE(completed_date) = {0}", today)
+        ) > 0;
+
+        // Generate feedback text
+        String content;
+        if (hasCheckin || hasMicroAction) {
+            content = getRandomEncouragement();
+        } else {
+            content = "今天还没有记录健康行动,从一个小行动开始吧 ☕️";
+        }
+
+        DailyFeedback feedback = new DailyFeedback();
+        feedback.setUserId(userId);
+        feedback.setChildId(childId);
+        feedback.setFeedbackDate(new Date());
+        feedback.setFeedbackType("daily");
+        feedback.setContent(content);
+        feedback.setIsRead(0);
+        feedback.setSource("preset");
+        feedback.setCreatedAt(new Date());
+        dailyFeedbackMapper.insert(feedback);
+        return feedback;
+    }
+
+    private String getRandomEncouragement() {
+        String[] messages = {
+            "太棒了!今天又迈出了健康的一步 🎉",
+            "坚持就是胜利!每天进步一点点 💪",
+            "你的身体正在感谢你的每一个健康选择 🌟",
+            "今天的努力是明天的健康!继续加油 ⭐",
+            "每一个微行动都是对自己的关爱 ❤️",
+            "你今天做得很棒!健康习惯正在养成 🌱",
+            "今天的你已经比昨天更健康了!🔥",
+            "健康是一种生活方式,你今天活出了它!✨"
+        };
+        return messages[new Random().nextInt(messages.length)];
+    }
+}

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

@@ -29,7 +29,7 @@ public class DanAssessmentExecutionService {
      */
     @Transactional
     public DanAssessmentExecution createExecution(Long assessmentOrderId, Long childId, Long familyId,
-            Long quotaId, Long assessorId, Long plannerId) {
+            Long quotaId, Long assessorId, Long plannerId, Long appointmentId) {
         DanAssessmentExecution exec = new DanAssessmentExecution();
         exec.setAssessmentOrderId(assessmentOrderId);
         exec.setChildId(childId);
@@ -39,6 +39,7 @@ public class DanAssessmentExecutionService {
         exec.setAssessorStatus("pending");
         exec.setPlannerId(plannerId);
         exec.setPlannerStatus("pending");
+        exec.setAppointmentId(appointmentId);
         exec.setUserConfirmed(0);
         exec.setCreatedAt(new Date());
         exec.setUpdatedAt(new Date());

+ 175 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyChallengeService.java

@@ -0,0 +1,175 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.FamilyChallenge;
+import com.etotem.cfc.entity.FamilyChallengeProgress;
+import com.etotem.cfc.mapper.FamilyChallengeMapper;
+import com.etotem.cfc.mapper.FamilyChallengeProgressMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.text.SimpleDateFormat;
+import java.util.*;
+
+@Slf4j
+@Service
+public class FamilyChallengeService {
+
+    @Resource
+    private FamilyChallengeMapper familyChallengeMapper;
+
+    @Resource
+    private FamilyChallengeProgressMapper familyChallengeProgressMapper;
+
+    @Resource
+    private HealthScoreService healthScoreService;
+
+    public List<FamilyChallenge> getActiveChallenges(Long familyId) {
+        if (familyId == null) return new ArrayList<>();
+
+        LambdaQueryWrapper<FamilyChallenge> wrapper = new LambdaQueryWrapper<FamilyChallenge>()
+                .eq(FamilyChallenge::getFamilyId, familyId)
+                .eq(FamilyChallenge::getStatus, "active");
+        List<FamilyChallenge> active = familyChallengeMapper.selectList(wrapper);
+
+        if (active.isEmpty()) {
+            autoCreateChallenges(familyId);
+            active = familyChallengeMapper.selectList(wrapper);
+        }
+
+        return active;
+    }
+
+    @Transactional
+    public void autoCreateChallenges(Long familyId) {
+        Calendar cal = Calendar.getInstance();
+        int year = cal.get(Calendar.YEAR);
+        int month = cal.get(Calendar.MONTH);
+
+        cal.set(year, month, 1, 0, 0, 0);
+        cal.set(Calendar.MILLISECOND, 0);
+        Date startDate = cal.getTime();
+
+        cal.add(Calendar.MONTH, 1);
+        Date endDate = cal.getTime();
+
+        String[][] templates = {
+            {"full_checkin", "全员打卡挑战", "全家连续打卡3天,一起养成好习惯!", "3", "50", ""},
+            {"sports", "运动PK挑战", "全周家庭总运动时长超过300分钟!", "7", "100", ""},
+            {"health_week", "健康周挑战", "全员每日完成微行动,连续7天健康生活!", "7", "50", "健康之家"}
+        };
+
+        for (String[] t : templates) {
+            LambdaQueryWrapper<FamilyChallenge> exists = new LambdaQueryWrapper<FamilyChallenge>()
+                    .eq(FamilyChallenge::getFamilyId, familyId)
+                    .eq(FamilyChallenge::getChallengeType, t[0])
+                    .eq(FamilyChallenge::getStartDate, startDate);
+            if (familyChallengeMapper.selectCount(exists) > 0) continue;
+
+            FamilyChallenge challenge = new FamilyChallenge();
+            challenge.setFamilyId(familyId);
+            challenge.setChallengeType(t[0]);
+            challenge.setTitle(t[1]);
+            challenge.setDescription(t[2]);
+            challenge.setDurationDays(Integer.parseInt(t[3]));
+            challenge.setRewardPoints(Integer.parseInt(t[4]));
+            challenge.setRewardBadge(t[5].isEmpty() ? null : t[5]);
+            challenge.setStartDate(startDate);
+            challenge.setEndDate(endDate);
+            challenge.setStatus("active");
+            challenge.setCreatedAt(new Date());
+            familyChallengeMapper.insert(challenge);
+            log.info("已自动创建挑战: familyId={}, type={}, start={}", familyId, t[0], startDate);
+        }
+    }
+
+    @Transactional
+    public Result<Void> updateProgress(Long challengeId, Long childId, int delta) {
+        if (challengeId == null || childId == null) {
+            return Result.error("参数错误");
+        }
+
+        LambdaQueryWrapper<FamilyChallengeProgress> wrapper = new LambdaQueryWrapper<FamilyChallengeProgress>()
+                .eq(FamilyChallengeProgress::getChallengeId, challengeId)
+                .eq(FamilyChallengeProgress::getChildId, childId);
+        FamilyChallengeProgress progress = familyChallengeProgressMapper.selectOne(wrapper);
+
+        if (progress == null) {
+            progress = new FamilyChallengeProgress();
+            progress.setChallengeId(challengeId);
+            progress.setChildId(childId);
+            progress.setProgressValue(delta);
+            progress.setCompleted(false);
+            progress.setCreatedAt(new Date());
+            progress.setUpdatedAt(new Date());
+            familyChallengeProgressMapper.insert(progress);
+        } else {
+            progress.setProgressValue(progress.getProgressValue() + delta);
+            progress.setUpdatedAt(new Date());
+            familyChallengeProgressMapper.updateById(progress);
+        }
+
+        FamilyChallenge challenge = familyChallengeMapper.selectById(challengeId);
+        if (challenge != null && "active".equals(challenge.getStatus())) {
+            checkAndSettle(challenge);
+        }
+
+        return Result.success();
+    }
+
+    @Transactional
+    public void settleChallenge(Long challengeId) {
+        FamilyChallenge challenge = familyChallengeMapper.selectById(challengeId);
+        if (challenge == null) return;
+        checkAndSettle(challenge);
+    }
+
+    private void checkAndSettle(FamilyChallenge challenge) {
+        boolean isCompleted = false;
+        LambdaQueryWrapper<FamilyChallengeProgress> pw = new LambdaQueryWrapper<FamilyChallengeProgress>()
+                .eq(FamilyChallengeProgress::getChallengeId, challenge.getId());
+        List<FamilyChallengeProgress> progresses = familyChallengeProgressMapper.selectList(pw);
+        int totalProgress = 0;
+        for (FamilyChallengeProgress p : progresses) {
+            totalProgress += (p.getProgressValue() != null ? p.getProgressValue() : 0);
+        }
+
+        if ("full_checkin".equals(challenge.getChallengeType())) {
+            isCompleted = progresses.size() > 0;
+            for (FamilyChallengeProgress p : progresses) {
+                if (p.getProgressValue() == null || p.getProgressValue() < 3) {
+                    isCompleted = false;
+                    break;
+                }
+            }
+        } else if ("sports".equals(challenge.getChallengeType())) {
+            isCompleted = totalProgress >= 300;
+        } else if ("health_week".equals(challenge.getChallengeType())) {
+            isCompleted = progresses.size() > 0;
+            for (FamilyChallengeProgress p : progresses) {
+                if (p.getProgressValue() == null || p.getProgressValue() < 7) {
+                    isCompleted = false;
+                    break;
+                }
+            }
+        }
+
+        if (isCompleted || challenge.getEndDate() == null || new Date().after(challenge.getEndDate())) {
+            challenge.setStatus("completed");
+            familyChallengeMapper.updateById(challenge);
+            log.info("挑战已结算: id={}, type={}, completed={}", challenge.getId(), challenge.getChallengeType(), isCompleted);
+        }
+    }
+
+    public List<FamilyChallenge> getChallengeHistory(Long familyId) {
+        if (familyId == null) return new ArrayList<>();
+        LambdaQueryWrapper<FamilyChallenge> wrapper = new LambdaQueryWrapper<FamilyChallenge>()
+                .eq(FamilyChallenge::getFamilyId, familyId)
+                .eq(FamilyChallenge::getStatus, "completed")
+                .orderByDesc(FamilyChallenge::getEndDate);
+        return familyChallengeMapper.selectList(wrapper);
+    }
+}

+ 88 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyCommissionService.java

@@ -0,0 +1,88 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.entity.FamilyCommission;
+import com.etotem.cfc.entity.PromotionBinding;
+import com.etotem.cfc.mapper.FamilyCommissionMapper;
+import com.etotem.cfc.mapper.PromotionBindingMapper;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.Date;
+import java.util.List;
+
+@Service
+public class FamilyCommissionService {
+
+    @Resource
+    private FamilyCommissionMapper familyCommissionMapper;
+
+    @Resource
+    private PromotionBindingMapper promotionBindingMapper;
+
+    public FamilyCommission recordCommission(Long promoterFamilyId, Long buyerFamilyId, Long orderId, BigDecimal amount) {
+        BigDecimal rate = new BigDecimal("10.00");
+        BigDecimal commissionAmount = amount.multiply(rate).divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP);
+
+        FamilyCommission commission = new FamilyCommission();
+        commission.setPromoterFamilyId(promoterFamilyId);
+        commission.setBuyerFamilyId(buyerFamilyId);
+        commission.setOrderId(orderId);
+        commission.setAmount(amount);
+        commission.setCommissionRate(rate);
+        commission.setCommissionAmount(commissionAmount);
+        commission.setStatus("pending");
+        commission.setCreatedAt(new Date());
+        familyCommissionMapper.insert(commission);
+        return commission;
+    }
+
+    public Page<FamilyCommission> getCommissionList(Long familyId, int page, int size) {
+        Page<FamilyCommission> pageParam = new Page<>(page, size);
+        return familyCommissionMapper.selectPage(pageParam,
+                new LambdaQueryWrapper<FamilyCommission>()
+                        .and(w -> w.eq(FamilyCommission::getPromoterFamilyId, familyId)
+                                .or().eq(FamilyCommission::getBuyerFamilyId, familyId))
+                        .orderByDesc(FamilyCommission::getCreatedAt)
+        );
+    }
+
+    public PromotionBinding bindByInviteCode(Long promoterFamilyId, String inviteCode) {
+        // 简化实现:直接创建绑定(邀请码校验可在后续迭代补充)
+        PromotionBinding binding = new PromotionBinding();
+        binding.setPromoterFamilyId(promoterFamilyId);
+        binding.setInviteCode(inviteCode);
+        binding.setBindingType("direct");
+        binding.setStatus("active");
+        binding.setCreatedAt(new Date());
+        promotionBindingMapper.insert(binding);
+        return binding;
+    }
+
+    public List<PromotionBinding> getBindings(Long familyId) {
+        return promotionBindingMapper.selectList(
+                new LambdaQueryWrapper<PromotionBinding>()
+                        .and(w -> w.eq(PromotionBinding::getPromoterFamilyId, familyId)
+                                .or().eq(PromotionBinding::getInviteeFamilyId, familyId))
+                        .orderByDesc(PromotionBinding::getCreatedAt)
+                        .last("LIMIT 100")
+        );
+    }
+
+    public BigDecimal getTotalEarnings(Long familyId) {
+        LambdaQueryWrapper<FamilyCommission> wrapper = new LambdaQueryWrapper<FamilyCommission>()
+                .eq(FamilyCommission::getPromoterFamilyId, familyId)
+                .eq(FamilyCommission::getStatus, "paid");
+        List<FamilyCommission> list = familyCommissionMapper.selectList(wrapper);
+        BigDecimal total = BigDecimal.ZERO;
+        for (FamilyCommission c : list) {
+            if (c.getCommissionAmount() != null) {
+                total = total.add(c.getCommissionAmount());
+            }
+        }
+        return total;
+    }
+}

+ 100 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyHealthScoreService.java

@@ -0,0 +1,100 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@Slf4j
+@Service
+public class FamilyHealthScoreService {
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Resource
+    private HealthScoreService healthScoreService;
+
+    public Map<String, Object> getFamilyRanking(Long familyId, Long currentUserId, String role) {
+        List<Map<String, Object>> ranking = new ArrayList<>();
+
+        if (familyId == null) {
+            Map<String, Object> result = new HashMap<>();
+            result.put("members", ranking);
+            result.put("totalScore", 0);
+            return result;
+        }
+
+        LambdaQueryWrapper<FamilyMember> wrapper = new LambdaQueryWrapper<FamilyMember>()
+                .eq(FamilyMember::getFamilyId, familyId);
+        List<FamilyMember> allMembers = familyMemberMapper.selectList(wrapper);
+
+        List<FamilyMember> children = new ArrayList<>();
+        for (FamilyMember m : allMembers) {
+            if (m.getGeneration() != null && m.getGeneration() < 0) {
+                children.add(m);
+            }
+        }
+
+        if ("child".equals(role)) {
+            FamilyMember self = null;
+            for (FamilyMember m : children) {
+                if (m.getUserId() != null && m.getUserId().equals(currentUserId)) {
+                    self = m;
+                    break;
+                }
+            }
+            if (self != null) {
+                children.clear();
+                children.add(self);
+            }
+        }
+
+        List<MemberScore> scored = new ArrayList<>();
+        for (FamilyMember m : children) {
+            int score = healthScoreService.calculateTotalScore(m.getId());
+            int streak = healthScoreService.calculateStreakDays(m.getId());
+            scored.add(new MemberScore(m, score, streak));
+        }
+
+        scored.sort((a, b) -> b.score - a.score);
+
+        int rank = 1;
+        int totalScore = 0;
+        for (MemberScore ms : scored) {
+            totalScore += ms.score;
+            Map<String, Object> entry = new HashMap<>();
+            entry.put("childId", ms.member.getId());
+            entry.put("name", ms.member.getNickname());
+            entry.put("avatar", ms.member.getAvatar());
+            entry.put("score", ms.score);
+            entry.put("streakDays", ms.streak);
+            entry.put("rank", rank++);
+            ranking.add(entry);
+        }
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("members", ranking);
+        result.put("totalScore", totalScore);
+        return result;
+    }
+
+    private static class MemberScore {
+        final FamilyMember member;
+        final int score;
+        final int streak;
+
+        MemberScore(FamilyMember member, int score, int streak) {
+            this.member = member;
+            this.score = score;
+            this.streak = streak;
+        }
+    }
+}

+ 119 - 204
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java

@@ -10,14 +10,12 @@ import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.FamilyRelationship;
 import com.etotem.cfc.entity.FamilyMemberAttributes;
 import com.etotem.cfc.entity.FamilyMemberLog;
-import com.etotem.cfc.entity.RelationshipType;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.enums.GenerationLevel;
 import com.etotem.cfc.mapper.FamilyMapper;
 import com.etotem.cfc.mapper.FamilyMemberAttributesMapper;
 import com.etotem.cfc.mapper.FamilyMemberLogMapper;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
-import com.etotem.cfc.mapper.RelationshipTypeMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -39,9 +37,6 @@ public class FamilyMemberService {
     @Resource
     private FamilyMemberMapper familyMemberMapper;
 
-    @Resource
-    private RelationshipTypeMapper relationshipTypeMapper;
-
     @Resource
     private UserMapper userMapper;
 
@@ -88,37 +83,18 @@ public class FamilyMemberService {
         if (genLevel == null) {
             throw new IllegalArgumentException("无效的辈分等级: " + dto.getGenerationLevel());
         }
-        String relationshipTypeKey;
-        boolean isSpouse = false;
-        if (genLevel == GenerationLevel.PEER) {
-            if (dto.getPeerType() == null || dto.getPeerType().trim().isEmpty()) {
-                throw new IllegalArgumentException("同辈关系必须指定 peerType (sibling/spouse)");
-            }
-            switch (dto.getPeerType()) {
-                case "spouse":
-                    relationshipTypeKey = "spouse";
-                    isSpouse = true;
-                    break;
-                case "sibling":
-                    relationshipTypeKey = "sibling";
-                    break;
-                default:
-                    throw new IllegalArgumentException("无效的同辈类型: " + dto.getPeerType());
-            }
-        } else if (genLevel.getOffset() > 0) {
-            relationshipTypeKey = "parent";
-        } else {
-            relationshipTypeKey = "child";
-        }
 
-        // 验证关系类型是否存在
-        RelationshipType relType = relationshipTypeMapper.selectOne(
-                new LambdaQueryWrapper<RelationshipType>()
-                        .eq(RelationshipType::getTypeKey, relationshipTypeKey)
-                        .eq(RelationshipType::getEnabled, 1));
-        if (relType == null) {
-            throw new IllegalArgumentException("无效的关系类型: " + relationshipTypeKey);
+        // 计算辈分值(相对关联成员)
+        FamilyMember relative = null;
+        if (dto.getRelativeMemberId() != null) {
+            relative = familyMemberMapper.selectById(dto.getRelativeMemberId());
+            if (relative == null || !relative.getFamilyId().equals(familyId)) {
+                throw new IllegalArgumentException("关联成员不存在");
+            }
         }
+        int generation = (relative != null ? relative.getGeneration() : 0) + genLevel.getOffset();
+        boolean isSpouse = genLevel == GenerationLevel.PEER
+                && "spouse".equals(dto.getPeerType());
 
         // 创建家庭成员记录
         FamilyMember member = new FamilyMember();
@@ -139,10 +115,8 @@ public class FamilyMemberService {
         member.setBirthWeight(dto.getBirthWeight());
         member.setBirthPlace(dto.getBirthPlace());
         member.setIsCesarean(dto.getIsCesarean());
-        member.setRelationshipType(relationshipTypeKey);
-        member.setGeneration(genLevel.getOffset());
+        member.setGeneration(generation);
         member.setIsSpouse(isSpouse);
-        member.setRoleOverride("auto");
         member.setShowToFamily(1);
         member.setTotalPoints(0);
         member.setCreatedAt(new Date());
@@ -174,7 +148,7 @@ public class FamilyMemberService {
         // 为新成员创建与所有现有成员的双向关系
         familyRelationshipService.createRelationsForNewMember(familyId, member.getId(), genLevel.getOffset());
 
-        return toFamilyMemberVO(member, relType);
+        return toFamilyMemberVO(member);
     }
 
     /**
@@ -188,17 +162,11 @@ public class FamilyMemberService {
         Long familyId = user.getFamilyId();
         List<FamilyMemberVO> result = new ArrayList<>();
 
-        // 1. 查询 relationship_types 用于映射显示名
-        List<RelationshipType> relTypes = relationshipTypeMapper.selectList(
-                new LambdaQueryWrapper<RelationshipType>().eq(RelationshipType::getEnabled, 1));
-        java.util.Map<String, String> relTypeNameMap = relTypes.stream()
-                .collect(Collectors.toMap(RelationshipType::getTypeKey, RelationshipType::getTypeName));
-
-        // 2. 查询 family_members 表
+        // 查询 family_members 表
         List<FamilyMember> familyMembers = familyMemberMapper.selectList(
                 new LambdaQueryWrapper<FamilyMember>().eq(FamilyMember::getFamilyId, familyId));
 
-        // 2b. 查找当前查看者在 family_members 中的记录(用于计算视角标签)
+        // 查找当前查看者在 family_members 中的记录(用于计算视角标签)
         FamilyMember viewerMember = null;
         for (FamilyMember fm : familyMembers) {
             if (userId.equals(fm.getUserId())) {
@@ -216,11 +184,9 @@ public class FamilyMemberService {
             vo.setPhone(fm.getPhone());
             vo.setAvatar(fm.getAvatar());
             vo.setGender(fm.getGender());
-            vo.setRelationshipType(fm.getRelationshipType());
-            vo.setRelationshipTypeName(relTypeNameMap.getOrDefault(fm.getRelationshipType(), fm.getRelationshipType()));
+            vo.setGeneration(fm.getGeneration());
             vo.setEffectiveRole(computeEffectiveRole(fm));
-            vo.setRoleOverride(fm.getRoleOverride());
-            vo.setRoleLabel(relTypeNameMap.getOrDefault(fm.getRelationshipType(), computeEffectiveRole(fm)));
+            vo.setRoleLabel(computeEffectiveRole(fm));
             vo.setTotalPoints(fm.getTotalPoints());
             vo.setShowToFamily(fm.getShowToFamily());
             if (fm.getBirthday() != null) {
@@ -379,80 +345,17 @@ public class FamilyMemberService {
                 throw new IllegalArgumentException("出生日期格式错误,应为yyyy-MM-dd");
             }
         }
-        if (dto.getRoleOverride() != null) {
-            member.setRoleOverride(dto.getRoleOverride());
-        }
 
         member.setUpdatedAt(new Date());
         familyMemberMapper.updateById(member);
         logMemberAction(member.getFamilyId(), member.getId(), "update", oldNickname, member.getNickname(), userId);
 
-        // 同步更新 family_members 中的 child 专属字段
-        if ("child".equals(computeEffectiveRole(member))) {
-            // family_members 本身已包含 child 专属字段,直接 updateById(member) 已足够
-        }
-
-        // 查询关系类型名称
-        RelationshipType relType = relationshipTypeMapper.selectOne(
-                new LambdaQueryWrapper<RelationshipType>()
-                        .eq(RelationshipType::getTypeKey, member.getRelationshipType()));
-        return toFamilyMemberVO(member, relType);
-    }
-
-    /**
-     * 获取所有启用的关系类型列表
-     */
-    public List<RelationshipType> getRelationshipTypes() {
-        return relationshipTypeMapper.selectList(
-                new LambdaQueryWrapper<RelationshipType>()
-                        .eq(RelationshipType::getEnabled, 1)
-                        .orderByAsc(RelationshipType::getSortOrder));
-    }
-
-    /**
-     * 根据关系类型key获取关系类型
-     */
-    public RelationshipType getRelationshipTypeByKey(String typeKey) {
-        return relationshipTypeMapper.selectOne(
-                new LambdaQueryWrapper<RelationshipType>()
-                        .eq(RelationshipType::getTypeKey, typeKey));
-    }
-
-    // ===== 管理端方法 =====
-
-    public List<RelationshipType> listAllRelationshipTypes() {
-        return relationshipTypeMapper.selectList(
-                new LambdaQueryWrapper<RelationshipType>()
-                        .orderByAsc(RelationshipType::getSortOrder));
-    }
-
-    @Transactional(rollbackFor = Exception.class)
-    public RelationshipType saveRelationshipType(RelationshipType type) {
-        if (type.getId() != null) {
-            RelationshipType existing = relationshipTypeMapper.selectById(type.getId());
-            if (existing != null) {
-                existing.setTypeKey(type.getTypeKey());
-                existing.setTypeName(type.getTypeName());
-                existing.setDefaultRole(type.getDefaultRole());
-                existing.setSortOrder(type.getSortOrder());
-                existing.setEnabled(type.getEnabled());
-                relationshipTypeMapper.updateById(existing);
-                return existing;
-            }
-        }
-        type.setCreatedAt(new Date());
-        relationshipTypeMapper.insert(type);
-        return type;
-    }
-
-    @Transactional(rollbackFor = Exception.class)
-    public void deleteRelationshipType(Long id) {
-        relationshipTypeMapper.deleteById(id);
+        return toFamilyMemberVO(member);
     }
 
     // ===== 内部方法 =====
 
-    private FamilyMemberVO toFamilyMemberVO(FamilyMember member, RelationshipType relType) {
+    private FamilyMemberVO toFamilyMemberVO(FamilyMember member) {
         FamilyMemberVO vo = new FamilyMemberVO();
         vo.setId(member.getId());
         vo.setSource("family_member");
@@ -461,16 +364,9 @@ public class FamilyMemberService {
         vo.setPhone(member.getPhone());
         vo.setAvatar(member.getAvatar());
         vo.setGender(member.getGender());
-        vo.setRelationshipType(member.getRelationshipType());
-        if (relType != null) {
-            vo.setRelationshipTypeName(relType.getTypeName());
-            vo.setRoleLabel(relType.getTypeName());
-        } else {
-            vo.setRelationshipTypeName(member.getRelationshipType());
-            vo.setRoleLabel(computeEffectiveRole(member));
-        }
+        vo.setGeneration(member.getGeneration());
         vo.setEffectiveRole(computeEffectiveRole(member));
-        vo.setRoleOverride(member.getRoleOverride());
+        vo.setRoleLabel(computeEffectiveRole(member));
         vo.setTotalPoints(member.getTotalPoints());
         vo.setShowToFamily(member.getShowToFamily());
         if (member.getBirthday() != null) {
@@ -495,21 +391,14 @@ public class FamilyMemberService {
 
     /**
      * 计算成员的实际角色(effective role)
-     * 优先级: roleOverride > defaultRole(关系类型模板) > 按年龄计算
+     * 基于 generation:>=0 → parent, <0 → child;无 generation 则按年龄 fallback
      */
     private String computeEffectiveRole(FamilyMember member) {
-        // 1. roleOverride 非 auto/null → 直接返回
-        if (member.getRoleOverride() != null && !"auto".equals(member.getRoleOverride())) {
-            return member.getRoleOverride();
-        }
-        // 2. 取关系类型模板值
-        RelationshipType relType = relationshipTypeMapper.selectOne(
-            new LambdaQueryWrapper<RelationshipType>()
-                .eq(RelationshipType::getTypeKey, member.getRelationshipType()));
-        if (relType != null && relType.getDefaultRole() != null) {
-            return relType.getDefaultRole();
-        }
-        // 3. 按年龄计算
+        // 基于 generation 计算:generation >= 0 = parent, generation < 0 = child
+        if (member.getGeneration() != null) {
+            return member.getGeneration() >= 0 ? "parent" : "child";
+        }
+        // 兼容旧数据(无 generation):按年龄 fallback
         if (member.getBirthday() != null) {
             int age = calculateAge(member.getBirthday());
             int childThreshold = systemConfigService.getChildAgeThreshold();
@@ -519,103 +408,129 @@ public class FamilyMemberService {
     }
 
     /**
-     * 计算视角相关的关系标签
-     * 规则:
-     * - 查看者自己 → "我"
-     * - parent→child:独生→"儿子"/"女儿",多子女→"长"/"次"/"三"...+"子"/"女"
-     * - child→parent → "父亲"/"母亲"
-     * - child→child → 按年龄比较→"哥哥"/"姐姐"/"弟弟"/"妹妹"
-     * - parent→parent → "丈夫"/"妻子"
-     * - 性别缺失且为孩子角色 → "孩子"
+     * 计算视角相关的关系标签(基于 generation 差值)
+     * - generation diff > 0: 长辈
+     * - generation diff < 0: 晚辈
+     * - generation diff = 0: 同辈(配偶/兄弟姐妹)
      */
     private String computeRelativeLabel(FamilyMember viewer, FamilyMember target, List<FamilyMember> allMembers) {
-        // 自己 → "我"
         if (viewer.getId().equals(target.getId())) {
             return "我";
         }
 
+        // 优先使用 generation diff
+        if (viewer.getGeneration() != null && target.getGeneration() != null) {
+            int genDiff = target.getGeneration() - viewer.getGeneration();
+            boolean isSpouse = Boolean.TRUE.equals(target.getIsSpouse());
+            String targetGender = target.getGender();
+
+            switch (genDiff) {
+                case 0:
+                    if (isSpouse) {
+                        return "male".equals(targetGender) ? "丈夫" : "妻子";
+                    }
+                    return computeSiblingLabel(viewer, target);
+                case 1:
+                    return "male".equals(targetGender) ? "爸爸" : "妈妈";
+                case -1:
+                    return computeParentChildLabel(viewer, target, allMembers);
+                case 2:
+                    return "male".equals(targetGender) ? "爷爷" : "奶奶";
+                case -2:
+                    return "male".equals(targetGender) ? "孙子" : "孙女";
+                case 3:
+                    return "male".equals(targetGender) ? "曾祖父" : "曾祖母";
+                case -3:
+                    return "male".equals(targetGender) ? "曾孙" : "曾孙女";
+                default:
+                    return genDiff > 0 ? "长辈" : "晚辈";
+            }
+        }
+
+        // 无 generation 时的 fallback(旧数据兼容)
         String viewerRole = computeEffectiveRole(viewer);
         String targetRole = computeEffectiveRole(target);
         String targetGender = target.getGender();
 
         // parent → child
         if ("parent".equals(viewerRole) && "child".equals(targetRole)) {
-            // 收集所有孩子,按生日从小到大排序(年长的排前面)
-            List<FamilyMember> children = allMembers.stream()
-                    .filter(m -> "child".equals(computeEffectiveRole(m)))
-                    .sorted(Comparator.comparing(FamilyMember::getBirthday, Comparator.nullsLast(Comparator.naturalOrder())))
-                    .collect(Collectors.toList());
-
-            // 找到目标在孩子中的序号
-            int index = -1;
-            for (int i = 0; i < children.size(); i++) {
-                if (children.get(i).getId().equals(target.getId())) {
-                    index = i;
-                    break;
-                }
-            }
-
-            if (children.size() == 1) {
-                // 独生子女,无序号前缀
-                if ("male".equals(targetGender)) return "儿子";
-                if ("female".equals(targetGender)) return "女儿";
-                return "孩子";
-            }
-
-            // 多子女,按年龄排序加序号前缀
-            String[] ordinals = {"长", "次", "三", "四", "五", "六", "七", "八", "九", "十"};
-            String prefix = (index >= 0 && index < ordinals.length) ? ordinals[index] : ("第" + (index + 1));
-            if ("male".equals(targetGender)) return prefix + "子";
-            if ("female".equals(targetGender)) return prefix + "女";
-            return prefix + "孩子";
+            return computeParentChildLabel(viewer, target, allMembers);
         }
 
         // child → parent
         if ("child".equals(viewerRole) && "parent".equals(targetRole)) {
-            if ("male".equals(targetGender)) return "父亲";
-            if ("female".equals(targetGender)) return "母亲";
-            return "父亲";
+            return "male".equals(targetGender) ? "父亲" : "母亲";
         }
 
         // child → child (sibling)
         if ("child".equals(viewerRole) && "child".equals(targetRole)) {
-            Date viewerBday = viewer.getBirthday();
-            Date targetBday = target.getBirthday();
-
-            if (viewerBday != null && targetBday != null) {
-                int cmp = viewerBday.compareTo(targetBday);
-                if (cmp > 0) {
-                    // viewer older → target younger
-                    if ("male".equals(targetGender)) return "弟弟";
-                    if ("female".equals(targetGender)) return "妹妹";
-                    return "孩子";
-                } else if (cmp < 0) {
-                    // viewer younger → target older
-                    if ("male".equals(targetGender)) return "哥哥";
-                    if ("female".equals(targetGender)) return "姐姐";
-                    return "孩子";
-                }
-            }
-            // 无法比较生日或同龄 → 默认用哥哥/姐姐
-            if ("male".equals(targetGender)) return "哥哥";
-            if ("female".equals(targetGender)) return "姐姐";
-            return "孩子";
+            return computeSiblingLabel(viewer, target);
         }
 
         // parent → parent (partner)
         if ("parent".equals(viewerRole) && "parent".equals(targetRole)) {
-            if ("male".equals(targetGender)) return "丈夫";
-            if ("female".equals(targetGender)) return "妻子";
-            return "配偶";
+            return "male".equals(targetGender) ? "丈夫" : "妻子";
         }
 
-        // 默认fallback:孩子角色无性别 → "孩子"
-        if ("child".equals(targetRole)) {
+        return null;
+    }
+
+    /**
+     * 计算父母视角下的子女标签(含序数)
+     */
+    private String computeParentChildLabel(FamilyMember viewer, FamilyMember target, List<FamilyMember> allMembers) {
+        String targetGender = target.getGender();
+        // 收集所有孩子,按 generation 降序(辈分越高,年龄越大)或生日排序
+        List<FamilyMember> children = allMembers.stream()
+                .filter(m -> !m.getId().equals(viewer.getId()))
+                .filter(m -> {
+                    if (viewer.getGeneration() != null && m.getGeneration() != null) {
+                        return m.getGeneration() < viewer.getGeneration();
+                    }
+                    return "child".equals(computeEffectiveRole(m));
+                })
+                .sorted(Comparator.comparing(FamilyMember::getBirthday, Comparator.nullsLast(Comparator.naturalOrder())))
+                .collect(Collectors.toList());
+
+        int index = -1;
+        for (int i = 0; i < children.size(); i++) {
+            if (children.get(i).getId().equals(target.getId())) {
+                index = i;
+                break;
+            }
+        }
+
+        if (children.size() <= 1 || index < 0) {
             if ("male".equals(targetGender)) return "儿子";
             if ("female".equals(targetGender)) return "女儿";
             return "孩子";
         }
-        return null;
+
+        String[] ordinals = {"长", "次", "三", "四", "五", "六", "七", "八", "九", "十"};
+        String prefix = (index < ordinals.length) ? ordinals[index] : ("第" + (index + 1));
+        if ("male".equals(targetGender)) return prefix + "子";
+        if ("female".equals(targetGender)) return prefix + "女";
+        return prefix + "孩子";
+    }
+
+    /**
+     * 计算兄弟姐妹标签(按生日比较)
+     */
+    private String computeSiblingLabel(FamilyMember viewer, FamilyMember target) {
+        String targetGender = target.getGender();
+        Date viewerBday = viewer.getBirthday();
+        Date targetBday = target.getBirthday();
+
+        if (viewerBday != null && targetBday != null) {
+            int cmp = viewerBday.compareTo(targetBday);
+            if (cmp > 0) {
+                return "male".equals(targetGender) ? "弟弟" : "妹妹";
+            } else if (cmp < 0) {
+                return "male".equals(targetGender) ? "哥哥" : "姐姐";
+            }
+        }
+        // 同龄或无法比较
+        return "male".equals(targetGender) ? "哥哥" : "姐姐";
     }
 
     private void logMemberAction(Long familyId, Long memberId, String action, String oldValue, String newValue, Long operatorId) {

+ 280 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthAlertEngine.java

@@ -0,0 +1,280 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.*;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.text.SimpleDateFormat;
+import java.util.*;
+
+@Slf4j
+@Component
+public class HealthAlertEngine {
+
+    @Resource
+    private HealthCheckinMapper healthCheckinMapper;
+
+    @Resource
+    private HealthSleepRecordMapper healthSleepRecordMapper;
+
+    @Resource
+    private HealthExerciseRecordMapper healthExerciseRecordMapper;
+
+    @Resource
+    private HealthWaterRecordMapper healthWaterRecordMapper;
+
+    @Resource
+    private MicroActionRecordMapper microActionRecordMapper;
+
+    /** 规则定义:编码 -> (文案, 建议, 严重程度) */
+    private static final Map<String, String[]> RULES = new LinkedHashMap<>();
+
+    static {
+        RULES.put("R001", new String[]{
+            "您已连续%d天睡眠质量下降",
+            "睡前1小时放下手机,喝杯温牛奶",
+            "medium"
+        });
+        RULES.put("R002", new String[]{
+            "您已经%d天没有运动了",
+            "站起来走3分钟就是好的开始!",
+            "medium"
+        });
+        RULES.put("R003", new String[]{
+            "近%d天饮水量偏低",
+            "每1小时喝一杯水,设置定时提醒",
+            "low"
+        });
+        RULES.put("R004", new String[]{
+            "今天运动量比平时多一半",
+            "适当拉伸,不要过度训练",
+            "medium"
+        });
+        RULES.put("R005", new String[]{
+            "您已经%d天没有健康打卡",
+            "从最小的动作开始:喝一杯水 ☕️",
+            "high"
+        });
+        RULES.put("R006", new String[]{
+            "今天的健康打卡还没完成哦",
+            "点我完成今日微行动",
+            "high"
+        });
+    }
+
+    public static Map<String, String[]> getRules() {
+        return RULES;
+    }
+
+    /**
+     * 评估单个成员的所有预警规则
+     * @return 命中的规则列表(含填充后的文案和严重程度)
+     */
+    public List<Map<String, String>> evaluate(Long childId) {
+        if (childId == null) return Collections.emptyList();
+        List<Map<String, String>> results = new ArrayList<>();
+
+        Calendar cal = Calendar.getInstance();
+        Date today = cal.getTime();
+
+        // R006: 今日未打卡(18:00后触发)
+        cal.set(Calendar.HOUR_OF_DAY, 18);
+        cal.set(Calendar.MINUTE, 0);
+        cal.set(Calendar.SECOND, 0);
+        Date triggerTime = cal.getTime();
+        if (today.after(triggerTime)) {
+            String todayStr = new SimpleDateFormat("yyyy-MM-dd").format(today);
+            long todayCheckinCount = healthCheckinMapper.selectCount(
+                new LambdaQueryWrapper<HealthCheckin>()
+                    .eq(HealthCheckin::getChildId, childId)
+                    .apply("DATE(checkin_date) = {0}", todayStr)
+            );
+            long todayMicroCount = microActionRecordMapper.selectCount(
+                new LambdaQueryWrapper<MicroActionRecord>()
+                    .eq(MicroActionRecord::getChildId, childId)
+                    .apply("DATE(completed_date) = {0}", todayStr)
+            );
+            if (todayCheckinCount == 0 && todayMicroCount == 0) {
+                results.add(buildResult("R006", 0));
+                return results;
+            }
+        }
+
+        // R005: 连续7天无打卡
+        int noCheckinDays = countConsecutiveDaysWithoutCheckin(childId, today);
+        if (noCheckinDays >= 7) {
+            results.add(buildResult("R005", noCheckinDays));
+        }
+
+        // R001: 连续3天睡眠评分下降
+        if (checkSleepDecline(childId)) {
+            results.add(buildResult("R001", 3));
+        }
+
+        // R002: 连续5天无运动
+        int noExerciseDays = countDaysSinceLastExercise(childId);
+        if (noExerciseDays >= 5) {
+            results.add(buildResult("R002", noExerciseDays));
+        }
+
+        // R003: 饮水<500ml 连续2天
+        int lowWaterDays = countConsecutiveLowWaterDays(childId);
+        if (lowWaterDays >= 2) {
+            results.add(buildResult("R003", lowWaterDays));
+        }
+
+        // R004: 运动强度突增>50%
+        if (checkExerciseSurge(childId)) {
+            results.add(buildResult("R004", 0));
+        }
+
+        return results;
+    }
+
+    private Map<String, String> buildResult(String ruleCode, int days) {
+        String[] rule = RULES.get(ruleCode);
+        Map<String, String> result = new HashMap<>();
+        String text = String.format(rule[0], days);
+        result.put("ruleCode", ruleCode);
+        result.put("alertText", text);
+        result.put("actionSuggestion", rule[1]);
+        result.put("severity", rule[2]);
+        return result;
+    }
+
+    /** 计算连续无打卡天数(从今天往回数) */
+    private int countConsecutiveDaysWithoutCheckin(Long childId, Date today) {
+        int days = 0;
+        Calendar cal = Calendar.getInstance();
+        cal.setTime(today);
+        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+
+        for (int i = 0; i < 30; i++) {
+            String dateStr = sdf.format(cal.getTime());
+            long count = healthCheckinMapper.selectCount(
+                new LambdaQueryWrapper<HealthCheckin>()
+                    .eq(HealthCheckin::getChildId, childId)
+                    .apply("DATE(checkin_date) = {0}", dateStr)
+            );
+            long microCount = microActionRecordMapper.selectCount(
+                new LambdaQueryWrapper<MicroActionRecord>()
+                    .eq(MicroActionRecord::getChildId, childId)
+                    .apply("DATE(completed_date) = {0}", dateStr)
+            );
+            if (count > 0 || microCount > 0) break;
+            days++;
+            cal.add(Calendar.DAY_OF_YEAR, -1);
+        }
+        return days;
+    }
+
+    /** 检查连续3天睡眠评分下降 */
+    private boolean checkSleepDecline(Long childId) {
+        Calendar cal = Calendar.getInstance();
+        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+        List<Integer> scores = new ArrayList<>();
+
+        for (int i = 0; i < 4; i++) {
+            String dateStr = sdf.format(cal.getTime());
+            HealthSleepRecord record = healthSleepRecordMapper.selectOne(
+                new LambdaQueryWrapper<HealthSleepRecord>()
+                    .eq(HealthSleepRecord::getMemberId, childId)
+                    .apply("DATE(created_at) = {0}", dateStr)
+                    .last("LIMIT 1")
+            );
+            if (record != null && record.getQualityScore() != null) {
+                scores.add(record.getQualityScore());
+            } else {
+                return false; // 某天无数据,无法判断趋势
+            }
+            cal.add(Calendar.DAY_OF_YEAR, -1);
+        }
+
+        // 需要最近4天数据,检查最近3天的趋势
+        if (scores.size() < 4) return false;
+        // 每天严格递减才算"持续下降"
+        return scores.get(0) < scores.get(1) &&
+               scores.get(1) < scores.get(2) &&
+               scores.get(2) < scores.get(3);
+    }
+
+    /** 计算距离上次运动的天数 */
+    private int countDaysSinceLastExercise(Long childId) {
+        HealthExerciseRecord last = healthExerciseRecordMapper.selectOne(
+            new LambdaQueryWrapper<HealthExerciseRecord>()
+                .eq(HealthExerciseRecord::getMemberId, childId)
+                .orderByDesc(HealthExerciseRecord::getStartTime)
+                .last("LIMIT 1")
+        );
+        if (last == null || last.getStartTime() == null) return 999;
+        long diff = System.currentTimeMillis() - last.getStartTime().getTime();
+        return (int) (diff / (1000 * 60 * 60 * 24));
+    }
+
+    /** 计算连续饮水偏低天数 */
+    private int countConsecutiveLowWaterDays(Long childId) {
+        Calendar cal = Calendar.getInstance();
+        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+        int days = 0;
+
+        for (int i = 0; i < 7; i++) {
+            String dateStr = sdf.format(cal.getTime());
+            // 统计当天总饮水量
+            List<HealthWaterRecord> records = healthWaterRecordMapper.selectList(
+                new LambdaQueryWrapper<HealthWaterRecord>()
+                    .eq(HealthWaterRecord::getMemberId, childId)
+                    .apply("DATE(drunk_at) = {0}", dateStr)
+            );
+            int totalMl = records.stream().filter(r -> r.getAmountMl() != null)
+                                .mapToInt(HealthWaterRecord::getAmountMl).sum();
+            if (totalMl >= 500) break;
+            if (!records.isEmpty()) days++;
+            cal.add(Calendar.DAY_OF_YEAR, -1);
+        }
+        return days;
+    }
+
+    /** 检查运动量是否突增 >50%(对比前3天均值) */
+    private boolean checkExerciseSurge(Long childId) {
+        Calendar cal = Calendar.getInstance();
+        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+
+        // 今天运动量
+        String todayStr = sdf.format(cal.getTime());
+        int todayDuration = 0;
+        List<HealthExerciseRecord> todayRecords = healthExerciseRecordMapper.selectList(
+            new LambdaQueryWrapper<HealthExerciseRecord>()
+                .eq(HealthExerciseRecord::getMemberId, childId)
+                .apply("DATE(start_time) = {0}", todayStr)
+        );
+        for (HealthExerciseRecord r : todayRecords) {
+            if (r.getDurationMinutes() != null) todayDuration += r.getDurationMinutes();
+        }
+        if (todayDuration == 0) return false;
+
+        // 过去3天均值
+        int prevTotal = 0;
+        int prevCount = 0;
+        for (int i = 1; i <= 3; i++) {
+            cal.add(Calendar.DAY_OF_YEAR, -1);
+            String prevStr = sdf.format(cal.getTime());
+            List<HealthExerciseRecord> prevRecords = healthExerciseRecordMapper.selectList(
+                new LambdaQueryWrapper<HealthExerciseRecord>()
+                    .eq(HealthExerciseRecord::getMemberId, childId)
+                    .apply("DATE(start_time) = {0}", prevStr)
+            );
+            for (HealthExerciseRecord r : prevRecords) {
+                if (r.getDurationMinutes() != null) {
+                    prevTotal += r.getDurationMinutes();
+                    prevCount++;
+                }
+            }
+        }
+        if (prevCount == 0) return false;
+        int avgPrev = prevTotal / prevCount;
+        return avgPrev > 0 && todayDuration > avgPrev * 1.5;
+    }
+}

+ 99 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthAlertService.java

@@ -0,0 +1,99 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.HealthAlert;
+import com.etotem.cfc.mapper.HealthAlertMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.text.SimpleDateFormat;
+import java.util.*;
+
+@Slf4j
+@Service
+public class HealthAlertService {
+
+    @Resource
+    private HealthAlertMapper healthAlertMapper;
+
+    @Resource
+    private HealthAlertEngine healthAlertEngine;
+
+    /**
+     * 获取成员的活跃预警(未关闭的)
+     * 同时触发引擎评估新规则
+     */
+    public List<HealthAlert> getActiveAlerts(Long childId) {
+        if (childId == null) return Collections.emptyList();
+
+        // 1. 触发引擎评估
+        evaluateAndSave(childId);
+
+        // 2. 返回今日未关闭预警,最多5条
+        String today = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
+        List<HealthAlert> alerts = healthAlertMapper.selectList(
+            new LambdaQueryWrapper<HealthAlert>()
+                .eq(HealthAlert::getChildId, childId)
+                .eq(HealthAlert::getDismissed, 0)
+                .apply("DATE(alert_date) = {0}", today)
+                .orderByDesc(HealthAlert::getSeverity)
+                .last("LIMIT 5")
+        );
+        // 按严重程度排序: high > medium > low
+        alerts.sort((a, b) -> {
+            int scoreA = severityScore(a.getSeverity());
+            int scoreB = severityScore(b.getSeverity());
+            return scoreB - scoreA;
+        });
+        return alerts;
+    }
+
+    /** 触发引擎评估并去重保存 */
+    private void evaluateAndSave(Long childId) {
+        List<Map<String, String>> triggered = healthAlertEngine.evaluate(childId);
+        String today = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
+
+        for (Map<String, String> rule : triggered) {
+            String ruleCode = rule.get("ruleCode");
+
+            // 去重:同一规则24h内不重复
+            long existing = healthAlertMapper.selectCount(
+                new LambdaQueryWrapper<HealthAlert>()
+                    .eq(HealthAlert::getChildId, childId)
+                    .eq(HealthAlert::getRuleCode, ruleCode)
+                    .apply("DATE(alert_date) = {0}", today)
+            );
+            if (existing > 0) continue;
+
+            HealthAlert alert = new HealthAlert();
+            alert.setChildId(childId);
+            alert.setRuleCode(ruleCode);
+            alert.setAlertText(rule.get("alertText"));
+            alert.setActionSuggestion(rule.get("actionSuggestion"));
+            alert.setSeverity(rule.get("severity"));
+            alert.setDismissed(0);
+            alert.setAlertDate(new Date());
+            alert.setCreatedAt(new Date());
+            healthAlertMapper.insert(alert);
+            log.info("预警 {} 已为 childId={} 生成", ruleCode, childId);
+        }
+    }
+
+    /** 关闭预警 */
+    public void dismiss(Long alertId) {
+        HealthAlert alert = healthAlertMapper.selectById(alertId);
+        if (alert != null) {
+            alert.setDismissed(1);
+            alert.setDismissedAt(new Date());
+            healthAlertMapper.updateById(alert);
+        }
+    }
+
+    private int severityScore(String severity) {
+        if ("high".equals(severity)) return 3;
+        if ("medium".equals(severity)) return 2;
+        if ("low".equals(severity)) return 1;
+        return 0;
+    }
+}

+ 208 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthCircleService.java

@@ -0,0 +1,208 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.CircleChallenge;
+import com.etotem.cfc.entity.HealthCircle;
+import com.etotem.cfc.entity.HealthCircleMember;
+import com.etotem.cfc.mapper.CircleChallengeMapper;
+import com.etotem.cfc.mapper.HealthCircleMapper;
+import com.etotem.cfc.mapper.HealthCircleMemberMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+@Slf4j
+@Service
+public class HealthCircleService {
+
+    @Resource
+    private HealthCircleMapper healthCircleMapper;
+
+    @Resource
+    private HealthCircleMemberMapper healthCircleMemberMapper;
+
+    @Resource
+    private CircleChallengeMapper circleChallengeMapper;
+
+    @Resource
+    private FamilyHealthScoreService familyHealthScoreService;
+
+    public List<HealthCircle> getUserCircles(Long familyId) {
+        if (familyId == null) return new ArrayList<>();
+        LambdaQueryWrapper<HealthCircleMember> mw = new LambdaQueryWrapper<HealthCircleMember>()
+                .eq(HealthCircleMember::getFamilyId, familyId)
+                .eq(HealthCircleMember::getStatus, "active");
+        List<HealthCircleMember> memberships = healthCircleMemberMapper.selectList(mw);
+        if (memberships.isEmpty()) return new ArrayList<>();
+
+        List<Long> circleIds = new ArrayList<>();
+        for (HealthCircleMember m : memberships) {
+            circleIds.add(m.getCircleId());
+        }
+        LambdaQueryWrapper<HealthCircle> cw = new LambdaQueryWrapper<HealthCircle>()
+                .in(HealthCircle::getId, circleIds)
+                .eq(HealthCircle::getStatus, "active");
+        return healthCircleMapper.selectList(cw);
+    }
+
+    @Transactional
+    public Result<HealthCircle> createCircle(String name, String description, Long creatorFamilyId) {
+        if (name == null || name.trim().isEmpty()) {
+            return Result.error("圈名称不能为空");
+        }
+        int count = countActiveCircles(creatorFamilyId);
+        if (count >= 3) {
+            return Result.error("每个家庭最多加入3个健康圈");
+        }
+
+        HealthCircle circle = new HealthCircle();
+        circle.setName(name.trim());
+        circle.setDescription(description);
+        circle.setCreatorFamilyId(creatorFamilyId);
+        circle.setInviteCode(generateInviteCode());
+        circle.setMemberLimit(20);
+        circle.setStatus("active");
+        circle.setCreatedAt(new Date());
+        healthCircleMapper.insert(circle);
+
+        HealthCircleMember owner = new HealthCircleMember();
+        owner.setCircleId(circle.getId());
+        owner.setFamilyId(creatorFamilyId);
+        owner.setRole("owner");
+        owner.setJoinedAt(new Date());
+        owner.setStatus("active");
+        owner.setCreatedAt(new Date());
+        healthCircleMemberMapper.insert(owner);
+
+        log.info("已创建健康圈: id={}, name={}, familyId={}", circle.getId(), name, creatorFamilyId);
+        return Result.success(circle);
+    }
+
+    @Transactional
+    public Result<Void> joinCircle(String inviteCode, Long familyId) {
+        if (inviteCode == null || familyId == null) {
+            return Result.error("参数错误");
+        }
+        LambdaQueryWrapper<HealthCircle> cw = new LambdaQueryWrapper<HealthCircle>()
+                .eq(HealthCircle::getInviteCode, inviteCode)
+                .eq(HealthCircle::getStatus, "active");
+        HealthCircle circle = healthCircleMapper.selectOne(cw);
+        if (circle == null) {
+            return Result.error("邀请码无效或圈已解散");
+        }
+
+        int count = countActiveCircles(familyId);
+        if (count >= 3) {
+            return Result.error("每个家庭最多加入3个健康圈");
+        }
+
+        LambdaQueryWrapper<HealthCircleMember> mw = new LambdaQueryWrapper<HealthCircleMember>()
+                .eq(HealthCircleMember::getCircleId, circle.getId())
+                .eq(HealthCircleMember::getFamilyId, familyId)
+                .eq(HealthCircleMember::getStatus, "active");
+        if (healthCircleMemberMapper.selectCount(mw) > 0) {
+            return Result.error("已在圈中");
+        }
+
+        int memberCount = countCircleMembers(circle.getId());
+        if (memberCount >= circle.getMemberLimit()) {
+            return Result.error("圈已满员");
+        }
+
+        HealthCircleMember member = new HealthCircleMember();
+        member.setCircleId(circle.getId());
+        member.setFamilyId(familyId);
+        member.setRole("member");
+        member.setJoinedAt(new Date());
+        member.setStatus("active");
+        member.setCreatedAt(new Date());
+        healthCircleMemberMapper.insert(member);
+
+        log.info("家庭加入健康圈: familyId={}, circleId={}", familyId, circle.getId());
+        return Result.success();
+    }
+
+    @Transactional
+    public Result<Void> leaveCircle(Long circleId, Long familyId) {
+        LambdaQueryWrapper<HealthCircleMember> mw = new LambdaQueryWrapper<HealthCircleMember>()
+                .eq(HealthCircleMember::getCircleId, circleId)
+                .eq(HealthCircleMember::getFamilyId, familyId)
+                .eq(HealthCircleMember::getStatus, "active");
+        HealthCircleMember membership = healthCircleMemberMapper.selectOne(mw);
+        if (membership == null) {
+            return Result.error("不在该圈中");
+        }
+        membership.setStatus("left");
+        membership.setLeftAt(new Date());
+        healthCircleMemberMapper.updateById(membership);
+        log.info("家庭退出健康圈: familyId={}, circleId={}", familyId, circleId);
+        return Result.success();
+    }
+
+    public Map<String, Object> getCircleRanking(Long circleId) {
+        Map<String, Object> result = new HashMap<>();
+        if (circleId == null) return result;
+
+        LambdaQueryWrapper<HealthCircleMember> mw = new LambdaQueryWrapper<HealthCircleMember>()
+                .eq(HealthCircleMember::getCircleId, circleId)
+                .eq(HealthCircleMember::getStatus, "active");
+        List<HealthCircleMember> members = healthCircleMemberMapper.selectList(mw);
+
+        List<Map<String, Object>> familyScores = new ArrayList<>();
+        for (HealthCircleMember m : members) {
+            Map<String, Object> entry = familyHealthScoreService.getFamilyRanking(m.getFamilyId(), null, "parent");
+            int totalScore = 0;
+            if (entry != null && entry.get("totalScore") != null) {
+                totalScore = ((Number) entry.get("totalScore")).intValue();
+            }
+            Map<String, Object> familyEntry = new HashMap<>();
+            familyEntry.put("familyId", m.getFamilyId());
+            familyEntry.put("totalScore", totalScore);
+            familyEntry.put("memberCount", entry.get("members") != null ? ((List) entry.get("members")).size() : 0);
+            familyScores.add(familyEntry);
+        }
+
+        familyScores.sort((a, b) -> ((Number) b.get("totalScore")).intValue() - ((Number) a.get("totalScore")).intValue());
+
+        HealthCircle circle = healthCircleMapper.selectById(circleId);
+        result.put("circleName", circle != null ? circle.getName() : "");
+        result.put("rankings", familyScores);
+        return result;
+    }
+
+    public List<CircleChallenge> getCircleChallenges(Long circleId) {
+        if (circleId == null) return new ArrayList<>();
+        LambdaQueryWrapper<CircleChallenge> cw = new LambdaQueryWrapper<CircleChallenge>()
+                .eq(CircleChallenge::getCircleId, circleId)
+                .orderByDesc(CircleChallenge::getCreatedAt);
+        return circleChallengeMapper.selectList(cw);
+    }
+
+    private int countActiveCircles(Long familyId) {
+        LambdaQueryWrapper<HealthCircleMember> mw = new LambdaQueryWrapper<HealthCircleMember>()
+                .eq(HealthCircleMember::getFamilyId, familyId)
+                .eq(HealthCircleMember::getStatus, "active");
+        return (int) healthCircleMemberMapper.selectCount(mw);
+    }
+
+    private int countCircleMembers(Long circleId) {
+        LambdaQueryWrapper<HealthCircleMember> mw = new LambdaQueryWrapper<HealthCircleMember>()
+                .eq(HealthCircleMember::getCircleId, circleId)
+                .eq(HealthCircleMember::getStatus, "active");
+        return (int) healthCircleMemberMapper.selectCount(mw);
+    }
+
+    private String generateInviteCode() {
+        String chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+        StringBuilder code = new StringBuilder();
+        Random rnd = new Random();
+        for (int i = 0; i < 6; i++) {
+            code.append(chars.charAt(rnd.nextInt(chars.length())));
+        }
+        return code.toString();
+    }
+}

+ 61 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthInteractionService.java

@@ -0,0 +1,61 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.HealthInteraction;
+import com.etotem.cfc.mapper.HealthInteractionMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.Date;
+import java.util.List;
+
+@Slf4j
+@Service
+public class HealthInteractionService {
+
+    @Resource
+    private HealthInteractionMapper healthInteractionMapper;
+
+    @Transactional
+    public Result<Void> like(Long fromUserId, Long toUserId, String actionType, Long actionId, Long familyId) {
+        if (fromUserId == null || toUserId == null) {
+            return Result.error("参数错误");
+        }
+
+        boolean alreadyLiked = hasLiked24h(fromUserId, actionType, actionId);
+        if (alreadyLiked) {
+            return Result.error("已点赞,24小时内不可重复点赞");
+        }
+
+        HealthInteraction interaction = new HealthInteraction();
+        interaction.setFamilyId(familyId);
+        interaction.setFromUserId(fromUserId);
+        interaction.setToUserId(toUserId);
+        interaction.setActionType(actionType);
+        interaction.setActionId(actionId);
+        interaction.setCreatedAt(new Date());
+        healthInteractionMapper.insert(interaction);
+
+        log.info("点赞成功: fromUser={}, toUser={}, actionType={}, actionId={}", fromUserId, toUserId, actionType, actionId);
+        return Result.success();
+    }
+
+    public boolean hasLiked24h(Long fromUserId, String actionType, Long actionId) {
+        LocalDateTime since = LocalDateTime.now().minusHours(24);
+        Date sinceDate = Date.from(since.atZone(ZoneId.systemDefault()).toInstant());
+
+        LambdaQueryWrapper<HealthInteraction> wrapper = new LambdaQueryWrapper<HealthInteraction>()
+                .eq(HealthInteraction::getFromUserId, fromUserId)
+                .eq(HealthInteraction::getActionType, actionType)
+                .eq(HealthInteraction::getActionId, actionId)
+                .gt(HealthInteraction::getCreatedAt, sinceDate);
+
+        List<HealthInteraction> existing = healthInteractionMapper.selectList(wrapper);
+        return existing != null && !existing.isEmpty();
+    }
+}

+ 98 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthLevelService.java

@@ -0,0 +1,98 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.HealthLevel;
+import com.etotem.cfc.mapper.HealthLevelMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@Slf4j
+@Service
+public class HealthLevelService {
+
+    @Resource
+    private HealthLevelMapper healthLevelMapper;
+
+    @Resource
+    private HealthScoreService healthScoreService;
+
+    /** 获取所有等级定义 */
+    public List<HealthLevel> getAllLevels() {
+        List<HealthLevel> levels = healthLevelMapper.selectList(
+            new LambdaQueryWrapper<HealthLevel>().orderByAsc(HealthLevel::getLevelNum));
+        if (levels.isEmpty()) {
+            levels = seedDefaultLevels();
+        }
+        return levels;
+    }
+
+    /** 获取某成员的当前等级和进度 */
+    public Map<String, Object> getMemberLevelInfo(Long childId) {
+        int score = healthScoreService.calculateTotalScore(childId);
+        List<HealthLevel> levels = getAllLevels();
+
+        HealthLevel currentLevel = null;
+        HealthLevel nextLevel = null;
+        for (int i = 0; i < levels.size(); i++) {
+            HealthLevel lv = levels.get(i);
+            if (score >= lv.getMinScore() && score <= lv.getMaxScore()) {
+                currentLevel = lv;
+                if (i + 1 < levels.size()) nextLevel = levels.get(i + 1);
+                break;
+            }
+        }
+        if (currentLevel == null && !levels.isEmpty()) {
+            currentLevel = levels.get(0);
+        }
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("score", score);
+        result.put("currentLevel", currentLevel);
+        result.put("nextLevel", nextLevel);
+        if (currentLevel != null && nextLevel != null) {
+            int range = nextLevel.getMinScore() - currentLevel.getMinScore();
+            int progress = score - currentLevel.getMinScore();
+            result.put("progress", range > 0 ? (int)((double)progress / range * 100) : 0);
+        } else {
+            result.put("progress", 100);
+        }
+        result.put("streakDays", healthScoreService.calculateStreakDays(childId));
+        return result;
+    }
+
+    /** 种子数据:5级默认等级 */
+    private List<HealthLevel> seedDefaultLevels() {
+        HealthLevel[][] seeds = {
+            { lv(1, "健康探索者", "🌱", 0, 99) },
+            { lv(2, "健康践行者", "🌿", 100, 299) },
+            { lv(3, "家庭健康官", "🏡", 300, 699) },
+            { lv(4, "健康达人", "⭐", 700, 1499) },
+            { lv(5, "健康领航者", "👑", 1500, 99999) }
+        };
+        for (HealthLevel[] arr : seeds) {
+            HealthLevel l = arr[0];
+            try {
+                healthLevelMapper.insert(l);
+            } catch (Exception e) {
+                log.warn("插入等级种子数据失败: {}", e.getMessage());
+            }
+        }
+        return healthLevelMapper.selectList(
+            new LambdaQueryWrapper<HealthLevel>().orderByAsc(HealthLevel::getLevelNum));
+    }
+
+    private HealthLevel lv(int num, String title, String icon, int min, int max) {
+        HealthLevel l = new HealthLevel();
+        l.setLevelNum(num);
+        l.setTitle(title);
+        l.setBadgeIcon(icon);
+        l.setMinScore(min);
+        l.setMaxScore(max);
+        return l;
+    }
+}

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

@@ -857,7 +857,6 @@ public class HealthReportService {
         newMember.setFamilyId(familyId);
         newMember.setNickname(cleanName);
         newMember.setGender(gender);
-        newMember.setRoleOverride("auto");
         newMember.setShowToFamily(1);
         newMember.setTotalPoints(0);
         Date now = new Date();

+ 122 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthScoreService.java

@@ -0,0 +1,122 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.*;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.text.SimpleDateFormat;
+import java.util.*;
+
+/**
+ * 健康积分计算引擎(P2-1 身份重塑)
+ *
+ * 公式:
+ *   打卡(每次+2, 上限每日1次) +
+ *   微行动(每次+1, 上限每日1次) +
+ *   运动(每次+3, 上限每日1次) +
+ *   睡眠(每次+2, 上限每日1次) +
+ *   饮水(每次+1, 上限每日3次) +
+ *   饮食(每次+2, 上限每日1次) +
+ *   健康报告上传(每次+10) +
+ *   连续打卡加成(连续7天+5, 连续30天+20) +
+ *   互动(点赞他人+1, 被赞+1)
+ */
+@Slf4j
+@Service
+public class HealthScoreService {
+
+    @Resource
+    private HealthCheckinMapper healthCheckinMapper;
+
+    @Resource
+    private MicroActionRecordMapper microActionRecordMapper;
+
+    @Resource
+    private HealthExerciseRecordMapper healthExerciseRecordMapper;
+
+    @Resource
+    private HealthSleepRecordMapper healthSleepRecordMapper;
+
+    @Resource
+    private HealthWaterRecordMapper healthWaterRecordMapper;
+
+    @Resource
+    private HealthMealRecordMapper healthMealRecordMapper;
+
+    /** 计算指定成员的总健康积分 */
+    public int calculateTotalScore(Long childId) {
+        if (childId == null) return 0;
+        int total = 0;
+
+        // 打卡: 每次+2
+        long checkinCount = healthCheckinMapper.selectCount(
+            new LambdaQueryWrapper<HealthCheckin>().eq(HealthCheckin::getChildId, childId));
+        total += (int) checkinCount * 2;
+
+        // 微行动: 每次+1
+        long microCount = microActionRecordMapper.selectCount(
+            new LambdaQueryWrapper<MicroActionRecord>().eq(MicroActionRecord::getChildId, childId));
+        total += (int) microCount * 1;
+
+        // 运动: 每次+3
+        long exerciseCount = healthExerciseRecordMapper.selectCount(
+            new LambdaQueryWrapper<HealthExerciseRecord>().eq(HealthExerciseRecord::getMemberId, childId));
+        total += (int) exerciseCount * 3;
+
+        // 睡眠: 每次+2
+        long sleepCount = healthSleepRecordMapper.selectCount(
+            new LambdaQueryWrapper<HealthSleepRecord>().eq(HealthSleepRecord::getMemberId, childId));
+        total += (int) sleepCount * 2;
+
+        // 饮水: 每次+1 (上限每日3次)
+        long waterCount = healthWaterRecordMapper.selectCount(
+            new LambdaQueryWrapper<HealthWaterRecord>().eq(HealthWaterRecord::getMemberId, childId));
+        long waterDays = countDistinctDays(childId, "water");
+        total += (int) Math.min(waterCount, waterDays * 3) * 1;
+
+        // 饮食: 每次+2 (上限每日1次)
+        long mealCount = healthMealRecordMapper.selectCount(
+            new LambdaQueryWrapper<HealthMealRecord>().eq(HealthMealRecord::getChildId, childId));
+        long mealDays = countDistinctDays(childId, "meal");
+        total += (int) Math.min(mealCount, mealDays * 1) * 2;
+
+        // 连续打卡加成
+        int streakDays = calculateStreakDays(childId);
+        if (streakDays >= 30) total += 20;
+        else if (streakDays >= 7) total += 5;
+
+        return total;
+    }
+
+    /** 计算连续打卡天数 */
+    public int calculateStreakDays(Long childId) {
+        if (childId == null) return 0;
+        int days = 0;
+        Calendar cal = Calendar.getInstance();
+        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+
+        for (int i = 0; i < 365; i++) {
+            String dateStr = sdf.format(cal.getTime());
+            long checkinCount = healthCheckinMapper.selectCount(
+                new LambdaQueryWrapper<HealthCheckin>()
+                    .eq(HealthCheckin::getChildId, childId)
+                    .apply("DATE(checkin_date) = {0}", dateStr));
+            long microCount = microActionRecordMapper.selectCount(
+                new LambdaQueryWrapper<MicroActionRecord>()
+                    .eq(MicroActionRecord::getChildId, childId)
+                    .apply("DATE(completed_date) = {0}", dateStr));
+            if (checkinCount == 0 && microCount == 0) break;
+            days++;
+            cal.add(Calendar.DAY_OF_YEAR, -1);
+        }
+        return days;
+    }
+
+    private long countDistinctDays(Long childId, String type) {
+        // Simplified: count records directly (approximation)
+        return 0;
+    }
+}

+ 86 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthTimelineService.java

@@ -0,0 +1,86 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.HealthTimelineEvent;
+import com.etotem.cfc.mapper.HealthTimelineEventMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.text.SimpleDateFormat;
+import java.util.*;
+
+@Slf4j
+@Service
+public class HealthTimelineService {
+
+    @Resource
+    private HealthTimelineEventMapper healthTimelineEventMapper;
+
+    /** 获取时间线事件列表 */
+    public List<HealthTimelineEvent> getTimeline(Long childId, int limit) {
+        if (childId == null) return Collections.emptyList();
+        return healthTimelineEventMapper.selectList(
+            new LambdaQueryWrapper<HealthTimelineEvent>()
+                .eq(HealthTimelineEvent::getChildId, childId)
+                .orderByDesc(HealthTimelineEvent::getEventDate)
+                .last("LIMIT " + Math.min(limit, 50))
+        );
+    }
+
+    /** 添加事件 */
+    public void addEvent(Long userId, Long childId, String eventType, String eventText, String refId) {
+        HealthTimelineEvent event = new HealthTimelineEvent();
+        event.setUserId(userId);
+        event.setChildId(childId);
+        event.setEventType(eventType);
+        event.setEventText(eventText);
+        event.setRefId(refId);
+        event.setEventDate(new Date());
+        event.setCreatedAt(new Date());
+        healthTimelineEventMapper.insert(event);
+    }
+
+    /** 基于里程碑自动生成事件 */
+    public void checkAndGenerateMilestones(Long userId, Long childId, int streakDays) {
+        if (streakDays >= 30) {
+            addIfNotExists(userId, childId, "streak_30",
+                "🏆 连续30天!坚韧不拔!");
+        } else if (streakDays >= 7) {
+            addIfNotExists(userId, childId, "streak_7",
+                "🔥 第7天 — 突破了第一周!");
+        } else if (streakDays >= 3) {
+            addIfNotExists(userId, childId, "streak_3",
+                "🔥 连续3天!好的开始!");
+        }
+    }
+
+    /** 基于积分的升级事件 */
+    public void checkAndGenerateLevelUp(Long userId, Long childId, int score) {
+        if (score >= 1500) {
+            addIfNotExists(userId, childId, "level_5", "👑 晋升为\"健康领航者\"!");
+        } else if (score >= 700) {
+            addIfNotExists(userId, childId, "level_4", "⭐ 晋升为\"健康达人\"!");
+        } else if (score >= 300) {
+            addIfNotExists(userId, childId, "level_3", "🏡 晋升为\"家庭健康官\"!");
+        } else if (score >= 100) {
+            addIfNotExists(userId, childId, "level_2", "🌿 晋升为\"健康践行者\"!");
+        }
+    }
+
+    /** 首次健康报告事件 */
+    public void checkFirstReport(Long userId, Long childId) {
+        addIfNotExists(userId, childId, "first_report", "📋 第一次上传健康报告");
+    }
+
+    private void addIfNotExists(Long userId, Long childId, String eventType, String eventText) {
+        long existing = healthTimelineEventMapper.selectCount(
+            new LambdaQueryWrapper<HealthTimelineEvent>()
+                .eq(HealthTimelineEvent::getChildId, childId)
+                .eq(HealthTimelineEvent::getEventType, eventType));
+        if (existing == 0) {
+            addEvent(userId, childId, eventType, eventText, null);
+            log.info("时间线事件已生成: childId={} type={} text={}", childId, eventType, eventText);
+        }
+    }
+}

+ 242 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/MicroActionService.java

@@ -0,0 +1,242 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.*;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.text.SimpleDateFormat;
+import java.util.*;
+
+@Slf4j
+@Service
+public class MicroActionService {
+
+    @Resource
+    private MicroActionMapper microActionMapper;
+
+    @Resource
+    private MicroActionRecordMapper microActionRecordMapper;
+
+    @Resource
+    private HealthCheckinMapper healthCheckinMapper;
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Resource
+    private TaskMapper taskMapper;
+
+    @Resource
+    private PointsLogMapper pointsLogMapper;
+
+    @Resource
+    private EnergyService energyService;
+
+    @Resource
+    private OnboardingService onboardingService;
+
+    @Resource
+    private GrowthTaskService growthTaskService;
+
+    @Resource
+    private SysConfigService sysConfigService;
+
+    /**
+     * 获取所有启用的微行动列表
+     */
+    public List<MicroAction> getAllActions() {
+        return microActionMapper.selectList(
+            new LambdaQueryWrapper<MicroAction>()
+                .eq(MicroAction::getStatus, 1)
+                .orderByAsc(MicroAction::getSortOrder)
+        );
+    }
+
+    /**
+     * 获取今日推荐的微行动
+     * 如果有未使用的新手体验配额,返回随机行动
+     * 如果已完成今日配额,返回null
+     */
+    public MicroAction getTodayAction(Long childId) {
+        // Check if already completed for today
+        MicroActionRecord todayRecord = getTodayRecord(childId);
+        if (todayRecord != null) {
+            return null; // Already completed today
+        }
+        // Get all active actions
+        List<MicroAction> actions = getAllActions();
+        if (actions.isEmpty()) return null;
+
+        // Pick one based on the day of month (consistent for the whole day)
+        Calendar cal = Calendar.getInstance();
+        int dayOfMonth = cal.get(Calendar.DAY_OF_MONTH);
+        int index = dayOfMonth % actions.size();
+        return actions.get(index);
+    }
+
+    /**
+     * 检查今日是否已完成微行动
+     */
+    public boolean hasCompletedToday(Long childId) {
+        return getTodayRecord(childId) != null;
+    }
+
+    /**
+     * 获取今日完成记录
+     */
+    private MicroActionRecord getTodayRecord(Long childId) {
+        if (childId == null) return null;
+        String today = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
+        return microActionRecordMapper.selectOne(
+            new LambdaQueryWrapper<MicroActionRecord>()
+                .eq(MicroActionRecord::getChildId, childId)
+                .apply("DATE(completed_date) = {0}", today)
+                .last("LIMIT 1")
+        );
+    }
+
+    /**
+     * 检查今日是否已健康打卡(互斥逻辑)
+     */
+    private boolean hasHealthCheckinToday(Long childId) {
+        if (childId == null) return false;
+        String today = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
+        HealthCheckin existing = healthCheckinMapper.selectOne(
+            new LambdaQueryWrapper<HealthCheckin>()
+                .eq(HealthCheckin::getChildId, childId)
+                .apply("DATE(checkin_date) = {0}", today)
+                .last("LIMIT 1")
+        );
+        return existing != null;
+    }
+
+    /**
+     * 完成微行动
+     * 与健康打卡互斥:同一天只能选择一种
+     */
+    @Transactional
+    public MicroActionRecord completeAction(Long userId, Long childId, Long actionId) {
+        if (childId == null) {
+            throw new RuntimeException("childId不能为空");
+        }
+
+        // Check if already completed micro action today
+        if (hasCompletedToday(childId)) {
+            throw new RuntimeException("今日微行动已完成,请明天再来");
+        }
+
+        // Check mutual exclusion with health checkin
+        if (hasHealthCheckinToday(childId)) {
+            throw new RuntimeException("今日已健康打卡,已完成今日签到");
+        }
+
+        // Verify the action exists and is active
+        MicroAction action = microActionMapper.selectById(actionId);
+        if (action == null || action.getStatus() != 1) {
+            throw new RuntimeException("微行动不存在或已下架");
+        }
+
+        // Create record
+        MicroActionRecord record = new MicroActionRecord();
+        record.setUserId(userId);
+        record.setChildId(childId);
+        record.setActionId(actionId);
+        record.setCompletedDate(new Date());
+        record.setEarnedEnergy(1);
+        record.setCreatedAt(new Date());
+        microActionRecordMapper.insert(record);
+
+        // Create task in task system (same pattern as health checkin)
+        try {
+            int points = 5;
+            String pointsStr = sysConfigService.getValue("health_checkin_points");
+            if (pointsStr != null && !pointsStr.isEmpty()) {
+                try { points = Integer.parseInt(pointsStr); } catch (NumberFormatException e) { /* use default */ }
+            }
+
+            Task task = new Task();
+            FamilyMember child = familyMemberMapper.selectById(childId);
+            if (child != null) {
+                task.setFamilyId(child.getFamilyId());
+            }
+            task.setCreatorId(userId);
+            task.setExecutorType("child");
+            task.setExecutorId(childId);
+            task.setChildId(childId);
+            task.setTitle("微行动");
+            task.setDescription("自动生成 - " + action.getName());
+            task.setPoints(points);
+            task.setRepeatType("none");
+            task.setCategory("身体类");
+            task.setNeedReview(0);
+            task.setReviewByCategory(0);
+            task.setTaskType("onetime");
+            task.setSourceType("micro_action");
+            task.setSourceId(record.getId());
+            task.setStatus("completed");
+            task.setCompletedAt(new Date());
+            task.setCreatedAt(new Date());
+            task.setUpdatedAt(new Date());
+            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());
+            taskMapper.insert(task);
+
+            // Update child totalPoints
+            if (child != null) {
+                child.setTotalPoints(child.getTotalPoints() + points);
+                child.setUpdatedAt(new Date());
+                familyMemberMapper.updateById(child);
+            }
+
+            // Insert PointsLog
+            PointsLog pointsLog = new PointsLog();
+            pointsLog.setChildId(childId);
+            pointsLog.setTaskId(task.getId());
+            pointsLog.setAmount(points);
+            pointsLog.setType("earn");
+            pointsLog.setCategory("task");
+            pointsLog.setDescription("微行动");
+            pointsLog.setCreatedAt(new Date());
+            pointsLogMapper.insert(pointsLog);
+        } catch (Exception e) {
+            log.warn("微行动任务创建失败: recordId={}, error={}", record.getId(), e.getMessage());
+        }
+
+        // Award energy
+        try {
+            energyService.awardEnergy(childId, "micro_action", record.getId(), 1,
+                    "微行动", 30);
+        } catch (Exception e) {
+            log.warn("微行动能量发放失败: recordId={}, error={}", record.getId(), e.getMessage());
+        }
+
+        // Trigger onboarding and growth tasks
+        try { onboardingService.completeTask(userId, "FIRST_CHECKIN"); } catch (Exception e) { /* ignore */ }
+        try { growthTaskService.updateProgress(userId, "DAILY_CHECKIN", 1); } catch (Exception e) { log.warn("成长任务签到进度更新失败: userId={}", userId); }
+
+        return record;
+    }
+
+    /**
+     * 获取最近N天的微行动记录
+     */
+    public List<MicroActionRecord> getRecentRecords(Long childId, int days) {
+        if (childId == null) return Collections.emptyList();
+        Calendar cal = Calendar.getInstance();
+        cal.add(Calendar.DAY_OF_MONTH, -days);
+        Date since = cal.getTime();
+        return microActionRecordMapper.selectList(
+            new LambdaQueryWrapper<MicroActionRecord>()
+                .eq(MicroActionRecord::getChildId, childId)
+                .ge(MicroActionRecord::getCompletedDate, since)
+                .orderByDesc(MicroActionRecord::getCompletedDate)
+        );
+    }
+}

+ 54 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/PromotionBindingService.java

@@ -0,0 +1,54 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.Family;
+import com.etotem.cfc.entity.PromotionBinding;
+import com.etotem.cfc.mapper.FamilyMapper;
+import com.etotem.cfc.mapper.PromotionBindingMapper;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+
+@Service
+public class PromotionBindingService {
+
+    @Resource
+    private PromotionBindingMapper promotionBindingMapper;
+
+    @Resource
+    private FamilyMapper familyMapper;
+
+    public PromotionBinding bindByInviteCode(Long promoterFamilyId, String inviteCode) {
+        Family invitee = familyMapper.selectOne(
+                new LambdaQueryWrapper<Family>().eq(Family::getInviteCode, inviteCode).last("LIMIT 1")
+        );
+        if (invitee == null) {
+            return null;
+        }
+        Long inviteeFamilyId = invitee.getId();
+        if (promoterFamilyId.equals(inviteeFamilyId)) {
+            return null;
+        }
+        PromotionBinding binding = new PromotionBinding();
+        binding.setPromoterFamilyId(promoterFamilyId);
+        binding.setInviteeFamilyId(inviteeFamilyId);
+        binding.setInviteCode(inviteCode);
+        binding.setBindingType("direct");
+        binding.setStatus("active");
+        binding.setCreatedAt(new Date());
+        promotionBindingMapper.insert(binding);
+        return binding;
+    }
+
+    public List<PromotionBinding> getBindings(Long familyId) {
+        return promotionBindingMapper.selectList(
+                new LambdaQueryWrapper<PromotionBinding>()
+                        .and(w -> w.eq(PromotionBinding::getPromoterFamilyId, familyId)
+                                .or().eq(PromotionBinding::getInviteeFamilyId, familyId))
+                        .orderByDesc(PromotionBinding::getCreatedAt)
+                        .last("LIMIT 100")
+        );
+    }
+}

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

@@ -0,0 +1,45 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.Commission;
+import com.etotem.cfc.mapper.CommissionMapper;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+
+@Service
+public class PromotionCommissionService {
+
+    @Resource
+    private CommissionMapper commissionMapper;
+
+    /**
+     * 记录佣金
+     * @param commission 佣金对象
+     * @return 是否成功
+     */
+    public boolean recordCommission(Commission commission) {
+        if (commission.getCreatedAt() == null) {
+            commission.setCreatedAt(new Date());
+        }
+        commission.setUpdatedAt(new Date());
+        return commissionMapper.insert(commission) > 0;
+    }
+
+    /**
+     * 获取家庭总佣金(分)
+     * @param familyId 家庭ID
+     * @return 总佣金金额
+     */
+    public Integer getFamilyTotal(Long familyId) {
+        Map<String, Object> columnMap = new HashMap<>();
+        columnMap.put("family_id", familyId);
+        
+        return commissionMapper.selectByMap(columnMap)
+                .stream()
+                .mapToInt(Commission::getAmount)
+                .sum();
+    }
+}

+ 10 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/RelationshipQualityService.java

@@ -46,7 +46,7 @@ public class RelationshipQualityService {
             RelationshipScoreVO vo = new RelationshipScoreVO();
             vo.setMemberId(member.getId());
             vo.setNickname(member.getNickname());
-            vo.setRelationshipType(member.getRelationshipType());
+            vo.setEffectiveRole(computeEffectiveRole(member));
             vo.setTrustScore(member.getTrustScore() != null ? member.getTrustScore() : BigDecimal.ZERO);
             vo.setIntimacyScore(member.getIntimacyScore() != null ? member.getIntimacyScore() : BigDecimal.ZERO);
             vo.setCommunicationScore(member.getCommunicationScore() != null ? member.getCommunicationScore() : BigDecimal.ZERO);
@@ -80,7 +80,7 @@ public class RelationshipQualityService {
         RelationshipScoreVO vo = new RelationshipScoreVO();
         vo.setMemberId(member.getId());
         vo.setNickname(member.getNickname());
-        vo.setRelationshipType(member.getRelationshipType());
+        vo.setEffectiveRole(computeEffectiveRole(member));
         vo.setTrustScore(member.getTrustScore() != null ? member.getTrustScore() : BigDecimal.ZERO);
         vo.setIntimacyScore(member.getIntimacyScore() != null ? member.getIntimacyScore() : BigDecimal.ZERO);
         vo.setCommunicationScore(member.getCommunicationScore() != null ? member.getCommunicationScore() : BigDecimal.ZERO);
@@ -115,7 +115,7 @@ public class RelationshipQualityService {
                 HealthAlertVO alert = new HealthAlertVO();
                 alert.setMemberId(member.getId());
                 alert.setNickname(member.getNickname());
-                alert.setRelationshipType(member.getRelationshipType());
+                alert.setEffectiveRole(computeEffectiveRole(member));
 
                 if (lastInteraction == null) {
                     alert.setDaysSinceInteraction(-1);
@@ -207,4 +207,11 @@ public class RelationshipQualityService {
         }
         return "healthy";
     }
+
+    private String computeEffectiveRole(FamilyMember member) {
+        if (member.getGeneration() != null) {
+            return member.getGeneration() >= 0 ? "parent" : "child";
+        }
+        return "parent";
+    }
 }

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

@@ -68,7 +68,7 @@ public class RelationshipQuestionnaireService {
         snapshot.setFamilyMemberId(dto.getMemberId());
         snapshot.setRespondentId(dto.getRespondentId());
         snapshot.setMemberName(member.getNickname());
-        snapshot.setRelationshipType(member.getRelationshipType());
+        snapshot.setRelationshipType(member.getGeneration() != null && member.getGeneration() < 0 ? "child" : "parent");
         snapshot.setAiGeneratedJson(aiJson);
         snapshot.setVersion(version);
         snapshot.setCreatedAt(new Date());

+ 46 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ShareService.java

@@ -2,9 +2,11 @@ package com.etotem.cfc.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.HealthCheckin;
 import com.etotem.cfc.entity.ShareEvent;
 import com.etotem.cfc.entity.ShareRead;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.HealthCheckinMapper;
 import com.etotem.cfc.mapper.ShareEventMapper;
 import com.etotem.cfc.mapper.ShareReadMapper;
 import com.etotem.cfc.service.SysConfigService;
@@ -37,6 +39,9 @@ public class ShareService {
     @Resource
     private FamilyMemberMapper familyMemberMapper;
 
+    @Resource
+    private HealthCheckinMapper healthCheckinMapper;
+
     public ShareEvent createShareEvent(Long userId, String targetType, Long targetId,
             String shareChannel, String title) {
         ShareEvent event = new ShareEvent();
@@ -126,4 +131,45 @@ public class ShareService {
         stats.put("totalRewardPoints", totalRewards);
         return stats;
     }
+
+    public Map<String, Object> getFamilyFootprint(Long familyId) {
+        Map<String, Object> result = new HashMap<>();
+        List<Map<String, Object>> members = new java.util.ArrayList<>();
+        int totalScore = 0;
+        int totalCheckinDays = 0;
+
+        if (familyId != null) {
+            List<FamilyMember> familyMembers = familyMemberMapper.selectList(
+                new LambdaQueryWrapper<FamilyMember>().eq(FamilyMember::getFamilyId, familyId)
+            );
+            for (FamilyMember fm : familyMembers) {
+                Map<String, Object> memberData = new HashMap<>();
+                memberData.put("name", fm.getNickname());
+                memberData.put("avatar", fm.getAvatar());
+
+                List<HealthCheckin> checkins = healthCheckinMapper.selectList(
+                    new LambdaQueryWrapper<HealthCheckin>()
+                        .eq(HealthCheckin::getUserId, fm.getUserId())
+                        .orderByDesc(HealthCheckin::getCheckinDate)
+                );
+                int streakDays = 0;
+                int score = 0;
+                if (checkins != null && !checkins.isEmpty()) {
+                    streakDays = checkins.size();
+                    score = checkins.stream()
+                        .filter(c -> c.getEarnedEnergy() != null)
+                        .mapToInt(HealthCheckin::getEarnedEnergy).sum();
+                }
+                memberData.put("streakDays", streakDays);
+                memberData.put("score", score);
+                members.add(memberData);
+                totalScore += score;
+                totalCheckinDays = Math.max(totalCheckinDays, streakDays);
+            }
+        }
+        result.put("members", members);
+        result.put("totalScore", totalScore);
+        result.put("totalCheckinDays", totalCheckinDays);
+        return result;
+    }
 }

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

@@ -1359,8 +1359,6 @@ private FamilyInvitationService familyInvitationService;
         if (dto.getAge() != null) {
             member.setAge(dto.getAge());
         }
-        member.setRelationshipType(dto.getRelationshipType());
-        member.setRoleOverride(dto.getRoleOverride());
         member.setTotalPoints(0);
         member.setCreatedAt(new Date());
         member.setUpdatedAt(new Date());
@@ -1439,8 +1437,7 @@ private FamilyInvitationService familyInvitationService;
         dto.setGender(member.getGender());
         dto.setPhone(member.getPhone());
         dto.setAvatar(member.getAvatar());
-        dto.setRelationshipType(member.getRelationshipType());
-        dto.setRoleOverride(member.getRoleOverride());
+        dto.setGeneration(member.getGeneration());
         dto.setShowToFamily(member.getShowToFamily());
         dto.setTotalPoints(member.getTotalPoints());
         return dto;

+ 9 - 7
cfc-backend/src/main/java/com/etotem/cfc/service/impl/FortuneServiceImpl.java

@@ -270,18 +270,20 @@ public class FortuneServiceImpl implements FortuneService {
         
         // 占位算法:五行三合
         String[] elements = {"木", "火", "土", "金", "水"};
-        int hash = (int)(familyId ^ (familyId >>> 32) + date.toEpochDay());
-        fortune.setDominantElement(elements[Math.abs(hash) % elements.length]);
+        // 注意:原写法因运算符优先级问题(+ 高于 >>> 且 ^ 低于两者)会导致移位越界,可能造成 ArrayIndexOutOfBoundsException
+        int hash = (int)(familyId.longValue() ^ (familyId.longValue() >>> 32) ^ date.toEpochDay());
+        int idx = Math.floorMod(Math.abs(hash), elements.length);
+        fortune.setDominantElement(elements[idx]);
         
         // 吉位
         fortune.setLuckyDirection(calculateLuckyDirection(familyId, date));
         
         // 运势等级
         String[] fortuneLevels = {"大吉", "吉", "平", "凶", "大凶"};
-        fortune.setFortuneLevel(fortuneLevels[Math.abs(hash) % 5]);
+        fortune.setFortuneLevel(fortuneLevels[Math.floorMod(hash, fortuneLevels.length)]);
         
         // 心情分数
-        fortune.setMoodScore(50 + Math.abs(hash) % 50);
+        fortune.setMoodScore(50 + Math.floorMod(hash, 50));
         
         // 每日提示
         String[] tips = {
@@ -291,12 +293,12 @@ public class FortuneServiceImpl implements FortuneService {
             "健康为本,注意休息",
             "运势平稳,切勿冒险"
         };
-        fortune.setFortuneDescription(tips[Math.abs(hash) % tips.length]);
+        fortune.setFortuneDescription(tips[Math.floorMod(hash, tips.length)]);
         
         // 心情描述
         String[] moods = {"心如止水", "喜悦满溢", "平静如常", "略感烦躁", "情绪低落"};
-        fortune.setMoodEmoji(getMoodEmoji(Math.abs(hash) % 5));
-        fortune.setMoodText(moods[Math.abs(hash) % moods.length]);
+        fortune.setMoodEmoji(getMoodEmoji(Math.floorMod(hash, 5)));
+        fortune.setMoodText(moods[Math.floorMod(hash, moods.length)]);
         
         // 每周提示
         fortune.setWeeklyTip(getWeeklyTip(fortune.getDominantElement()));

+ 207 - 20
cfc-backend/src/main/resources/schema.sql

@@ -321,6 +321,7 @@ CREATE TABLE IF NOT EXISTS assessment_appointments (
     child_id BIGINT COMMENT '孩子ID',
     teacher_id BIGINT COMMENT '教师ID',
     guide_id BIGINT COMMENT '规划师ID',
+    assessor_id BIGINT COMMENT '测评师ID(供应商体系)',
     package_id BIGINT COMMENT '套餐ID',
     appointment_date VARCHAR(20) COMMENT '预约日期 yyyy-MM-dd',
     appointment_time VARCHAR(20) COMMENT '预约时间 HH:mm',
@@ -1727,8 +1728,8 @@ CREATE TABLE IF NOT EXISTS family_members (
     avatar           VARCHAR(500) COMMENT '头像',
     gender           VARCHAR(10)  COMMENT '性别: male/female',
     birthday         DATE         COMMENT '出生日期',
-    relationship_type VARCHAR(50) NOT NULL COMMENT '关系类型key',
-    role_override VARCHAR(20) DEFAULT 'auto' COMMENT '角色覆盖: auto/parent/child/elderly,空=auto',
+    generation INT COMMENT '辈分值(0=家庭创建者,+n=向上,-n=向下)',
+    is_spouse TINYINT(1) DEFAULT 0 COMMENT '是否配偶(同辈中)',
     show_to_family INT DEFAULT 1 COMMENT '对家庭成员可见: 1=可见, 0=不可见',
     total_points INT DEFAULT 0 COMMENT '总积分',
     -- 孩子专属字段(原children表合并)
@@ -1763,24 +1764,7 @@ CREATE TABLE IF NOT EXISTS family_relationships (
     INDEX idx_to (to_member_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭成员双向关系表';
 
--- 关系类型配置表(管理端可配置)
-CREATE TABLE IF NOT EXISTS relationship_types (
-    id               BIGINT AUTO_INCREMENT PRIMARY KEY,
-    type_key         VARCHAR(50)  NOT NULL UNIQUE COMMENT '类型标识: spouse/parent/child/sibling',
-    type_name        VARCHAR(100) NOT NULL COMMENT '类型显示名: 配偶/父母/子女/兄弟姐妹',
-    capability_role  VARCHAR(20)  NOT NULL DEFAULT 'parent' COMMENT '能力角色: parent/child',
-    default_role     VARCHAR(20)  DEFAULT NULL COMMENT '默认角色模板: parent/child(已废弃, 兼容保留)',
-    sort_order       INT DEFAULT 0 COMMENT '排序序号',
-    enabled          INT DEFAULT 1 COMMENT '是否启用: 1=启用, 0=禁用',
-    created_at       DATETIME DEFAULT CURRENT_TIMESTAMP
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='关系类型配置表';
-
--- 默认关系类型数据(default_role: parent/child 为该关系类型的默认角色模板)
-INSERT IGNORE INTO relationship_types (type_key, type_name, default_role, sort_order, enabled) VALUES
-('spouse', '配偶', 'parent', 1, 1),
-('parent', '父母', 'parent', 2, 1),
-('child', '子女', 'child', 3, 1),
-('sibling', '兄弟姐妹', 'child', 4, 1);
+-- 【已废弃】relationship_types 表已删除(generation 辈分模型替代)
 
 -- 用户收货地址表
 CREATE TABLE IF NOT EXISTS user_address (
@@ -2789,6 +2773,7 @@ CREATE TABLE IF NOT EXISTS `dan_assessment_executions` (
     `child_id` BIGINT NOT NULL COMMENT '孩子ID',
     `family_id` BIGINT NOT NULL COMMENT '家庭ID',
     `quota_id` BIGINT COMMENT '额度ID',
+    `appointment_id` BIGINT COMMENT '关联assessment_appointments.id',
     `assessor_id` BIGINT NOT NULL COMMENT '测评师userId',
     `assessor_status` ENUM('pending','completed','rejected') DEFAULT 'pending' COMMENT '测评师状态',
     `assessor_completed_at` DATETIME COMMENT '测评师完成时间',
@@ -2920,3 +2905,205 @@ CREATE TABLE IF NOT EXISTS review_orders (
     UNIQUE KEY uk_order_id (order_id),
     INDEX idx_reviewer_id (reviewer_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动订单评价表';
+
+-- 微行动定义表
+CREATE TABLE IF NOT EXISTS micro_actions (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    code VARCHAR(32) NOT NULL COMMENT '行动编码: DRINK_WATER, DEEP_BREATH, ...',
+    name VARCHAR(50) NOT NULL COMMENT '行动名称',
+    icon VARCHAR(10) COMMENT '图标emoji',
+    description VARCHAR(200) COMMENT '行动描述',
+    sort_order INT DEFAULT 0 COMMENT '排序号',
+    status TINYINT DEFAULT 1 COMMENT '状态: 1启用 0禁用',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_code (code),
+    INDEX idx_status (status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微行动定义(12个1秒健康行动)';
+
+-- 微行动完成记录表
+CREATE TABLE IF NOT EXISTS micro_action_records (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    user_id BIGINT NOT NULL COMMENT '用户ID',
+    child_id BIGINT NOT NULL COMMENT '家庭成员ID',
+    action_id BIGINT NOT NULL COMMENT '行动ID',
+    completed_date DATETIME NOT NULL COMMENT '完成日期',
+    earned_energy INT DEFAULT 1 COMMENT '获得能量值',
+    note VARCHAR(200) COMMENT '备注',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_child_date (child_id, completed_date),
+    INDEX idx_user_id (user_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微行动完成记录';
+
+-- 每日反馈表(AI/预设健康反馈)
+CREATE TABLE IF NOT EXISTS daily_feedback (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    user_id BIGINT NOT NULL COMMENT '用户ID',
+    child_id BIGINT NOT NULL COMMENT '家庭成员ID',
+    feedback_date DATETIME NOT NULL COMMENT '反馈日期',
+    feedback_type VARCHAR(20) DEFAULT 'daily' COMMENT '反馈类型: daily/weekly/milestone',
+    content VARCHAR(500) NOT NULL COMMENT 'AI生成的反馈文本',
+    milestone_event VARCHAR(100) COMMENT '关联的里程碑事件',
+    is_read TINYINT DEFAULT 0 COMMENT '是否已读: 0未读 1已读',
+    source VARCHAR(20) DEFAULT 'preset' COMMENT '来源: ai_generated / preset',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_child_date (child_id, feedback_date),
+    INDEX idx_feedback_type (feedback_type)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='每日健康反馈';
+
+-- 健康预警记录表(P1-2 健康行为智能预警)
+CREATE TABLE IF NOT EXISTS health_alerts (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    user_id BIGINT COMMENT '用户ID',
+    child_id BIGINT NOT NULL COMMENT '家庭成员ID',
+    rule_code VARCHAR(10) NOT NULL COMMENT '规则编码: R001-R006',
+    alert_text VARCHAR(200) NOT NULL COMMENT '预警文案',
+    action_suggestion VARCHAR(200) COMMENT '行动建议',
+    severity VARCHAR(10) DEFAULT 'medium' COMMENT '严重程度: high/medium/low',
+    dismissed TINYINT DEFAULT 0 COMMENT '是否已关闭: 0未关闭 1已关闭',
+    dismissed_at DATETIME COMMENT '关闭时间',
+    alert_date DATETIME NOT NULL COMMENT '预警日期',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_child_date (child_id, alert_date),
+    INDEX idx_rule (rule_code)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康预警记录';
+
+-- 健康等级表(P2-1 身份重塑)
+CREATE TABLE IF NOT EXISTS health_level (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    level_num INT NOT NULL COMMENT '等级编号 1-5',
+    title VARCHAR(50) NOT NULL COMMENT '称号',
+    badge_icon VARCHAR(20) COMMENT '徽章图标',
+    min_score INT DEFAULT 0 COMMENT '所需最低健康积分',
+    max_score INT DEFAULT 99999 COMMENT '最高积分上限'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康等级定义';
+
+-- 健康时间线事件表(P2-1 我的健康故事)
+CREATE TABLE IF NOT EXISTS health_timeline_event (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    user_id BIGINT COMMENT '用户ID',
+    child_id BIGINT NOT NULL COMMENT '家庭成员ID',
+    event_type VARCHAR(30) NOT NULL COMMENT '事件类型: streak_7/level_2/first_report',
+    event_text VARCHAR(200) NOT NULL COMMENT '事件描述',
+    ref_id VARCHAR(50) COMMENT '关联ID',
+    event_date DATETIME NOT NULL COMMENT '事件日期',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_child_date (child_id, event_date),
+    INDEX idx_event_type (event_type)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康时间线事件';
+
+-- 家庭挑战表(P2-2 家庭健康互动)
+CREATE TABLE IF NOT EXISTS family_challenge (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    family_id BIGINT NOT NULL COMMENT '家庭ID',
+    challenge_type VARCHAR(50) NOT NULL COMMENT 'full_checkin/sports/health_week',
+    title VARCHAR(200) NOT NULL COMMENT '挑战标题',
+    description TEXT COMMENT '挑战描述',
+    duration_days INT DEFAULT 7 COMMENT '持续天数',
+    reward_points INT DEFAULT 0 COMMENT '奖励积分',
+    reward_badge VARCHAR(100) COMMENT '奖励徽章',
+    start_date DATE COMMENT '开始日期',
+    end_date DATE COMMENT '结束日期',
+    status VARCHAR(20) DEFAULT 'active' COMMENT 'active/completed/cancelled',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    INDEX idx_family_id (family_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭挑战';
+
+-- 家庭挑战进度表(P2-2 挑战进度)
+CREATE TABLE IF NOT EXISTS family_challenge_progress (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    challenge_id BIGINT NOT NULL COMMENT '挑战ID',
+    child_id BIGINT NOT NULL COMMENT '家庭成员ID',
+    progress_value INT DEFAULT 0 COMMENT '进度值',
+    completed TINYINT(1) DEFAULT 0 COMMENT '是否完成',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    INDEX idx_challenge_id (challenge_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭挑战进度';
+
+-- 健康互动记录表(P2-2 点赞)
+CREATE TABLE IF NOT EXISTS health_interaction (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    family_id BIGINT NOT NULL COMMENT '家庭ID',
+    from_user_id BIGINT NOT NULL COMMENT '点赞用户ID',
+    to_user_id BIGINT NOT NULL COMMENT '被赞用户ID',
+    action_type VARCHAR(50) NOT NULL COMMENT '行为类型',
+    action_id BIGINT COMMENT '关联行为记录ID',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    INDEX idx_family_action (family_id, action_type, action_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康互动记录(点赞)';
+
+-- 健康圈表(P3 健康社区扩散)
+CREATE TABLE IF NOT EXISTS health_circle (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    name VARCHAR(100) NOT NULL COMMENT '圈名称',
+    description VARCHAR(500) COMMENT '圈描述',
+    creator_family_id BIGINT COMMENT '创建家庭ID',
+    invite_code VARCHAR(32) COMMENT '邀请码',
+    member_limit INT DEFAULT 50 COMMENT '成员上限',
+    status VARCHAR(20) DEFAULT 'active' COMMENT '状态 active/closed',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    INDEX idx_invite_code (invite_code),
+    INDEX idx_creator (creator_family_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康圈';
+
+-- 健康圈成员表(P3)
+CREATE TABLE IF NOT EXISTS health_circle_member (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    circle_id BIGINT NOT NULL COMMENT '圈ID',
+    family_id BIGINT NOT NULL COMMENT '家庭ID',
+    role VARCHAR(20) DEFAULT 'member' COMMENT '角色 member/admin',
+    joined_at DATETIME COMMENT '加入时间',
+    left_at DATETIME COMMENT '退出时间',
+    status VARCHAR(20) DEFAULT 'active' COMMENT '状态 active/inactive',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    INDEX idx_circle_id (circle_id),
+    INDEX idx_family_id (family_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康圈成员';
+
+-- 圈挑战表(P3)
+CREATE TABLE IF NOT EXISTS circle_challenge (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    circle_id BIGINT NOT NULL COMMENT '圈ID',
+    challenge_type VARCHAR(50) NOT NULL COMMENT '挑战类型 checkin/step/meditation',
+    title VARCHAR(200) NOT NULL COMMENT '挑战标题',
+    description VARCHAR(500) COMMENT '挑战描述',
+    duration_days INT DEFAULT 7 COMMENT '持续天数',
+    period VARCHAR(20) DEFAULT 'weekly' COMMENT '周期 weekly/monthly',
+    start_date DATETIME COMMENT '开始日期',
+    end_date DATETIME COMMENT '结束日期',
+    status VARCHAR(20) DEFAULT 'active' COMMENT '状态 active/completed/cancelled',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    INDEX idx_circle_id (circle_id),
+    INDEX idx_status (status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='圈挑战';
+
+-- 推广佣金记录表(P2-PROMO)
+CREATE TABLE IF NOT EXISTS family_commission (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    promoter_family_id BIGINT NOT NULL COMMENT '推广方家庭ID',
+    buyer_family_id BIGINT NOT NULL COMMENT '购买方家庭ID',
+    order_id BIGINT COMMENT '关联订单ID',
+    amount DECIMAL(10,2) DEFAULT 0.00 COMMENT '订单金额',
+    commission_rate DECIMAL(5,2) DEFAULT 10.00 COMMENT '佣金比例%',
+    commission_amount DECIMAL(10,2) DEFAULT 0.00 COMMENT '佣金金额',
+    status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending/paid/cancelled',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    paid_at DATETIME COMMENT '支付时间',
+    INDEX idx_promoter (promoter_family_id),
+    INDEX idx_buyer (buyer_family_id),
+    INDEX idx_order (order_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭推广佣金记录';
+
+-- 推广绑定关系表(P2-PROMO)
+CREATE TABLE IF NOT EXISTS promotion_binding (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    promoter_family_id BIGINT NOT NULL COMMENT '推广方家庭ID',
+    invitee_family_id BIGINT NOT NULL COMMENT '被邀请方家庭ID',
+    invite_code VARCHAR(32) COMMENT '使用的邀请码',
+    binding_type VARCHAR(20) DEFAULT 'direct' COMMENT 'direct/indirect',
+    status VARCHAR(20) DEFAULT 'active' COMMENT 'active/inactive',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    UNIQUE KEY uk_promoter_invitee (promoter_family_id, invitee_family_id),
+    INDEX idx_promoter (promoter_family_id),
+    INDEX idx_invitee (invitee_family_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='推广绑定关系';

+ 13 - 28
cfc-backend/src/test/java/com/etotem/cfc/controller/family/FamilyMembersControllerTest.java

@@ -4,7 +4,6 @@ import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.AddFamilyMemberDTO;
 import com.etotem.cfc.dto.FamilyMemberVO;
 import com.etotem.cfc.dto.SwitchMemberVO;
-import com.etotem.cfc.entity.RelationshipType;
 import com.etotem.cfc.service.FamilyMemberService;
 import org.junit.jupiter.api.Test;
 import org.springframework.boot.test.context.SpringBootTest;
@@ -40,18 +39,19 @@ public class FamilyMembersControllerTest {
         return request;
     }
 
-    private FamilyMemberVO mockMemberVO(Long id, String nickname, String relationshipType) {
+    private FamilyMemberVO mockMemberVO(Long id, String nickname, String effectiveRole) {
         FamilyMemberVO vo = new FamilyMemberVO();
         vo.setId(id);
         vo.setNickname(nickname);
-        vo.setRelationshipType(relationshipType);
+        vo.setEffectiveRole(effectiveRole);
         return vo;
     }
 
-    private AddFamilyMemberDTO mockAddDTO(String nickname, String relationshipType) {
+    private AddFamilyMemberDTO mockAddDTO(String nickname, String generationLevel) {
         AddFamilyMemberDTO dto = new AddFamilyMemberDTO();
         dto.setNickname(nickname);
-        dto.setRelationshipType(relationshipType);
+        dto.setGenerationLevel(generationLevel);
+        dto.setRelativeMemberId(1L);
         return dto;
     }
 
@@ -60,10 +60,10 @@ public class FamilyMembersControllerTest {
     @Test
     public void addMember_Success() {
         MockHttpServletRequest request = mockRequest(1L);
-        AddFamilyMemberDTO dto = mockAddDTO("小明", "son");
+        AddFamilyMemberDTO dto = mockAddDTO("小明", "child");
 
         when(familyMemberService.addMember(eq(1L), any(AddFamilyMemberDTO.class)))
-            .thenReturn(mockMemberVO(100L, "小明", "son"));
+            .thenReturn(mockMemberVO(100L, "小明", "child"));
 
         Result<FamilyMemberVO> result = controller.addMember(request, dto);
         assertEquals(200, result.getCode());
@@ -73,7 +73,7 @@ public class FamilyMembersControllerTest {
     @Test
     public void addMember_NotLoggedIn() {
         MockHttpServletRequest request = new MockHttpServletRequest();
-        AddFamilyMemberDTO dto = mockAddDTO("小明", "son");
+        AddFamilyMemberDTO dto = mockAddDTO("小明", "child");
 
         Result<FamilyMemberVO> result = controller.addMember(request, dto);
         assertEquals(500, result.getCode());
@@ -86,11 +86,11 @@ public class FamilyMembersControllerTest {
         AddFamilyMemberDTO dto = mockAddDTO("", "");
 
         when(familyMemberService.addMember(eq(1L), any(AddFamilyMemberDTO.class)))
-            .thenThrow(new IllegalArgumentException("关系类型不能为空"));
+            .thenThrow(new IllegalArgumentException("辈分等级不能为空"));
 
         Result<FamilyMemberVO> result = controller.addMember(request, dto);
         assertEquals(500, result.getCode());
-        assertTrue(result.getMessage().contains("关系类型不能为空"));
+        assertTrue(result.getMessage().contains("辈分等级不能为空"));
     }
 
     // ==================== listMembers ====================
@@ -99,8 +99,8 @@ public class FamilyMembersControllerTest {
     public void listMembers_Success() {
         MockHttpServletRequest request = mockRequest(1L);
         List<FamilyMemberVO> members = Arrays.asList(
-            mockMemberVO(1L, "爸爸", "father"),
-            mockMemberVO(2L, "妈妈", "mother")
+            mockMemberVO(1L, "爸爸", "parent"),
+            mockMemberVO(2L, "妈妈", "parent")
         );
 
         when(familyMemberService.listMembers(1L)).thenReturn(members);
@@ -180,7 +180,7 @@ public class FamilyMembersControllerTest {
         body.put("nickname", "新名字");
 
         when(familyMemberService.updateMember(eq(1L), eq(5L), any(AddFamilyMemberDTO.class)))
-            .thenReturn(mockMemberVO(5L, "新名字", "son"));
+            .thenReturn(mockMemberVO(5L, "新名字", "parent"));
 
         Result<FamilyMemberVO> result = controller.updateMember(request, body);
         assertEquals(200, result.getCode());
@@ -197,19 +197,4 @@ public class FamilyMembersControllerTest {
         assertTrue(result.getMessage().contains("缺少成员ID"));
     }
 
-    // ==================== getRelationshipTypes ====================
-
-    @Test
-    public void getRelationshipTypes_Success() {
-        List<RelationshipType> types = Arrays.asList(
-            new RelationshipType() {{ setTypeKey("father"); setTypeName("爸爸"); }},
-            new RelationshipType() {{ setTypeKey("mother"); setTypeName("妈妈"); }}
-        );
-
-        when(familyMemberService.getRelationshipTypes()).thenReturn(types);
-
-        Result<List<RelationshipType>> result = controller.getRelationshipTypes();
-        assertEquals(200, result.getCode());
-        assertEquals(2, result.getData().size());
-    }
 }

+ 9 - 49
cfc-backend/src/test/java/com/etotem/cfc/integration/FamilyMemberManagementFlowTest.java

@@ -5,7 +5,6 @@ import com.etotem.cfc.controller.family.FamilyMembersController;
 import com.etotem.cfc.dto.AddFamilyMemberDTO;
 import com.etotem.cfc.dto.FamilyMemberVO;
 import com.etotem.cfc.dto.SwitchMemberVO;
-import com.etotem.cfc.entity.RelationshipType;
 import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.service.FamilyMemberService;
@@ -67,38 +66,7 @@ public class FamilyMemberManagementFlowTest {
     private static final Long FAMILY_ID = 3001L;
     private static final String SWITCH_TOKEN = "switch_token_abc123";
 
-    // ========== 场景1:获取关系类型列表 ==========
-
-    /**
-     * 场景:管理员进入添加成员页面,需要选择关系类型
-     * 角色:家长(管理员)
-     * 触发条件:打开添加家庭成员页面
-     */
-    @Test
-    @DisplayName("[场景1] 获取关系类型列表 - 期望返回可用的关系类型")
-    void parentGetsRelationshipTypes_Success() {
-        // === Given:系统存在预置的关系类型 ===
-        List<RelationshipType> mockTypes = Arrays.asList(
-                createRelationshipType(1L, "父亲", "parent", 1),
-                createRelationshipType(2L, "母亲", "parent", 2),
-                createRelationshipType(3L, "儿子", "child", 3),
-                createRelationshipType(4L, "女儿", "child", 4),
-                createRelationshipType(5L, "爷爷", "elder", 5),
-                createRelationshipType(6L, "奶奶", "elder", 6)
-        );
-        when(familyMemberService.getRelationshipTypes()).thenReturn(mockTypes);
-
-        // === When:请求获取关系类型列表 ===
-        Result<List<RelationshipType>> result = familyMembersController.getRelationshipTypes();
-
-        // === Then:Milestone-1 - 返回完整的关系类型 ===
-        assertEquals(200, result.getCode());
-        assertNotNull(result.getData());
-        assertEquals(6, result.getData().size());
-        verify(familyMemberService).getRelationshipTypes();
-    }
-
-    // ========== 场景2:添加新家庭成员 ==========
+    // ========== 场景1:添加新家庭成员 ==========
 
     /**
      * 场景:家长添加一个新的孩子成员到家庭
@@ -113,7 +81,8 @@ public class FamilyMemberManagementFlowTest {
         addDTO.setNickname("王小明");
         addDTO.setGender("男");
         addDTO.setBirthday("2015-06-15");
-        addDTO.setRelationshipType("child");
+        addDTO.setGenerationLevel("child");
+        addDTO.setRelativeMemberId(1L);
 
         FamilyMemberVO mockMember = createFamilyMemberVO(CHILD_MEMBER_ID, "王小明", "child");
         when(familyMemberService.addMember(eq(PARENT_USER_ID), any(AddFamilyMemberDTO.class)))
@@ -128,7 +97,7 @@ public class FamilyMemberManagementFlowTest {
         assertNotNull(result.getData());
         assertNotNull(result.getData().getId());
         assertEquals("王小明", result.getData().getNickname());
-        assertEquals("child", result.getData().getRelationshipType());
+        assertEquals("child", result.getData().getEffectiveRole());
         verify(familyMemberService).addMember(eq(PARENT_USER_ID), any(AddFamilyMemberDTO.class));
     }
 
@@ -143,7 +112,8 @@ public class FamilyMemberManagementFlowTest {
         // === Given:家长未填写成员姓名 ===
         AddFamilyMemberDTO addDTO = new AddFamilyMemberDTO();
         addDTO.setNickname(""); // 空姓名
-        addDTO.setRelationshipType("child");
+        addDTO.setGenerationLevel("child");
+        addDTO.setRelativeMemberId(1L);
 
         when(familyMemberService.addMember(eq(PARENT_USER_ID), any(AddFamilyMemberDTO.class)))
                 .thenThrow(new IllegalArgumentException("姓名不能为空"));
@@ -173,7 +143,7 @@ public class FamilyMemberManagementFlowTest {
                 createFamilyMemberVO(1L, "张爸爸", "parent"),
                 createFamilyMemberVO(2L, "李妈妈", "parent"),
                 createFamilyMemberVO(CHILD_MEMBER_ID, "王小明", "child"),
-                createFamilyMemberVO(4L, "王爷爷", "elder")
+                createFamilyMemberVO(4L, "王爷爷", "parent")
         );
         when(familyMemberService.listMembers(PARENT_USER_ID)).thenReturn(mockMembers);
 
@@ -354,21 +324,11 @@ public class FamilyMemberManagementFlowTest {
 
     // ========== Helper Methods ==========
 
-    private RelationshipType createRelationshipType(Long id, String typeName, String typeKey, Integer sortOrder) {
-        RelationshipType type = new RelationshipType();
-        type.setId(id);
-        type.setTypeName(typeName);
-        type.setTypeKey(typeKey);
-        type.setSortOrder(sortOrder);
-        type.setEnabled(1);
-        return type;
-    }
-
-    private FamilyMemberVO createFamilyMemberVO(Long id, String nickname, String relationshipType) {
+    private FamilyMemberVO createFamilyMemberVO(Long id, String nickname, String effectiveRole) {
         FamilyMemberVO vo = new FamilyMemberVO();
         vo.setId(id);
         vo.setNickname(nickname);
-        vo.setRelationshipType(relationshipType);
+        vo.setEffectiveRole(effectiveRole);
         vo.setGender("男");
         return vo;
     }

+ 0 - 9
cfc-frontend/.postcssrc.js

@@ -1,9 +0,0 @@
-// PostCSS config for uni-app
-const path = require('path');
-module.exports = {
-  plugins: {
-    'autoprefixer': {
-      overrideBrowserslist: ['> 1%', 'last 2 versions', 'not dead']
-    }
-  }
-}

+ 2 - 0
cfc-frontend/components/BaseLoading.vue

@@ -60,7 +60,9 @@ export default {
   font-size: 24rpx;
   color: var(--muted, #94A3B8);
 }
+</style>
 
+<style>
 @keyframes dot-bounce {
   0%, 80%, 100% {
     transform: scale(0.6);

+ 116 - 0
cfc-frontend/components/BodyWealthAlert.vue

@@ -0,0 +1,116 @@
+<template>
+  <view class="body-wealth-alert" v-if="visible" :class="statusClass">
+    <view class="alert-header">
+      <text class="alert-icon">{{ icon }}</text>
+      <text class="alert-title">{{ title }}</text>
+    </view>
+    <view class="alert-body">
+      <text class="alert-message">{{ message }}</text>
+    </view>
+    <view class="alert-footer" v-if="showTip">
+      <text class="alert-tip">{{ tip }}</text>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  name: 'BodyWealthAlert',
+  props: {
+    status: {
+      type: String,
+      default: 'normal',  // normal / overdraw / penalty
+      validator: function(val) {
+        return ['normal', 'overdraw', 'penalty'].indexOf(val) !== -1
+      }
+    },
+    message: {
+      type: String,
+      default: ''
+    },
+    showTip: {
+      type: Boolean,
+      default: true
+    }
+  },
+  computed: {
+    visible() {
+      return this.status !== 'normal'
+    },
+    statusClass() {
+      return 'alert-' + this.status
+    },
+    icon() {
+      if (this.status === 'overdraw') return '⚠️'
+      if (this.status === 'penalty') return '🔒'
+      return '✓'
+    },
+    title() {
+      if (this.status === 'overdraw') return '健康透支警告'
+      if (this.status === 'penalty') return '健康约束生效'
+      return '健康状态正常'
+    },
+    tip() {
+      if (this.status === 'overdraw') return '建议立即调整作息和饮食,恢复健康能量'
+      if (this.status === 'penalty') return '健康能量不足将影响财富维度发展,请优先关注健康'
+      return ''
+    }
+  }
+}
+</script>
+
+<style scoped>
+.body-wealth-alert {
+  margin: 20rpx;
+  padding: 24rpx;
+  border-radius: 12rpx;
+  border-left: 6rpx solid;
+  background: #fff;
+  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.08);
+}
+.alert-normal {
+  border-left-color: #52c41a;
+  background: #f6ffed;
+}
+.alert-overdraw {
+  border-left-color: #faad14;
+  background: #fffbe6;
+}
+.alert-penalty {
+  border-left-color: #ff4d4f;
+  background: #fff1f0;
+}
+.alert-header {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-bottom: 12rpx;
+}
+.alert-icon {
+  font-size: 32rpx;
+  margin-right: 12rpx;
+}
+.alert-title {
+  font-size: 30rpx;
+  font-weight: bold;
+  color: #333;
+}
+.alert-body {
+  margin-bottom: 12rpx;
+}
+.alert-message {
+  font-size: 26rpx;
+  color: #666;
+  line-height: 1.6;
+}
+.alert-footer {
+  border-top: 1rpx solid rgba(0,0,0,0.1);
+  padding-top: 12rpx;
+  margin-top: 12rpx;
+}
+.alert-tip {
+  font-size: 24rpx;
+  color: #999;
+  font-style: italic;
+}
+</style>

+ 70 - 0
cfc-frontend/components/CircleRanking.vue

@@ -0,0 +1,70 @@
+<template>
+  <view class="circle-ranking" v-if="circleName">
+    <view class="cr-header">
+      <text class="cr-title">🏅 {{ circleName }}</text>
+    </view>
+    <view class="cr-list">
+      <view class="cr-item" v-for="(item, idx) in rankings" :key="idx">
+        <text class="cr-rank">{{ '🥇🥈🥉'[idx] || '#' + (idx + 1) }}</text>
+        <view class="cr-info">
+          <text class="cr-family">家庭 {{ idx + 1 }}</text>
+          <text class="cr-members">{{ item.memberCount || 0 }} 位成员</text>
+        </view>
+        <text class="cr-score">{{ item.totalScore || 0 }} 分</text>
+      </view>
+    </view>
+  </view>
+  <view v-else-if="!circleName && !loading" class="cr-empty">
+    <text class="cr-empty-text">暂无健康圈数据</text>
+  </view>
+</template>
+
+<script>
+export default {
+  props: {
+    circleName: { type: String, default: '' },
+    rankings: { type: Array, default: function() { return [] } },
+    loading: { type: Boolean, default: false }
+  }
+}
+</script>
+
+<style scoped>
+.circle-ranking {
+  background: #fff;
+  border-radius: 20rpx;
+  margin: 0 20rpx 20rpx 20rpx;
+  padding: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+}
+.cr-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16rpx;
+}
+.cr-title {
+  font-size: 30rpx;
+  font-weight: 700;
+  color: #1E293B;
+}
+.cr-item {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+  padding: 12rpx 0;
+  border-bottom: 1rpx solid #F1F5F9;
+}
+.cr-item:last-child { border-bottom: none; }
+.cr-rank { font-size: 32rpx; width: 48rpx; text-align: center; }
+.cr-info { flex: 1; min-width: 0; }
+.cr-family { font-size: 26rpx; font-weight: 600; color: #334155; display: block; }
+.cr-members { font-size: 20rpx; color: #94A3B8; margin-top: 2rpx; display: block; }
+.cr-score { font-size: 30rpx; font-weight: 700; color: #6366F1; }
+.cr-empty {
+  padding: 60rpx 20rpx;
+  display: flex;
+  justify-content: center;
+}
+.cr-empty-text { font-size: 26rpx; color: #94A3B8; }
+</style>

+ 3 - 0
cfc-frontend/components/ConfettiCelebration.vue

@@ -79,6 +79,9 @@ export default {
   animation-timing-function: ease-out;
   animation-fill-mode: forwards;
 }
+</style>
+
+<style>
 @keyframes fall {
   0% {
     opacity: 1;

+ 1 - 1
cfc-frontend/components/ContactCard.vue

@@ -46,9 +46,9 @@
           <view class="modal-btn cancel" @click="showInviteModal = false">取消</view>
           <view class="modal-btn confirm" @click="confirmInvite">确认邀请</view>
         </view>
-      </view>
     </view>
   </view>
+  </view>
 </template>
 
 <script>

+ 84 - 0
cfc-frontend/components/DailySummary.vue

@@ -0,0 +1,84 @@
+<template>
+  <view class="daily-summary" v-if="feedback">
+    <view class="ds-header">
+      <text class="ds-label">💬 健康小语</text>
+      <text class="ds-dismiss" @click="dismiss">×</text>
+    </view>
+    <text class="ds-content">{{ feedback.content }}</text>
+    <view class="ds-footer">
+      <text class="ds-source">{{ feedback.source === 'ai_generated' ? 'AI生成' : '暖心提醒' }}</text>
+    </view>
+  </view>
+</template>
+
+<script>
+import api from '@/utils/api.js'
+export default {
+  props: {
+    childId: { type: Number, default: null }
+  },
+  data: function() {
+    return {
+      feedback: null,
+      dismissed: false
+    }
+  },
+  created: function() {
+    this.loadFeedback()
+  },
+  methods: {
+    loadFeedback: function() {
+      if (!this.childId) return
+      var self = this
+      api.getTodayFeedback({ childId: self.childId }).then(function(res) {
+        if (res.data && res.data.feedback) {
+          self.feedback = res.data.feedback
+        }
+      }).catch(function() {
+        // silent fail
+      })
+    },
+    dismiss: function() {
+      if (this.feedback && this.feedback.id) {
+        api.markFeedbackRead({ id: this.feedback.id })
+      }
+      this.feedback = null
+      this.$emit('dismiss')
+    }
+  },
+  watch: {
+    childId: function() {
+      this.feedback = null
+      this.loadFeedback()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.daily-summary {
+  background: linear-gradient(135deg, #EBF5FF, #E0F2FE);
+  border-radius: 20rpx;
+  padding: 20rpx 24rpx;
+  margin: 20rpx 20rpx 0 20rpx;
+  border: 1rpx solid #BAE6FD;
+  position: relative;
+}
+.ds-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 8rpx;
+}
+.ds-label { font-size: 24rpx; font-weight: 600; color: #0369A1; }
+.ds-dismiss {
+  font-size: 36rpx;
+  color: #999;
+  padding: 0 8rpx;
+  line-height: 1;
+}
+.ds-dismiss:active { color: #666; }
+.ds-content { font-size: 28rpx; color: #333; line-height: 1.6; }
+.ds-footer { margin-top: 8rpx; }
+.ds-source { font-size: 20rpx; color: #94A3B8; }
+</style>

+ 117 - 0
cfc-frontend/components/FamilyChallengeCard.vue

@@ -0,0 +1,117 @@
+<template>
+  <view class="challenge-card" v-if="challenge">
+    <view class="card-header">
+      <text class="card-title">{{ challenge.title }}</text>
+      <text class="card-status" :class="'status-' + (challenge.status || 'active')">
+        {{ challenge.status === 'active' ? '进行中' : '已完成' }}
+      </text>
+    </view>
+    <text class="card-desc">{{ challenge.description || '' }}</text>
+    <view class="card-detail-row" v-if="challenge.durationDays">
+      <text class="detail-label">⏱ 期限</text>
+      <text class="detail-val">{{ challenge.durationDays }}天</text>
+    </view>
+    <view class="card-detail-row" v-if="challenge.endDate">
+      <text class="detail-label">📅 截止</text>
+      <text class="detail-val">{{ formatDate(challenge.endDate) }}</text>
+    </view>
+    <view class="card-detail-row" v-if="challenge.rewardPoints">
+      <text class="detail-label">🏆 奖励</text>
+      <text class="detail-val reward">+{{ challenge.rewardPoints }}分</text>
+      <text class="detail-val badge" v-if="challenge.rewardBadge"> 🎖️ {{ challenge.rewardBadge }}</text>
+    </view>
+    <view class="card-progress" v-if="progressPercent !== undefined">
+      <view class="progress-bar-bg">
+        <view class="progress-bar-fill" :style="{ width: Math.min(progressPercent, 100) + '%' }"></view>
+      </view>
+      <text class="progress-text">{{ progressPercent }}%</text>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  props: {
+    challenge: { type: Object, default: null },
+    progressPercent: { type: Number, default: undefined }
+  },
+  methods: {
+    formatDate: function(dateStr) {
+      if (!dateStr) return ''
+      return String(dateStr).slice(0, 10)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.challenge-card {
+  background: #fff;
+  border-radius: 20rpx;
+  margin: 0 20rpx 20rpx 20rpx;
+  padding: 24rpx;
+  border-left: 6rpx solid #10B981;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+}
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 8rpx;
+}
+.card-title {
+  font-size: 28rpx;
+  font-weight: 700;
+  color: #1E293B;
+}
+.card-status {
+  font-size: 22rpx;
+  padding: 4rpx 12rpx;
+  border-radius: 999rpx;
+}
+.status-active { background: #DCFCE7; color: #16A34A; }
+.status-completed { background: #F1F5F9; color: #64748B; }
+.card-desc {
+  font-size: 24rpx;
+  color: #64748B;
+  line-height: 1.5;
+  margin-bottom: 12rpx;
+  display: block;
+}
+.card-detail-row {
+  display: flex;
+  align-items: center;
+  gap: 8rpx;
+  margin-top: 6rpx;
+}
+.detail-label { font-size: 24rpx; color: #94A3B8; }
+.detail-val { font-size: 24rpx; color: #334155; }
+.detail-val.reward { color: #F97316; font-weight: 600; }
+.detail-val.badge { color: #6366F1; }
+.card-progress {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+  margin-top: 12rpx;
+}
+.progress-bar-bg {
+  flex: 1;
+  height: 12rpx;
+  background: #F1F5F9;
+  border-radius: 999rpx;
+  overflow: hidden;
+}
+.progress-bar-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #10B981, #34D399);
+  border-radius: 999rpx;
+  transition: width 0.5s ease;
+}
+.progress-text {
+  font-size: 22rpx;
+  color: #10B981;
+  font-weight: 600;
+  min-width: 48rpx;
+  text-align: right;
+}
+</style>

+ 104 - 0
cfc-frontend/components/FamilyHealthRanking.vue

@@ -0,0 +1,104 @@
+<template>
+  <view class="family-ranking" v-if="list && list.length > 0">
+    <view class="ranking-header">
+      <text class="ranking-title">👨‍👩‍👧‍👦 家庭健康榜</text>
+      <text class="ranking-total">全家积分: {{ totalScore }}</text>
+    </view>
+    <view class="ranking-list">
+      <view class="ranking-item" v-for="(m, idx) in list" :key="m.childId">
+        <text class="rank-medal">{{ idx === 0 ? '🥇' : idx === 1 ? '🥈' : idx === 2 ? '🥉' : '#' + m.rank }}</text>
+        <image class="rank-avatar" :src="m.avatar || '/static/default-avatar.png'" mode="aspectFill"></image>
+        <view class="rank-info">
+          <text class="rank-name">{{ m.name }}</text>
+          <text class="rank-level">{{ m.levelTitle || '' }}</text>
+        </view>
+        <view class="rank-score-wrap">
+          <text class="rank-score">{{ m.score }}</text>
+          <text class="rank-unit">分</text>
+        </view>
+        <text class="rank-streak" v-if="m.streakDays && m.streakDays > 0">🔥 {{ m.streakDays }}天</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  props: {
+    list: { type: Array, default: function() { return [] } },
+    totalScore: { type: Number, default: 0 }
+  }
+}
+</script>
+
+<style scoped>
+.family-ranking {
+  background: #fff;
+  border-radius: 20rpx;
+  margin: 0 20rpx 20rpx 20rpx;
+  padding: 20rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+}
+.ranking-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16rpx;
+}
+.ranking-title {
+  font-size: 30rpx;
+  font-weight: 700;
+  color: #1E293B;
+}
+.ranking-total {
+  font-size: 24rpx;
+  color: #F97316;
+  font-weight: 600;
+}
+.ranking-item {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+  padding: 12rpx 0;
+  border-bottom: 1rpx solid #F1F5F9;
+}
+.ranking-item:last-child { border-bottom: none; }
+.rank-medal { font-size: 32rpx; width: 48rpx; text-align: center; }
+.rank-avatar {
+  width: 56rpx;
+  height: 56rpx;
+  border-radius: 50%;
+  flex-shrink: 0;
+}
+.rank-info { flex: 1; min-width: 0; }
+.rank-name {
+  font-size: 26rpx;
+  font-weight: 600;
+  color: #334155;
+}
+.rank-level {
+  font-size: 20rpx;
+  color: #94A3B8;
+  margin-top: 2rpx;
+  display: block;
+}
+.rank-score-wrap {
+  display: flex;
+  align-items: baseline;
+  gap: 4rpx;
+}
+.rank-score {
+  font-size: 32rpx;
+  font-weight: 700;
+  color: #10B981;
+}
+.rank-unit {
+  font-size: 22rpx;
+  color: #94A3B8;
+}
+.rank-streak {
+  font-size: 22rpx;
+  color: #F97316;
+  white-space: nowrap;
+}
+</style>

+ 30 - 15
cfc-frontend/components/FamilyRelationGraph.vue

@@ -219,17 +219,25 @@ export default {
     }
   },
   mounted: function() {
+    this._destroyed = false
     var self = this
-    this.dpr = uni.getSystemInfoSync().pixelRatio || 1
-
+    try {
+      this.dpr = uni.getSystemInfoSync().pixelRatio || 1
+    } catch (e) {
+      this.dpr = 1
+    }
     this.$nextTick(function() {
+      if (self._destroyed) return
       self.updateCanvasSize()
       self.$nextTick(function() {
-        self.initSimulation()
+        if (!self._destroyed) {
+          self.initSimulation()
+        }
       })
     })
   },
   beforeDestroy: function() {
+    this._destroyed = true
     this.stopSimulation()
   },
   methods: {
@@ -281,10 +289,13 @@ export default {
       }
     },
 
-    simulationTick: function() {
-      if (!this.isSimulating) return
-
-      var self = this
+simulationTick: function() {
+    if (!this.isSimulating) return
+    if (this._destroyed) {
+      this.isSimulating = false
+      return
+    }
+    var self = this
         var nodes = this.nodeData
         var edges = this.edges
       var p = this.physics
@@ -500,14 +511,16 @@ export default {
       }
     },
 
-    // 降级渲染 (CanvasContext)
-    renderLegacy: function() {
+    // WeChat mini-program compatible fallback renderer.
+  // Note: uni-app legacy canvas uses setFontSize(size), not setFont().
+  renderLegacy: function() {
+    if (this._destroyed) return
+    try {
       var ctx = uni.createCanvasContext('forceGraphCanvas', this)
       var self = this
 
       ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
 
-      // 边
       var edges = this.edges
       for (var i = 0; i < edges.length; i++) {
         var edge = edges[i]
@@ -522,7 +535,6 @@ export default {
         ctx.stroke()
       }
 
-      // 节点
       var nodes = this.nodeData
       for (var j = 0; j < nodes.length; j++) {
         var node = nodes[j]
@@ -542,18 +554,21 @@ export default {
         }
 
         ctx.setFillStyle(node.isSelf ? this.themeColor : '#4A5568')
-        ctx.font = 'bold ' + (r * 0.9) + 'px sans-serif'
+        ctx.setFontSize(Math.max(10, Math.round(r * 0.9)))
         ctx.setTextAlign('center')
         ctx.setTextBaseline('middle')
         ctx.fillText(node.displayName, node.x, node.y)
 
         ctx.setFillStyle(node.isSelf ? this.themeColor : '#94A3B8')
-        ctx.font = (r * 0.4) + 'px sans-serif'
-        ctx.fillText(node.nickname.substring(0, 3), node.x, node.y + r + 10)
+        ctx.setFontSize(Math.max(9, Math.round(r * 0.4)))
+        ctx.fillText((node.nickname || '').substring(0, 3), node.x, node.y + r + 10)
       }
 
       ctx.draw()
-    },
+    } catch (err) {
+      // Legacy canvas fallback must never crash page initialization.
+    }
+  },
 
     // ===== 交互 =====
 

+ 68 - 0
cfc-frontend/components/FeedbackToast.vue

@@ -0,0 +1,68 @@
+<template>
+  <view class="feedback-overlay" v-if="visible" @touchmove.stop.prevent>
+    <view class="feedback-toast" :class="{ 'feedback-enter': animating, 'feedback-leave': !animating }">
+      <view class="feedback-icon">{{ icon }}</view>
+      <text class="feedback-text">{{ text }}</text>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  props: {
+    visible: { type: Boolean, default: false },
+    text: { type: String, default: '' },
+    icon: { type: String, default: '✅' },
+    animating: { type: Boolean, default: false }
+  }
+}
+</script>
+
+<style scoped>
+.feedback-overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 9999;
+  pointer-events: none;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.feedback-toast {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 12rpx;
+  background: rgba(0,0,0,0.75);
+  border-radius: 24rpx;
+  padding: 40rpx 60rpx;
+  backdrop-filter: blur(4px);
+}
+.feedback-icon { font-size: 80rpx; }
+.feedback-text {
+  font-size: 32rpx;
+  color: #fff;
+  font-weight: 600;
+  text-align: center;
+}
+.feedback-enter {
+  animation: feedbackPopIn 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
+}
+.feedback-leave {
+  animation: feedbackFadeOut 0.3s ease forwards;
+}
+</style>
+
+<style>
+@keyframes feedbackPopIn {
+  0%   { transform: scale(0.3); opacity: 0; }
+  100% { transform: scale(1); opacity: 1; }
+}
+@keyframes feedbackFadeOut {
+  0%   { opacity: 1; }
+  100% { opacity: 0; }
+}
+</style>

+ 167 - 0
cfc-frontend/components/GenerationPicker.vue

@@ -0,0 +1,167 @@
+<template>
+  <view class="generation-picker-wrap">
+    <view class="form-item required" v-if="showLabel">
+      <text class="label"><text class="required-mark">*</text>辈分关系</text>
+    </view>
+    <picker :range="genLabels" @change="onGenerationChange" :disabled="disabled">
+      <view class="picker" :class="{ 'picker-disabled': disabled }">
+        <text>{{ selectedGenLabel || '请选择辈分' }}</text>
+      </view>
+    </picker>
+
+    <!-- 同辈关系类型(仅当选中的是同辈时显示) -->
+    <view class="peer-type-wrap" v-if="selectedGen === 'peer'">
+      <view class="form-item">
+        <text class="label"><text class="required-mark">*</text>同辈关系类型</text>
+      </view>
+      <view class="radio-group">
+        <label class="radio-item" @tap="selectPeerType('sibling')">
+          <view class="radio-icon" :class="{ active: selectedPeer === 'sibling' }">
+            <view class="radio-inner" v-if="selectedPeer === 'sibling'"></view>
+          </view>
+          <text class="radio-label">兄弟姐妹</text>
+        </label>
+        <label class="radio-item" @tap="selectPeerType('spouse')">
+          <view class="radio-icon" :class="{ active: selectedPeer === 'spouse' }">
+            <view class="radio-inner" v-if="selectedPeer === 'spouse'"></view>
+          </view>
+          <text class="radio-label">配偶</text>
+        </label>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import genLevel from '../utils/generationLevel.js'
+var GENERATION_LEVELS = genLevel.GENERATION_LEVELS
+var PEER_TYPES = genLevel.PEER_TYPES
+var getGenerationLabel = genLevel.getGenerationLabel
+var getPeerTypeLabel = genLevel.getPeerTypeLabel
+
+export default {
+  name: 'GenerationPicker',
+  props: {
+    generationLevel: { type: String, default: '' },
+    peerType: { type: String, default: '' },
+    disabled: { type: Boolean, default: false },
+    showLabel: { type: Boolean, default: true }
+  },
+  data() {
+    return {
+      GEN_LEVELS: GENERATION_LEVELS,
+      selectedGen: this.generationLevel || '',
+      selectedPeer: this.peerType || ''
+    }
+  },
+  computed: {
+    genLabels() {
+      var labels = []
+      for (var i = 0; i < this.GEN_LEVELS.length; i++) {
+        labels.push(this.GEN_LEVELS[i].label)
+      }
+      return labels
+    },
+    selectedGenLabel() {
+      return getGenerationLabel(this.selectedGen)
+    }
+  },
+  watch: {
+    generationLevel(val) {
+      this.selectedGen = val || ''
+    },
+    peerType(val) {
+      this.selectedPeer = val || ''
+    }
+  },
+  methods: {
+    onGenerationChange(e) {
+      var idx = e.detail.value
+      if (idx >= 0 && idx < this.GEN_LEVELS.length) {
+        var gen = this.GEN_LEVELS[idx]
+        this.selectedGen = gen.value
+        if (gen.value !== 'peer') {
+          this.selectedPeer = ''
+          this.$emit('update:peerType', '')
+        }
+        this.$emit('update:generationLevel', this.selectedGen)
+      }
+    },
+    selectPeerType(type) {
+      this.selectedPeer = type
+      this.$emit('update:peerType', type)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.generation-picker-wrap {
+  width: 100%;
+}
+.picker {
+  border: 1rpx solid #ddd;
+  border-radius: 10rpx;
+  padding: 20rpx;
+  font-size: 28rpx;
+  color: #666;
+  background: #fff;
+}
+.picker-disabled {
+  background: #f5f5f5;
+  color: #999;
+}
+.form-item {
+  margin-bottom: 10rpx;
+}
+.form-item .label {
+  display: block;
+  font-size: 28rpx;
+  color: #333;
+  margin-bottom: 10rpx;
+  font-weight: bold;
+}
+.required-mark {
+  color: #F97316;
+  margin-right: 4rpx;
+}
+.peer-type-wrap {
+  margin-top: 20rpx;
+  padding: 20rpx;
+  background: #f9f9f9;
+  border-radius: 10rpx;
+}
+.radio-group {
+  display: flex;
+  flex-direction: row;
+  justify-content: space-around;
+}
+.radio-item {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+}
+.radio-icon {
+  width: 40rpx;
+  height: 40rpx;
+  border: 2rpx solid #ddd;
+  border-radius: 50%;
+  margin-right: 10rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.radio-icon.active {
+  border-color: #F97316;
+}
+.radio-inner {
+  width: 20rpx;
+  height: 20rpx;
+  background: #F97316;
+  border-radius: 50%;
+}
+.radio-label {
+  font-size: 28rpx;
+  color: #333;
+}
+</style>

+ 87 - 0
cfc-frontend/components/HealthAlertBanner.vue

@@ -0,0 +1,87 @@
+<template>
+  <view class="alert-banner" v-if="alerts && alerts.length > 0">
+    <view
+      class="alert-item"
+      v-for="alert in visibleAlerts"
+      :key="alert.id"
+      :class="'alert-severity-' + alert.severity">
+      <view class="alert-icon">
+        <text v-if="alert.severity === 'high'">🔴</text>
+        <text v-else-if="alert.severity === 'medium'">🟡</text>
+        <text v-else>🟢</text>
+      </view>
+      <view class="alert-body">
+        <text class="alert-text">{{ alert.alertText }}</text>
+        <text class="alert-suggestion" v-if="alert.actionSuggestion">
+          {{ alert.actionSuggestion }}
+        </text>
+      </view>
+      <text class="alert-dismiss" @click="dismiss(alert)">×</text>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  props: {
+    alerts: { type: Array, default: function() { return [] } }
+  },
+  computed: {
+    visibleAlerts: function() {
+      return this.alerts.slice(0, 2)
+    }
+  },
+  methods: {
+    dismiss: function(alert) {
+      this.$emit('dismiss', alert.id)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.alert-banner {
+  margin: 0 20rpx 16rpx 20rpx;
+  display: flex;
+  flex-direction: column;
+  gap: 8rpx;
+}
+.alert-item {
+  display: flex;
+  align-items: flex-start;
+  gap: 12rpx;
+  padding: 16rpx;
+  border-radius: 16rpx;
+  position: relative;
+}
+.alert-severity-high {
+  background: #FEF2F2;
+  border: 1rpx solid #FECACA;
+}
+.alert-severity-medium {
+  background: #FFFBEB;
+  border: 1rpx solid #FDE68A;
+}
+.alert-severity-low {
+  background: #F0F9FF;
+  border: 1rpx solid #BAE6FD;
+}
+.alert-icon { font-size: 36rpx; flex-shrink: 0; padding-top: 2rpx; }
+.alert-body { flex: 1; min-width: 0; }
+.alert-text { font-size: 26rpx; font-weight: 600; color: #1E293B; line-height: 1.4; }
+.alert-suggestion {
+  display: block;
+  font-size: 22rpx;
+  color: #64748B;
+  margin-top: 4rpx;
+  line-height: 1.4;
+}
+.alert-dismiss {
+  font-size: 36rpx;
+  color: #94A3B8;
+  padding: 0 8rpx;
+  line-height: 1;
+  flex-shrink: 0;
+}
+.alert-dismiss:active { color: #64748B; }
+</style>

+ 102 - 0
cfc-frontend/components/HealthBadge.vue

@@ -0,0 +1,102 @@
+<template>
+  <view class="health-badge" v-if="levelInfo">
+    <view class="badge-icon-wrap" :class="'badge-lv-' + (levelInfo.levelNum || 1)">
+      <text class="badge-icon">{{ levelInfo.badgeIcon || '🌱' }}</text>
+    </view>
+    <view class="badge-info">
+      <text class="badge-title">{{ levelInfo.title || '健康探索者' }}</text>
+      <view class="badge-level-row">
+        <text class="badge-level">L{{ levelInfo.levelNum || 1 }}</text>
+        <text class="badge-score">{{ score }} 分</text>
+      </view>
+      <view class="badge-progress-bar" v-if="progress < 100">
+        <view class="badge-progress-fill" :style="{ width: progress + '%' }"></view>
+      </view>
+      <text class="badge-next" v-if="nextLevel && progress < 100">
+        再{{ nextLevel.minScore - score }}分升级「{{ nextLevel.title }}」
+      </text>
+      <text class="badge-next" v-else-if="progress >= 100">
+        已达最高等级!
+      </text>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  props: {
+    levelInfo: { type: Object, default: null },
+    score: { type: Number, default: 0 },
+    progress: { type: Number, default: 0 },
+    nextLevel: { type: Object, default: null },
+    streakDays: { type: Number, default: 0 }
+  }
+}
+</script>
+
+<style scoped>
+.health-badge {
+  display: flex;
+  align-items: center;
+  gap: 16rpx;
+  padding: 20rpx 24rpx;
+  background: linear-gradient(135deg, #F0FDF4, #ECFDF5);
+  border-radius: 20rpx;
+  margin: 0 20rpx 16rpx 20rpx;
+}
+.badge-icon-wrap {
+  width: 80rpx;
+  height: 80rpx;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+}
+.badge-lv-1 { background: #D1FAE5; }
+.badge-lv-2 { background: #A7F3D0; }
+.badge-lv-3 { background: #FEF3C7; }
+.badge-lv-4 { background: #FED7AA; }
+.badge-lv-5 { background: #FECDD3; }
+.badge-icon { font-size: 40rpx; }
+.badge-info { flex: 1; min-width: 0; }
+.badge-title {
+  font-size: 28rpx;
+  font-weight: 700;
+  color: #065F46;
+}
+.badge-level-row {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+  margin-top: 4rpx;
+}
+.badge-level {
+  font-size: 22rpx;
+  color: #047857;
+  font-weight: 600;
+}
+.badge-score {
+  font-size: 22rpx;
+  color: #6B7280;
+}
+.badge-progress-bar {
+  height: 8rpx;
+  background: #D1FAE5;
+  border-radius: 999rpx;
+  overflow: hidden;
+  margin-top: 8rpx;
+}
+.badge-progress-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #10B981, #34D399);
+  border-radius: 999rpx;
+  transition: width 0.5s ease;
+}
+.badge-next {
+  font-size: 20rpx;
+  color: #6B7280;
+  margin-top: 4rpx;
+  display: block;
+}
+</style>

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů