فهرست منبع

chore: auto bump version and changelog [skip ci]

iwt 3 ماه پیش
والد
کامیت
7c870209dc
18فایلهای تغییر یافته به همراه937 افزوده شده و 1 حذف شده
  1. 56 0
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 43 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/FamilyChallengeController.java
  3. 26 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/FamilyHealthController.java
  4. 31 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthInteractionController.java
  5. 39 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyChallenge.java
  6. 29 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyChallengeProgress.java
  7. 29 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthInteraction.java
  8. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/FamilyChallengeMapper.java
  9. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/FamilyChallengeProgressMapper.java
  10. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthInteractionMapper.java
  11. 175 0
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyChallengeService.java
  12. 100 0
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyHealthScoreService.java
  13. 61 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthInteractionService.java
  14. 41 0
      cfc-backend/src/main/resources/schema.sql
  15. 117 0
      cfc-frontend/components/FamilyChallengeCard.vue
  16. 104 0
      cfc-frontend/components/FamilyHealthRanking.vue
  17. 64 0
      cfc-frontend/components/HealthLikeButton.vue
  18. 1 1
      cfc-web/.last_build_commit

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

@@ -5526,6 +5526,62 @@ try {
     } 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());
+    }
     }
 
     private void runMigration82() {

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

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

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

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

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

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

+ 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 ("child".equals(m.getRoleOverride())) {
+                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;
+        }
+    }
+}

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

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

@@ -3007,3 +3007,44 @@ CREATE TABLE IF NOT EXISTS health_timeline_event (
     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='健康互动记录(点赞)';

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

+ 64 - 0
cfc-frontend/components/HealthLikeButton.vue

@@ -0,0 +1,64 @@
+<template>
+  <view class="like-btn" :class="{ liked: isLikedVal }" @click="handleClick">
+    <text class="like-icon">{{ isLikedVal ? '❤️' : '🤍' }}</text>
+    <text class="like-count">{{ count }}</text>
+  </view>
+</template>
+
+<script>
+export default {
+  props: {
+    actionType: { type: String, default: '' },
+    actionId: { type: [Number, String], default: '' },
+    toUserId: { type: [Number, String], default: '' },
+    isLiked: { type: Boolean, default: false },
+    count: { type: Number, default: 0 }
+  },
+  data() {
+    return {
+      isLikedVal: this.isLiked
+    }
+  },
+  watch: {
+    isLiked: function(val) {
+      this.isLikedVal = val
+    }
+  },
+  methods: {
+    handleClick: function() {
+      if (this.isLikedVal) return
+      this.isLikedVal = true
+      this.$emit('like', {
+        actionType: this.actionType,
+        actionId: this.actionId,
+        toUserId: this.toUserId
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.like-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 6rpx;
+  padding: 8rpx 16rpx;
+  border-radius: 999rpx;
+  border: 1rpx solid #E2E8F0;
+  background: #FAFAFA;
+  transition: all 0.2s;
+}
+.like-btn.liked {
+  border-color: #FECDD3;
+  background: #FFF1F2;
+}
+.like-icon { font-size: 24rpx; }
+.like-count {
+  font-size: 22rpx;
+  color: #64748B;
+  min-width: 20rpx;
+  text-align: center;
+}
+.liked .like-count { color: #E11D48; }
+</style>

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-c98820ad7f6326f95c590019f429609a34beeadd
+3443be507e42a6a2cba8c2295c391e1304b83d0f