2026-07-23-P2-2-family-health-interaction.md 18 KB

P2-2 家庭健康互动 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task.

Goal: 实现家庭健康排行榜、点赞系统、家庭挑战三大功能

Architecture: 后端3个Controller + 3个Service + 3个Entity/Mapper / 前端3个组件 + 集成到parent首页

Tech Stack: Spring Boot 2.7.18 + MyBatis-Plus / uni-app Vue 2 小程序


Task 1: 后端Entity + Mapper (3张新表)

Files:

  • Create: cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyChallenge.java
  • Create: cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyChallengeProgress.java
  • Create: cfc-backend/src/main/java/com/etotem/cfc/entity/HealthInteraction.java
  • Create: cfc-backend/src/main/java/com/etotem/cfc/mapper/FamilyChallengeMapper.java
  • Create: cfc-backend/src/main/java/com/etotem/cfc/mapper/FamilyChallengeProgressMapper.java
  • Create: cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthInteractionMapper.java
  • Modify: cfc-backend/src/main/resources/schema.sql (追加3张表)
  • Modify: cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java (迁移107/108/109)

  • [ ] Step 1: Create FamilyChallenge.java

    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;         // full_checkin, sports, health_week
    private String title;
    private String description;
    private Integer durationDays;
    private Integer rewardPoints;
    private String rewardBadge;
    private Date startDate;
    private Date endDate;
    private String status;                // active, completed, cancelled
    private Date createdAt;
    }
    
  • [ ] Step 2: Create FamilyChallengeProgress.java

    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;        // e.g. checkin days count, sports minutes
    private Boolean completed;
    private Date createdAt;
    private Date updatedAt;
    }
    
  • [ ] Step 3: Create HealthInteraction.java

    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;            // health_checkin, micro_action, etc.
    private Long actionId;                // reference to the completed action record
    private Date createdAt;
    }
    
  • [ ] Step 4: Create 3 Mapper interfaces

    package com.etotem.cfc.mapper;
    
    import com.baomidou.mybatisplus.core.mapper.BaseMapper;
    import com.etotem.cfc.entity.FamilyChallenge;
    
    public interface FamilyChallengeMapper extends BaseMapper<FamilyChallenge> {}
    

(同样模式创建 FamilyChallengeProgressMapper 和 HealthInteractionMapper)

  • [ ] Step 5: Add migrations 107/108/109 to DatabaseInitializer

    // 迁移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, " +
            "challenge_type VARCHAR(50) NOT NULL COMMENT 'full_checkin/sports/health_week', " +
            "title VARCHAR(200) NOT NULL, " +
            "description TEXT, " +
            "duration_days INT DEFAULT 7, " +
            "reward_points INT DEFAULT 0, " +
            "reward_badge VARCHAR(100), " +
            "start_date DATE, " +
            "end_date DATE, " +
            "status VARCHAR(20) DEFAULT 'active' COMMENT 'active/completed/cancelled', " +
            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
            "INDEX idx_family_id (family_id)" +
            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭挑战'");
    log.info("已创建family_challenge表");
    } catch (Exception e) { /* 忽略 */ }
    
    // 迁移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, " +
            "child_id BIGINT NOT NULL, " +
            "progress_value INT DEFAULT 0, " +
            "completed TINYINT(1) DEFAULT 0, " +
            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
            "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
            "INDEX idx_challenge_id (challenge_id)" +
            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭挑战进度'");
    log.info("已创建family_challenge_progress表");
    } catch (Exception e) { /* 忽略 */ }
    
    // 迁移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, " +
            "from_user_id BIGINT NOT NULL, " +
            "to_user_id BIGINT NOT NULL, " +
            "action_type VARCHAR(50) NOT NULL, " +
            "action_id BIGINT, " +
            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
            "INDEX idx_family_action (family_id, action_type, action_id)" +
            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康互动记录(点赞)'");
    log.info("已创建health_interaction表");
    } catch (Exception e) { /* 忽略 */ }
    
  • [ ] Step 6: Sync schema.sql — append 3 CREATE TABLE statements

在 schema.sql 末尾追加上述3张表的 CREATE TABLE IF NOT EXISTS。

  • Step 7: mvn clean compile — 验证编译通过

Task 2: HealthInteractionService + Controller (点赞系统)

Files:

  • Create: cfc-backend/src/main/java/com/etotem/cfc/service/HealthInteractionService.java
  • Create: cfc-backend/src/main/java/com/etotem/cfc/controller/HealthInteractionController.java
  • Modify: cfc-backend/src/main/java/com/etotem/cfc/service/HealthScoreService.java (加 public addScore(childId, points, reason) 方法)

  • [ ] Step 1: Add addScore method to HealthScoreService

    /** 给指定孩子增加健康积分(供点赞系统调用) */
    public void addScore(Long childId, int points, String reason) {
    String dateKey = LocalDate.now().toString();
    // 在 existing score map 中增加
    if (reason != null) {
        // 记录积分来源
    }
    }
    
  • [ ] Step 2: Create HealthInteractionService

    @Service
    public class HealthInteractionService {
    @Resource
    private HealthInteractionMapper healthInteractionMapper;
    @Resource
    private HealthScoreService healthScoreService;
    @Resource
    private FamilyMembersMapper familyMembersMapper;
    
    /** 点赞: 双方各+1分, 24h内同一行为不可重复点赞 */
    @Transactional
    public Result<Void> like(Long fromUserId, Long toUserId, String actionType, Long actionId, Long familyId) {
        // 1. 查24h内是否已点赞
        // 2. 如果已存在 → return Result.error("已点赞")
        // 3. 插入 HealthInteraction 记录
        // 4. 调用 healthScoreService.addScore(toUserId, 1, "收到点赞")
        // 5. 调用 healthScoreService.addScore(fromUserId, 1, "点赞他人")
        // 6. return Result.success()
    }
    
    /** 检查24h内是否已点赞 */
    public boolean hasLiked24h(Long fromUserId, String actionType, Long actionId) {
        // LocalDateTime.now().minusHours(24) 过滤
    }
    }
    
  • [ ] Step 3: Create HealthInteractionController

    @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 = Long.valueOf(params.get("toUserId").toString());
        String actionType = (String) params.get("actionType");
        Long actionId = Long.valueOf(params.get("actionId").toString());
        Long familyId = Long.valueOf(params.get("familyId").toString());
        return healthInteractionService.like(userId, toUserId, actionType, actionId, familyId);
    }
    }
    
  • [ ] Step 4: mvn clean compile


Task 3: FamilyHealthScoreService + FamilyHealthController (排行榜)

Files:

  • Create: cfc-backend/src/main/java/com/etotem/cfc/service/FamilyHealthScoreService.java
  • Create: cfc-backend/src/main/java/com/etotem/cfc/controller/FamilyHealthController.java

  • [ ] Step 1: FamilyHealthScoreService

    @Service
    public class FamilyHealthScoreService {
    @Resource
    private FamilyMembersMapper familyMembersMapper;
    @Resource
    private HealthScoreService healthScoreService;
    
    /**
     * 获取家庭健康排行榜
     * @return List<{childId, name, avatar, score, levelTitle, streakDays}>
     */
    public List<Map<String, Object>> getFamilyRanking(Long familyId, Long currentUserId, String role) {
        // 1. 查家庭所有成员 (child)
        // 2. 对每个child查 HealthScoreService 获取积分
        // 3. 按积分降序排序
        // 4. 如果是child角色, 只返回自己的排名
        // 5. 计算全家总积分
        // 6. 返回 (含 rank 序号)
    }
    }
    
  • [ ] Step 2: FamilyHealthController

    @RestController
    @RequestMapping("/api/health/family")
    public class FamilyHealthController {
    @Resource
    private FamilyHealthScoreService familyHealthScoreService;
    
    @PostMapping("/ranking")
    public Result<List<Map<String, Object>>> getRanking(
            @RequestBody Map<String, Object> params,
            @RequestAttribute("userId") Long userId,
            @RequestAttribute("role") String role) {
        Long familyId = Long.valueOf(params.get("familyId").toString());
        List<Map<String, Object>> ranking = familyHealthScoreService.getFamilyRanking(familyId, userId, role);
        return Result.success(ranking);
    }
    }
    
  • [ ] Step 3: mvn clean compile


Task 4: FamilyChallengeService + FamilyChallengeController (挑战引擎)

Files:

  • Create: cfc-backend/src/main/java/com/etotem/cfc/service/FamilyChallengeService.java
  • Create: cfc-backend/src/main/java/com/etotem/cfc/controller/FamilyChallengeController.java

  • [ ] Step 1: FamilyChallengeService

    @Service
    public class FamilyChallengeService {
    @Resource
    private FamilyChallengeMapper familyChallengeMapper;
    @Resource
    private FamilyChallengeProgressMapper familyChallengeProgressMapper;
    @Resource
    private HealthScoreService healthScoreService;
    
    /** 获取活跃挑战, 若无则自动创建 */
    public List<FamilyChallenge> getActiveChallenges(Long familyId) {
        // 1. 查 active 状态的挑战
        // 2. 如果为空 → autoCreateChallenges(familyId)
        // 3. 再查一次返回
    }
    
    /** 按当前月份自动创建挑战 (全员打卡/运动PK/健康周) */
    public void autoCreateChallenges(Long familyId) {
        // 本月1日 → 下月1日
        // 创建3种挑战模板
    }
    
    /** 更新挑战进度 */
    public Result<Void> updateProgress(Long challengeId, Long childId, int delta) {
        // 查找或创建进度记录
        // 更新 progressValue
        // 检查是否完成后自动结算
    }
    
    /** 结算挑战: 达标发奖 */
    private void settleChallenge(Long challengeId) {
        // 读取所有进度
        // 判断达标条件
        // 达标: healthScoreService.addScore + 更新status=completed
        // 超期未达标: status=completed (不发奖)
    }
    
    /** 挑战历史 */
    public List<FamilyChallenge> getChallengeHistory(Long familyId) {
        // status=completed, 按时间倒序
    }
    }
    
  • [ ] Step 2: FamilyChallengeController

    @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 = Long.valueOf(params.get("familyId").toString());
        return Result.success(familyChallengeService.getActiveChallenges(familyId));
    }
    
    @PostMapping("/history")
    public Result<List<FamilyChallenge>> getHistory(@RequestBody Map<String, Object> params) {
        Long familyId = Long.valueOf(params.get("familyId").toString());
        return Result.success(familyChallengeService.getChallengeHistory(familyId));
    }
    
    @PostMapping("/progress")
    public Result<Void> updateProgress(@RequestBody Map<String, Object> params) {
        Long challengeId = Long.valueOf(params.get("challengeId").toString());
        Long childId = Long.valueOf(params.get("childId").toString());
        int delta = Integer.parseInt(params.get("delta").toString());
        return familyChallengeService.updateProgress(challengeId, childId, delta);
    }
    }
    
  • [ ] Step 3: mvn clean compile


Task 5: 前端组件 - FamilyHealthRanking.vue + HealthLikeButton.vue

Files:

  • Create: cfc-frontend/components/FamilyHealthRanking.vue
  • Create: cfc-frontend/components/HealthLikeButton.vue

  • [ ] Step 1: FamilyHealthRanking.vue

    <template>
    <view class="family-ranking" v-if="members.length">
    <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 members"
        :key="m.childId"
      >
        <text class="rank-num">{{ '🥇🥈🥉'[idx] || '#' + (idx+1) }}</text>
        <image class="rank-avatar" :src="m.avatar || '/static/default-avatar.png'" />
        <view class="rank-info">
          <text class="rank-name">{{ m.name }}</text>
          <text class="rank-level">{{ m.levelTitle }}</text>
        </view>
        <view class="rank-score">
          <text class="score-val">{{ m.score }}</text>
          <text class="score-unit">分</text>
        </view>
        <text class="rank-streak" v-if="m.streakDays > 0">🔥 {{ m.streakDays }}天</text>
      </view>
    </view>
    </view>
    </template>
    
  • [ ] Step 2: HealthLikeButton.vue

    <template>
    <view class="like-btn" :class="{ liked: isLiked }" @click="handleLike">
    <text class="like-icon">{{ isLiked ? '❤️' : '🤍' }}</text>
    <text class="like-count">{{ count }}</text>
    </view>
    </template>
    <script>
    export default {
    props: { actionType: String, actionId: [Number, String], toUserId: [Number, String] },
    data() { return { isLiked: false, count: 0 } },
    methods: {
    handleLike() {
      if (this.isLiked) return
      // POST /api/health/interact/like
    }
    }
    }
    </script>
    

Task 6: 前端组件 FamilyChallengeCard.vue + 集成到 parent/index.vue

Files:

  • Create: cfc-frontend/components/FamilyChallengeCard.vue
  • Modify: cfc-frontend/pages/index/index.vue (parent tab 嵌入排行榜+挑战)

  • [ ] Step 1: FamilyChallengeCard.vue

    <template>
    <view class="challenge-card" v-if="challenge">
    <view class="card-header">
      <text class="card-title">{{ challenge.title }}</text>
      <text class="card-status">{{ challenge.status === 'active' ? '进行中' : '已完成' }}</text>
    </view>
    <text class="card-desc">{{ challenge.description }}</text>
    <view class="card-footer">
      <text class="card-deadline">截止: {{ formatDate(challenge.endDate) }}</text>
      <text class="card-reward">🏆 +{{ challenge.rewardPoints }}分</text>
    </view>
    <!-- 进度条 -->
    <view class="progress-bar" v-if="challenge.progress !== undefined">
      <view class="progress-fill" :style="{ width: progressPct + '%' }"></view>
    </view>
    </view>
    </template>
    
  • [ ] Step 2: 在 parent/index.vue 中嵌入排行榜+挑战卡片

在家长首页适当位置添加 <FamilyHealthRanking /> 和 <FamilyChallengeCard />。

  • [ ] Step 3: 添加 api.js 端点

    export const getFamilyRanking = (data) => request('/api/health/family/ranking', 'POST', data)
    export const getChallengeList = (data) => request('/api/health/challenge/list', 'POST', data)
    export const getChallengeHistory = (data) => request('/api/health/challenge/history', 'POST', data)
    export const updateChallengeProgress = (data) => request('/api/health/challenge/progress', 'POST', data)
    export const sendLike = (data) => request('/api/health/interact/like', 'POST', data)
    

Task 7: 路由检查 + 编译验证 + 提交

  • [ ] Step 1: 检查重复路由

    grep -rn '@Mapping' cfc-backend/src/.../controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u
    
  • [ ] Step 2: mvn clean compile — 全量编译验证

  • [ ] Step 3: git add + commit

    git add ... # all P2-2 files
    git commit -m "feat: P2-2 家庭健康互动 - 排行榜+点赞+挑战系统"
    git push
    
  • [ ] Step 4: 更新 PROJECT-OVERVIEW.md — P2-2 标记 ⚡ 开发中