2026-08-06-family-challenge-fix.md 35 KB

家庭挑战断点修复实现计划

面向 AI 代理的工作者: 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(- [ ])语法来跟踪进度。

目标: 修复家庭挑战功能的 6 个断点(spec: docs/superpowers/specs/2026-08-06-family-challenge-flow-design.md §7 的 #1-#6;#7 autoCreate 无调用方明确排除,属 spec §8"未来"功能,如需启用需另行 spec),使挑战形成闭环:家长创建 → 孩子打卡(限次)→ 到期结算 → 奖励发放,并补齐角色校验与前端展示修复。

架构: 后端 cfc-backend 扩展 FamilyChallengeService/FamilyChallengeController:加角色校验(复用 JwtInterceptor 已设的 role/familyId/currentMemberId attribute)、打卡每日限次、结算时奖励发放(对接 PointsService 能量点)、列表查询时惰性结算。前端 cfc-frontend:FamilyChallengeCard.vue 加打卡按钮 + 修复 cancelled 状态显示、challenge-manage.vue 限制家长访问、api.js 已有封装不动。

技术栈: Java 8、Spring Boot 2.7.18、MyBatis-Plus、Lombok(后端);uni-app Vue 2 Options API(小程序)。后端验证 mvn clean compile。

约束: 接口统一 @PostMapping;DI 用 @Resource;小程序禁止可选链 ?.、禁止 :key 表达式、禁止直接 new Date(string)(用 utils/format.js 的 parseDate());角色检查在 Controller 用 @RequestAttribute("role")。


文件结构

文件 职责 操作
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyChallengeService.java 打卡限次、奖励发放、惰性结算 修改
cfc-backend/src/main/java/com/etotem/cfc/controller/FamilyChallengeController.java 角色校验(家长/孩子分流) 修改
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyChallenge.java 新增 lastSettleCheckAt(可选,惰性结算防重) 修改
cfc-frontend/components/FamilyChallengeCard.vue 打卡按钮 + cancelled 状态显示修复 修改
cfc-frontend/pages/health/challenge-manage.vue 家长权限控制 修改
cfc-frontend/pages/index-home/index.vue 孩子端展示打卡入口(若卡片已含则跳过) 修改

任务 1:后端 — 打卡每日限次 + memberId 归属校验

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/service/FamilyChallengeService.java:93-125(updateProgress 方法)
  • 测试:cfc-backend/src/test/java/com/etotem/cfc/service/FamilyChallengeServiceTest.java(新建)

  • [ ] 步骤 1:编写失败的测试(限次逻辑)

    package com.etotem.cfc.service;
    
    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 com.etotem.cfc.mapper.FamilyMemberMapper;
    import org.junit.jupiter.api.BeforeEach;
    import org.junit.jupiter.api.Test;
    import org.mockito.InjectMocks;
    import org.mockito.Mock;
    import org.mockito.MockitoAnnotations;
    
    import java.util.Arrays;
    import java.util.Date;
    
    import static org.junit.jupiter.api.Assertions.assertEquals;
    import static org.mockito.ArgumentMatchers.any;
    import static org.mockito.ArgumentMatchers.anyString;
    import static org.mockito.ArgumentMatchers.argThat;
    import static org.mockito.ArgumentMatchers.eq;
    import static org.mockito.Mockito.*;
    
    public class FamilyChallengeServiceTest {
    
    @Mock private FamilyChallengeMapper familyChallengeMapper;
    @Mock private FamilyChallengeProgressMapper familyChallengeProgressMapper;
    @Mock private FamilyMemberMapper familyMemberMapper;
    @Mock private HealthScoreService healthScoreService;
    @Mock private PointsService pointsService;
    @InjectMocks private FamilyChallengeService service;
    
    @BeforeEach
    void setUp() {
        MockitoAnnotations.openMocks(this);
    }
    
    @Test
    void updateProgress_shouldRejectDuplicateCheckinSameDay() {
        FamilyChallenge challenge = new FamilyChallenge();
        challenge.setId(1L);
        challenge.setFamilyId(10L);
        challenge.setStatus("active");
        challenge.setTargetMode("aggregate");
        challenge.setTargetValue(300);
        challenge.setEndDate(new Date(System.currentTimeMillis() + 86400000L * 7));
    
        FamilyChallengeProgress existing = new FamilyChallengeProgress();
        existing.setChallengeId(1L);
        existing.setChildId(100L);
        existing.setProgressValue(5);
        // 今天已打卡(updatedAt 是今天)
        existing.setUpdatedAt(new Date());
    
        when(familyChallengeMapper.selectById(1L)).thenReturn(challenge);
        when(familyChallengeProgressMapper.selectOne(any())).thenReturn(existing);
        // 归属校验通过:childId(100) 属于 familyId(10)
        when(familyMemberMapper.selectCount(any())).thenReturn(1L);
    
        Result<Void> result = service.updateProgress(1L, 100L, 1);
    
        // Result.error() 返回 code=500
        assertEquals(Integer.valueOf(500), result.getCode());
        verify(familyChallengeProgressMapper, never()).updateById(any());
    }
    
    @Test
    void updateProgress_shouldRejectWhenChallengeNotActive() {
        FamilyChallenge challenge = new FamilyChallenge();
        challenge.setId(1L);
        challenge.setFamilyId(10L);
        challenge.setStatus("completed"); // 非 active:已结算/已取消
        challenge.setTargetMode("aggregate");
        challenge.setTargetValue(300);
        challenge.setEndDate(new Date(System.currentTimeMillis() + 86400000L * 7));
    
        when(familyChallengeMapper.selectById(1L)).thenReturn(challenge);
    
        Result<Void> result = service.updateProgress(1L, 100L, 1);
    
        assertEquals(Integer.valueOf(500), result.getCode());
        verify(familyChallengeProgressMapper, never()).insert(any());
        verify(familyChallengeProgressMapper, never()).updateById(any());
    }
    }
    

注意:Result.error() 返回 code=500(Integer),Result.success() 返回 code=200(见 common/Result.java)。familyMemberMapper.selectCount() 返回 Long,mock 用 thenReturn(1L)。

  • 步骤 2:运行测试确认失败

运行:cd cfc-backend && mvn test -Dtest=FamilyChallengeServiceTest#updateProgress_shouldRejectDuplicateCheckinSameDay+updateProgress_shouldRejectWhenChallengeNotActive -pl . 预期:FAIL(updateProgress 目前接受重复打卡、也接受非 active 挑战打卡,不会返回 error)

  • 步骤 3:实现限次 + 归属校验

将 FamilyChallengeService.updateProgress(L93-125)整体替换为以下实现(合并原有逻辑 + 新增校验,消除重复查询):

@Transactional
public Result<Void> updateProgress(Long challengeId, Long childId, int delta) {
    if (challengeId == null || childId == null) {
        return Result.error("参数错误");
    }

    // 1. 挑战必须存在
    FamilyChallenge challenge = familyChallengeMapper.selectById(challengeId);
    if (challenge == null) {
        return Result.error("挑战不存在");
    }

    // 2. 挑战必须 active 且未到期(已结算/已取消/已过期的挑战不允许打卡)
    if (!"active".equals(challenge.getStatus())) {
        return Result.error("挑战已结束");
    }
    if (challenge.getEndDate() != null && !new Date().before(challenge.getEndDate())) {
        return Result.error("挑战已结束");
    }

    // 3. memberId 归属校验:childId 对应的 family_member 必须属于挑战所属家庭(防刷他人进度)
    LambdaQueryWrapper<FamilyMember> memberCheck = new LambdaQueryWrapper<FamilyMember>()
            .eq(FamilyMember::getId, childId)
            .eq(FamilyMember::getFamilyId, challenge.getFamilyId());
    if (familyMemberMapper.selectCount(memberCheck) == 0) {
        return Result.error("无权为该成员打卡");
    }

    // 4. 查该成员在本挑战的进度
    LambdaQueryWrapper<FamilyChallengeProgress> wrapper = new LambdaQueryWrapper<FamilyChallengeProgress>()
            .eq(FamilyChallengeProgress::getChallengeId, challengeId)
            .eq(FamilyChallengeProgress::getChildId, childId);
    FamilyChallengeProgress progress = familyChallengeProgressMapper.selectOne(wrapper);

    // 5. 每日限次:同一 challengeId+childId 当天只能打卡一次(按 updatedAt 判断)
    if (progress != null && progress.getUpdatedAt() != null) {
        java.util.Calendar today = java.util.Calendar.getInstance();
        today.setTime(new Date());
        java.util.Calendar upd = java.util.Calendar.getInstance();
        upd.setTime(progress.getUpdatedAt());
        if (upd.get(java.util.Calendar.YEAR) == today.get(java.util.Calendar.YEAR)
                && upd.get(java.util.Calendar.DAY_OF_YEAR) == today.get(java.util.Calendar.DAY_OF_YEAR)) {
            return Result.error("今日已打卡,明天再来吧");
        }
    }

    // 6. 累加进度(原有逻辑)
    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);
    }

    // 7. 结算判定(原有逻辑,复用已查出的 challenge,不再重复 selectById)
    if ("active".equals(challenge.getStatus())) {
        checkAndSettle(challenge);
    }

    return Result.success();
}
  • 步骤 4:运行测试确认通过

运行:cd cfc-backend && mvn test -Dtest=FamilyChallengeServiceTest -pl . 预期:PASS

  • [ ] 步骤 5:Commit

    git add cfc-backend/src/test/java/com/etotem/cfc/service/FamilyChallengeServiceTest.java cfc-backend/src/main/java/com/etotem/cfc/service/FamilyChallengeService.java
    git commit -m "fix(backend): 家庭挑战打卡每日限次 + memberId 归属校验"
    

任务 2:后端 — 结算时奖励发放(能量点)

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/service/FamilyChallengeService.java:134-182(checkAndSettle 方法)
  • 测试:cfc-backend/src/test/java/com/etotem/cfc/service/FamilyChallengeServiceTest.java(追加)

  • [ ] 步骤 1:编写失败的测试(结算发放奖励)

    @Test
    void checkAndSettle_shouldGrantPointsWhenCompleted() {
    FamilyChallenge challenge = new FamilyChallenge();
    challenge.setId(1L);
    challenge.setFamilyId(10L);
    challenge.setStatus("active");
    challenge.setTargetMode("aggregate");
    challenge.setTargetValue(10);
    challenge.setRewardPoints(50);
    challenge.setEndDate(new Date(System.currentTimeMillis() - 86400000L)); // 已到期
    
    FamilyChallengeProgress p = new FamilyChallengeProgress();
    p.setChallengeId(1L);
    p.setChildId(100L);
    p.setProgressValue(12); // ≥10 达成
    p.setCompleted(false);
    
    when(familyChallengeMapper.selectById(1L)).thenReturn(challenge);
    when(familyChallengeProgressMapper.selectList(any())).thenReturn(Arrays.asList(p));
    when(familyMemberMapper.selectList(any())).thenReturn(java.util.Collections.emptyList());
    // 条件更新成功(active → completed 原子跃迁,模拟并发下唯一成功的一次)
    when(familyChallengeMapper.update(any(), any())).thenReturn(1);
    
    service.settleChallenge(1L);
    
    // 挑战应通过条件更新标记 completed(不再是 updateById)
    verify(familyChallengeMapper).update(any(), any());
    // 进度应标记完成
    verify(familyChallengeProgressMapper).updateById(argThat(p2 -> Boolean.TRUE.equals(p2.getCompleted())));
    // 应发放积分(PointsService.awardSystemPoints)
    verify(pointsService).awardSystemPoints(eq(100L), eq(50), anyString());
    }
    

已核实:积分发放使用 PointsService.awardSystemPoints(Long memberId, int amount, String reason)(service/PointsService.java:97)。@Mock private PointsService pointsService; 已在任务 1 测试类中声明(同包 com.etotem.cfc.service,无需 import)。

  • 步骤 2:运行测试确认失败

运行:cd cfc-backend && mvn test -Dtest=FamilyChallengeServiceTest#checkAndSettle_shouldGrantPointsWhenCompleted -pl . 预期:FAIL(当前 checkAndSettle 用 updateById 而非条件更新、不改 progress.completed、不发积分)

  • 步骤 3:实现奖励发放

3a. 先在 FamilyChallengeService 字段区新增注入(在 healthScoreService 字段 L33 之后):

@Resource
private PointsService pointsService;

PointsService 与 FamilyChallengeService 同包(com.etotem.cfc.service),无需 import。字段名 pointsService 与类型默认 Bean Name 一致。

同时新增 import(在 L3 LambdaQueryWrapper 之后):import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;(3b 的幂等条件更新需要)。

3b. 在 checkAndSettle 中,把结算判定块(L176-181,if (isCompleted || endDate…) { challenge.setStatus("completed"); updateById; })整体替换为带幂等保护的结算 + 发奖:

if (isCompleted || challenge.getEndDate() == null || new Date().after(challenge.getEndDate())) {
    // 幂等结算:仅当挑战仍是 active 时原子跃迁为 completed(条件更新 WHERE id=? AND status='active')。
    // 并发打卡/重复触发结算时,只有一个请求能更新成功(影响行数=1),其余请求影响行数=0 直接跳过,
    // 从根源杜绝重复发奖。
    LambdaUpdateWrapper<FamilyChallenge> uw = new LambdaUpdateWrapper<FamilyChallenge>()
            .eq(FamilyChallenge::getId, challenge.getId())
            .eq(FamilyChallenge::getStatus, "active")
            .set(FamilyChallenge::getStatus, "completed");
    int updated = familyChallengeMapper.update(null, uw);
    if (updated > 0) {
        if (isCompleted) {
            // 发放奖励:能量点 + 标记成员完成(仅成功结算的调用执行,不会重复)
            int points = challenge.getRewardPoints() != null ? challenge.getRewardPoints() : 0;
            for (FamilyChallengeProgress p : progresses) {
                if (!Boolean.TRUE.equals(p.getCompleted())) {
                    p.setCompleted(true);
                    p.setUpdatedAt(new Date());
                    familyChallengeProgressMapper.updateById(p);
                }
                if (points > 0) {
                    // 对接积分服务发放奖励(PointsService.awardSystemPoints,按 memberId 发放)
                    pointsService.awardSystemPoints(p.getChildId(), points,
                            "家庭挑战完成奖励: " + challenge.getTitle());
                }
            }
        }
        log.info("挑战已结算: id={}, type={}, targetMode={}, targetValue={}, completed={}",
                challenge.getId(), challenge.getChallengeType(), targetMode, targetValue, isCompleted);
    }
}

幂等性说明:update(null, uw) 条件更新 WHERE id=? AND status='active' 是原子操作。首个结算请求把 status 置为 completed,后续并发请求的 WHERE 条件不再命中 → 影响行数 0 → 跳过发奖。重复调用 settleChallenge 同样被拦截。这修复了 momus 复核指出的"并发/重复结算重复发奖"问题。

为何对所有参与成员发放(不按个人 progressValue >= targetValue 判断):

  • aggregate 模式(如"全家总运动 300 分钟",targetValue=300):挑战达成条件是家庭合计达标,个人进度几乎不可能达到 300 → 若按个人目标判断则无人得奖,违背&#34;奖励发放"修复意图(spec §7 #3)。挑战整体达成后,所有参与成员(有 progress 记录)都应获得奖励。
  • all_members 模式:挑战达成条件本就是每个成员都达到 targetValue,结算时所有成员必然已达标 → 同样应全员发放。
  • 两种模式下"有 progress 记录 = 参与了该挑战",统一全员标记完成 + 发放,逻辑一致且简单。
  • 步骤 4:运行测试确认通过

运行:cd cfc-backend && mvn test -Dtest=FamilyChallengeServiceTest -pl . 预期:PASS

  • [ ] 步骤 5:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/service/FamilyChallengeService.java cfc-backend/src/test/java/com/etotem/cfc/service/FamilyChallengeServiceTest.java
    git commit -m "feat(backend): 家庭挑战结算发放能量点奖励并标记成员完成"
    

任务 3:后端 — 到期惰性结算 + 角色校验

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/service/FamilyChallengeService.java:35-46(getActiveChallenges)
  • 修改:cfc-backend/src/main/java/com/etotem/cfc/controller/FamilyChallengeController.java
  • 测试:cfc-backend/src/test/java/com/etotem/cfc/service/FamilyChallengeServiceTest.java(追加)

  • [ ] 步骤 1:编写失败的测试(查询时惰性结算)

    @Test
    void getActiveChallenges_shouldSettleExpiredChallenges() {
    FamilyChallenge expired = new FamilyChallenge();
    expired.setId(2L);
    expired.setFamilyId(10L);
    expired.setStatus("active");
    expired.setTargetMode("aggregate");
    expired.setTargetValue(10);
    expired.setEndDate(new Date(System.currentTimeMillis() - 86400000L)); // 已到期
    
    when(familyChallengeMapper.selectList(any())).thenReturn(Arrays.asList(expired));
    when(familyChallengeProgressMapper.selectList(any())).thenReturn(java.util.Collections.emptyList());
    when(familyMemberMapper.selectList(any())).thenReturn(java.util.Collections.emptyList());
    // 条件更新成功(active → completed 原子跃迁)
    when(familyChallengeMapper.update(any(), any())).thenReturn(1);
    
    service.getActiveChallenges(10L);
    
    // 到期挑战应通过条件更新结算为 completed
    verify(familyChallengeMapper).update(any(), any());
    }
    
  • [ ] 步骤 2:运行测试确认失败

运行:cd cfc-backend && mvn test -Dtest=FamilyChallengeServiceTest#getActiveChallenges_shouldSettleExpiredChallenges -pl . 预期:FAIL(当前 getActiveChallenges 只查不改)

  • 步骤 3:实现惰性结算 + 角色校验

在 getActiveChallenges 中,selectList 之后、fillProgress 之前插入:

// 惰性结算:遍历 active 挑战,到期的触发 checkAndSettle
for (FamilyChallenge c : active) {
    if (c.getEndDate() != null && new Date().after(c.getEndDate())) {
        checkAndSettle(c);
    }
}
// 结算后重新查询一次(排除刚结算的)
active = familyChallengeMapper.selectList(wrapper);

在 FamilyChallengeController 各端点加角色校验。以下为完整方法体(基于现有 L19-72 逐方法替换,保留原参数与 Service 调用):

// list/history:任何登录角色可看(parent/child),保持现状不动(L19-33 不改)

// progress:仅 child 可打卡;强制用 currentMemberId,忽略请求体 memberId(防刷他人进度)
@PostMapping("/progress")
public Result<Void> updateProgress(@RequestBody Map<String, Object> params,
                                   @RequestAttribute(value = "role", required = false) String role,
                                   @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
    if (!"child".equals(role)) {
        return Result.error("仅孩子可打卡");
    }
    if (currentMemberId == null) {
        return Result.error("未识别当前成员");
    }
    Long challengeId = params.get("challengeId") != null
            ? Long.valueOf(params.get("challengeId").toString()) : null;
    int delta = params.get("delta") != null
            ? Integer.parseInt(params.get("delta").toString()) : 0;
    return familyChallengeService.updateProgress(challengeId, currentMemberId, delta);
}

// templates:任何登录角色可看,保持现状不动(L47-50 不改)

// create:仅 parent
@PostMapping("/create")
public Result<Long> createChallenge(@RequestBody Map<String, Object> params,
                                    @RequestAttribute(value = "role", required = false) String role,
                                    @RequestAttribute(value = "familyId", required = false) Long familyId,
                                    @RequestAttribute("userId") Long userId) {
    if (!"parent".equals(role)) {
        return Result.error("仅家长可创建挑战");
    }
    return familyChallengeService.createChallenge(familyId, userId, params);
}

// update:仅 parent
@PostMapping("/update/{id}")
public Result<Void> updateChallenge(@PathVariable("id") Long challengeId,
                                    @RequestBody Map<String, Object> params,
                                    @RequestAttribute(value = "role", required = false) String role,
                                    @RequestAttribute(value = "familyId", required = false) Long familyId,
                                    @RequestAttribute("userId") Long userId) {
    if (!"parent".equals(role)) {
        return Result.error("仅家长可修改挑战");
    }
    return familyChallengeService.updateChallenge(challengeId, userId, familyId, params);
}

// delete:仅 parent
@PostMapping("/delete/{id}")
public Result<Void> deleteChallenge(@PathVariable("id") Long challengeId,
                                    @RequestAttribute(value = "role", required = false) String role,
                                    @RequestAttribute(value = "familyId", required = false) Long familyId,
                                    @RequestAttribute("userId") Long userId) {
    if (!"parent".equals(role)) {
        return Result.error("仅家长可删除挑战");
    }
    return familyChallengeService.deleteChallenge(challengeId, userId, familyId);
}

注意:progress 端点不再读请求体 memberId(原 L40-41 的逻辑删除),统一用 currentMemberId(JwtInterceptor 对 child 角色已自动从 family_member 表查出并注入,见 JwtInterceptor.java:147-159)。

  • 步骤 4:运行测试确认通过

运行:cd cfc-backend && mvn test -Dtest=FamilyChallengeServiceTest -pl . 预期:PASS

  • 步骤 5:验证编译

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS(无编译错误)

  • [ ] 步骤 6:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/service/FamilyChallengeService.java cfc-backend/src/main/java/com/etotem/cfc/controller/FamilyChallengeController.java cfc-backend/src/test/java/com/etotem/cfc/service/FamilyChallengeServiceTest.java
    git commit -m "feat(backend): 家庭挑战到期惰性结算 + 角色校验(家长管理/孩子打卡)"
    

任务 4:前端 — 打卡按钮 + cancelled 状态显示修复

文件:

  • 修改:cfc-frontend/components/FamilyChallengeCard.vue
  • 修改:cfc-frontend/pages/index-home/index.vue(孩子端打卡入口)
  • 不改:cfc-frontend/pages/home-pages/parent-index.vue(家长端 L51-53 只传 :challenge="ch",showCheckin 默认 false 不显示打卡按钮——后端仅允许 child 角色打卡,家长端不应出现打卡入口;状态显示修复在组件内部自动生效,无需改动此文件)

  • [ ] 步骤 1:修复 cancelled 状态显示

FamilyChallengeCard.vue 第 5-7 行当前:

<text class="card-status" :class="statusClass">
  {{ challenge.status === 'active' ? '进行中' : '已完成' }}
</text>

改为(区分三态):

<text class="card-status" :class="statusClass">
  {{ challenge.status === 'active' ? '进行中' : (challenge.status === 'cancelled' ? '已取消' : '已完成') }}
</text>

statusClass 计算属性(L55-57)已存在且通用(返回 'status-' + status),无需修改。但 style 区缺 .status-cancelled 样式(现有 L115-116 只有 .status-active/.status-completed),在 L116 后新增:

.status-cancelled { background: #FEE2E2; color: #DC2626; }
  • 步骤 2:加打卡按钮(仅 child 登录角色显示)

在卡片底部(card-detail-row 之后)加:

<view class="card-action" v-if="showCheckin && challenge.status === 'active'">
  <view class="checkin-btn" @click="onCheckin">✅ 打卡</view>
</view>

在 script 加 props 与方法:

props: {
  challenge: { type: Object, default: null },
  showCheckin: { type: Boolean, default: false }
},
methods: {
  onCheckin: function() {
    var self = this
    uni.showModal({
      title: '确认打卡',
      content: '确认完成今日打卡吗?',
      success: function(res) {
        if (res.confirm) {
          // 后端已强制使用 JWT 注入的 currentMemberId,无需传 childId
          self.$emit('checkin', { challengeId: self.challenge.id })
        }
      }
    })
  }
}

不要加 childId prop 守卫:后端 progress 端点强制使用 JwtInterceptor 注入的 currentMemberId(对 role=child 自动从 family_member 表查出,JwtInterceptor.java:147-159),前端不需要也不应传 childId。若在卡片加 if (!this.childId) 守卫会误拦合法孩子打卡——孩子登录时 currentChildId storage 可能为空(store/index.js 的 setUserInfo 不写该值、setChildren 只写 state 不写 storage)。

  • 步骤 3:index-home.vue 接入打卡事件

cfc-frontend/pages/index-home/index.vue 当前 L98-105 的 section 结构已有 section-header("🔥 家庭挑战"标题),不要改动外层结构,只改 FamilyChallengeCard 标签(原 L104-109):

<FamilyChallengeCard
  v-for="ch in activeChallenges"
  :key="ch.id"
  :challenge="ch"
  :showCheckin="showCheckinBtn"
  @checkin="onChallengeCheckin" />

保留 :key="ch.id"(简单属性访问,合规);不要引入 getItemKey(index-home 无此方法,也不必要)。卡片已无 childId prop(见步骤 2),此处不传 :childId。

打卡按钮必须按登录角色门控:index-home 是双角色页面(家长登录后可切换到孩子视角,此时 currentRole='child' 但 JWT 登录角色仍是 parent),而后端 progress 端点仅接受 role=child(JwtInterceptor 只对 child 角色注入 currentMemberId,家长打卡必被拒)。所以 不能 用 currentRole 判断,必须用登录角色 uni.getStorageSync('role')。

3a. 补 import:L379 的 api.js import 行加入 updateChallengeProgress(现有行已含 getChallengeList,在其后追加):

import { acceptParentInvite, productList, getActivityList, getFeaturedArticles, getFamilyEnergySandbox, getVisibleFamilyMembers, getTodayTasksByCategory, getChildren, getEnergyOverview, getProductsByDomain, getChallengeList, getMyCircles, discoverCircles, joinGeneralCircle, leaveGeneralCircle, switchToFamilyMember, getMyMembership, updateChallengeProgress } from '../../utils/api.js'

3b. 在 computed 区(L478 起)加 showCheckinBtn(登录角色为 child 才显示打卡按钮;家长切换孩子视角时 currentRole='child' 但登录角色仍是 parent,不可打卡):

showCheckinBtn: function() {
  return (uni.getStorageSync('role') || 'parent') === 'child'
}

3c. 在 methods 加:

onChallengeCheckin: function(payload) {
  var self = this
  updateChallengeProgress({ challengeId: payload.challengeId, delta: 1 })
    .then(function(res) {
      if (res && res.code === 200) {
        uni.showToast({ title: '打卡成功', icon: 'success' })
        self.loadChallenges()
      } else {
        uni.showToast({ title: (res && res.message) || '打卡失败', icon: 'none' })
      }
    })
}

不要加 currentChildId 守卫:孩子登录时 currentChildId storage 可能为空(store/index.js 的 setUserInfo 不写该值、setChildren 只写 state 不写 storage),守卫会误拦合法打卡。后端已强制用 JWT 注入的 currentMemberId(见任务 3 步骤 3),请求体不需要传 memberId。loadChallenges 是 index-home 现有方法(L703),直接复用。

  • 步骤 4:构建验证

运行:cd cfc-frontend && npm run build:mp-weixin 预期:构建成功,无语法错误(重点检查无可选链 ?.、无 :key 表达式)

  • [ ] 步骤 5:Commit

    git add cfc-frontend/components/FamilyChallengeCard.vue cfc-frontend/pages/index-home/index.vue
    git commit -m "feat(frontend): 家庭挑战孩子端打卡入口 + cancelled 状态显示修复"
    

任务 5:前端 — 管理页家长权限控制

文件:

  • 修改:cfc-frontend/pages/health/challenge-manage.vue

  • [ ] 步骤 1:加角色判断

在 challenge-manage.vue 的 onLoad 开头(现有 loadFamilyId() 调用之前)插入角色判断:

onLoad: function() {
  var role = this.getUserRole()
  if (role !== 'parent') {
    uni.showToast({ title: '仅家长可管理挑战', icon: 'none' })
    setTimeout(function() { uni.navigateBack() }, 1000)
    return
  }
  this.loadFamilyId()
  this.loadData()
  this.loadTemplates()
},
getUserRole: function() {
  // store state 顶层有 role 字段(见 store/index.js:15)
  return this.$store && this.$store.state ? this.$store.state.role : ''
}

已核实:challenge-manage.vue 现有 onLoad 只调 loadFamilyId()/loadData()/loadTemplates()(L170-174),无其他前置逻辑;getUserRole 是新增方法,加入 methods 区。store 角色字段是 state.role(store/index.js:15 初始 'parent'),不是 state.userInfo.role。

  • 步骤 2:验证现有加载逻辑未被破坏

读取 challenge-manage.vue 的完整 onLoad/onShow 及 created 钩子,确认插入位置正确、原有逻辑保留。

  • 步骤 3:构建验证

运行:cd cfc-frontend && npm run build:mp-weixin 预期:构建成功

  • [ ] 步骤 4:Commit

    git add cfc-frontend/pages/health/challenge-manage.vue
    git commit -m "feat(frontend): 家庭挑战管理页仅家长可访问"
    

任务 6:全量验证

  • 步骤 1:后端编译 + 测试

运行:cd cfc-backend && mvn clean compile && mvn test 预期:BUILD SUCCESS,全部测试通过

  • 步骤 2:前端构建

运行:cd cfc-frontend && npm run build:mp-weixin 预期:构建成功

  • 步骤 3:路由重复检查

⚠️ 注意:grep -rn '@Mapping' 匹配不到 @PostMapping(@PostMapping 不包含 @Mapping 子串),且方法级注解是相对路径(/list 等,不含 "challenge" 字样),所以 grep ... | grep challenge 会输出空。正确做法如下。

运行(检查 FamilyChallengeController 内部方法级路径无重复,共 7 行):

cd cfc-backend && grep -n '@PostMapping' src/main/java/com/etotem/cfc/controller/FamilyChallengeController.java

预期:/list、/history、/progress、/templates、/create、/update/{id}、/delete/{id} 各出现一次(恰好 7 行)。

再运行(确认类级前缀 /api/health/challenge 全局无第二个 controller 占用):

cd cfc-backend && grep -rn '@RequestMapping' src/main/java/com/etotem/cfc/controller/ | grep 'api/health/challenge'

预期:仅 FamilyChallengeController.java 一行命中。

  • 步骤 4:最终 Commit(若有遗漏改动)

⚠️ 仓库工作区存在 42 个遗留未提交文件(与本次无关)。禁止 git add -A 或 git add .,必须用明确文件路径。

git add cfc-backend/src/main/java/com/etotem/cfc/service/FamilyChallengeService.java \
        cfc-backend/src/main/java/com/etotem/cfc/controller/FamilyChallengeController.java \
        cfc-backend/src/test/java/com/etotem/cfc/service/FamilyChallengeServiceTest.java \
        cfc-frontend/components/FamilyChallengeCard.vue \
        cfc-frontend/pages/index-home/index.vue \
        cfc-frontend/pages/health/challenge-manage.vue
git commit -m "fix: 家庭挑战断点修复收尾"

提交前检查:git diff --cached --stat 确认 staged 内容仅含上述 6 个文件。


自检

1. 规格覆盖度(spec §7 断点 ↔ 任务映射,目标为修复 #1-#6,排除 #7):

spec §7 # 断点 任务
1 打卡无前端入口 任务 4(打卡按钮 + index-home 接入)
2 到期不自动结算 任务 3(getActiveChallenges 惰性结算)
3 奖励未发放 任务 2(checkAndSettle 发能量点)
4 无角色检查 任务 3(Controller 角色校验)+ 任务 5(管理页权限)
5 cancelled 显示为已完成 任务 4 步骤 1
6 无每日限次 任务 1(updateProgress 限次)
7 autoCreate 无调用方 不在本次范围(属于"未来"功能,spec §8;如需启用需另行 spec)

2. 占位符扫描: 无 TBD/TODO。任务 2 已核实 PointsService.awardSystemPoints 签名并写死到计划中,无"实现时确认"残留。

3. 类型一致性: updateProgress(challengeId, childId, delta) 签名全计划一致;onCheckin/onChallengeCheckin 命名一致;卡片 :key="ch.id" 为简单属性访问(合规,不违反"禁止 :key 表达式"规范);currentChildId 为 index-home 现有 data 属性直接复用;结算统一走条件更新 familyChallengeMapper.update(null, uw)(返回 int),测试 mock thenReturn(1) 与实现一致。

4. 与代码库事实核对(本计划所有引用均已验证):

  • Result.success() → code=200,Result.error() → code=500(common/Result.java),前端统一 res.code === 200 判断
  • PointsService.awardSystemPoints(Long memberId, int amount, String reason) 存在于 service/PointsService.java:97;FamilyChallengeService 需新增 @Resource private PointsService pointsService;
  • JwtInterceptor 已注入 role/familyId/currentMemberId attribute(child 角色自动查 family_member 表,JwtInterceptor.java:147-159)
  • FamilyChallengeCard.vue 的 statusClass 计算属性已存在且通用,仅缺 .status-cancelled CSS
  • index-home 现有 section-header("🔥 家庭挑战")不重复渲染;currentChildId 是现有 data 属性(L458);loadChallenges 是现有方法(L703)
  • challenge-manage.vue 现有 onLoad 仅调三个 load 方法;store 角色字段是 state.role(store/index.js:15)
  • FamilyChallengeController 各端点现有签名已读取(L19-72),替换片段基于真实代码;类级前缀 /api/health/challenge 全局唯一
  • 仓库有 42 个遗留未提交文件,所有 git 命令均为明确路径,禁止 git add -A

5. momus 复核 REJECT 修正记录(2026-08-06 第二轮):

  • 问题 1(路由命令):任务 6 步骤 3 原命令 grep '@Mapping' 匹配不到 @PostMapping、grep challenge 会过滤掉所有提取路径 → 改为两条已验证命令(7 个方法级路径 + 类前缀唯一性),已真实执行确认输出
  • 问题 2(打卡状态校验):任务 1 updateProgress 新增前置校验——挑战非 active 或已过 endDate 时返回 Result.error("挑战已结束"),杜绝过期/已完成挑战继续累加进度;新增测试 updateProgress_shouldRejectWhenChallengeNotActive
  • 问题 3(奖励幂等):任务 2 checkAndSettle 结算改为条件更新 LambdaUpdateWrapper(WHERE id=? AND status='active' 原子跃迁为 completed),仅影响行数>0 的调用发奖 → 并发/重复结算不会重复发奖;任务 2/3 测试同步改为 verify update(any(), any()) + mock 返回 1

执行交接: 计划已保存到 docs/superpowers/plans/2026-08-06-family-challenge-fix.md。