Quellcode durchsuchen

docs(plan): 家庭挑战断点修复实现计划 + PROJECT-OVERVIEW 索引更新

Xiaogang Liao vor 1 Monat
Ursprung
Commit
9c419d4e1b

+ 2 - 0
docs/superpowers/PROJECT-OVERVIEW.md

@@ -336,6 +336,7 @@
 | `2026-08-04-report-confirm-member-selector-design.md` | 🟡 设计稿 | 健康报告确认页被检测人选择面板整合(基本信息内选/添被检人) |
 | `2026-08-04-family-challenge-custom.md` | 🟢 已实施 | 家庭挑战自定义管理(创建/编辑/删除/模板) |
 | `2026-08-05-inventory-management-design.md` | 🟡 设计稿 | 库存管理(入库单+流水+盘点+预警+套餐拆库存) |
+| `2026-08-06-family-challenge-flow-design.md` | 🟢 现状基线(含断点清单) | 家庭挑战使用流程梳理(7 断点,含实现状态标注) |
 
 ### 实施计划(plans/)
 
@@ -391,6 +392,7 @@
 | `2026-07-28-membership-price-tier-update.md` | ✅ 全栈完成(FREE/FAMILY/PREMIUM 三等级,7 次提交) | 会员等级价格体系更新 |
 | `2026-07-31-family-members-redesign.md` | 🟡 计划中 | 家庭成员页面重新设计 |
 | `2026-08-02-virtual-payment-implementation.md` | 🟢 已实施 | 小程序虚拟支付接入实施计划(12 Tasks:建表/签名/推送/三链路改造/前端/管理端) |
+| `2026-08-06-family-challenge-fix.md` | 🟡 计划中 | 家庭挑战断点修复(打卡限次/归属校验/奖励发放/角色检查/状态显示) |
 | `2026-08-04-virtual-payment-refund-followup.md` | 🔵 pending | 虚拟支付退款闭环 follow-up(会员退款ORD分支 + iOS退款问询按官方协议接入) |
 | `2026-08-04-registration-profile-backfill.md` | ✅ 已实施(12 Tasks:注册解耦最小集 + 个人中心卡片 + 功能场景就地补充自动回写 + 无家庭引导组件) | 新用户注册引导实施计划 |
 | `2026-08-04-report-confirm-member-selector.md` | ✅ 已完成 | 被检测人选择面板整合实现计划(report-confirm.vue 单文件) |

+ 586 - 0
docs/superpowers/plans/2026-08-06-family-challenge-fix.md

@@ -0,0 +1,586 @@
+# 家庭挑战断点修复实现计划
+
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
+
+**目标:** 修复家庭挑战功能的 7 个断点(spec: `docs/superpowers/specs/2026-08-06-family-challenge-flow-design.md` §7),使挑战形成闭环:家长创建 → 孩子打卡(限次)→ 到期结算 → 奖励发放,并补齐角色校验与前端展示修复。
+
+**架构:** 后端 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:编写失败的测试(限次逻辑)**
+
+```java
+package com.etotem.cfc.service;
+
+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 org.springframework.test.util.ReflectionTestUtils;
+
+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.Mockito.*;
+
+public class FamilyChallengeServiceTest {
+
+    @Mock private FamilyChallengeMapper familyChallengeMapper;
+    @Mock private FamilyChallengeProgressMapper familyChallengeProgressMapper;
+    @Mock private FamilyMemberMapper familyMemberMapper;
+    @Mock private HealthScoreService healthScoreService;
+    @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);
+
+        var result = service.updateProgress(1L, 100L, 1);
+
+        assertEquals("error", result.getCode());
+        verify(familyChallengeProgressMapper, never()).updateById(any());
+    }
+}
+```
+
+- [ ] **步骤 2:运行测试确认失败**
+
+运行:`cd cfc-backend && mvn test -Dtest=FamilyChallengeServiceTest#updateProgress_shouldRejectDuplicateCheckinSameDay -pl .`
+预期:FAIL(`updateProgress` 目前接受重复打卡,不会返回 error)
+
+- [ ] **步骤 3:实现限次 + 归属校验**
+
+将 `FamilyChallengeService.updateProgress`(L93-125)整体替换为以下实现(合并原有逻辑 + 新增校验,消除重复查询):
+
+```java
+@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. 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("无权为该成员打卡");
+    }
+
+    // 3. 查该成员在本挑战的进度
+    LambdaQueryWrapper<FamilyChallengeProgress> wrapper = new LambdaQueryWrapper<FamilyChallengeProgress>()
+            .eq(FamilyChallengeProgress::getChallengeId, challengeId)
+            .eq(FamilyChallengeProgress::getChildId, childId);
+    FamilyChallengeProgress progress = familyChallengeProgressMapper.selectOne(wrapper);
+
+    // 4. 每日限次:同一 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("今日已打卡,明天再来吧");
+        }
+    }
+
+    // 5. 累加进度(原有逻辑)
+    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);
+    }
+
+    // 6. 结算判定(原有逻辑,复用已查出的 challenge,不再重复 selectById)
+    if ("active".equals(challenge.getStatus())) {
+        checkAndSettle(challenge);
+    }
+
+    return Result.success();
+}
+```
+
+- [ ] **步骤 4:运行测试确认通过**
+
+运行:`cd cfc-backend && mvn test -Dtest=FamilyChallengeServiceTest -pl .`
+预期:PASS
+
+- [ ] **步骤 5:Commit**
+
+```bash
+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:编写失败的测试(结算发放奖励)**
+
+```java
+@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());
+
+    service.settleChallenge(1L);
+
+    // 挑战应标记 completed
+    verify(familyChallengeMapper).updateById(argThat(c -> "completed".equals(c.getStatus())));
+    // 进度应标记完成
+    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`),`@Resource` 注入 `PointsService pointsService`。测试需 `@Mock private PointsService pointsService;`。
+
+- [ ] **步骤 2:运行测试确认失败**
+
+运行:`cd cfc-backend && mvn test -Dtest=FamilyChallengeServiceTest#checkAndSettle_shouldGrantPointsWhenCompleted -pl .`
+预期:FAIL(当前 `checkAndSettle` 不改 progress.completed、不发积分)
+
+- [ ] **步骤 3:实现奖励发放**
+
+在 `checkAndSettle` 中,`isCompleted` 判定通过后、`challenge.setStatus("completed")` 之前插入:
+
+```java
+// 发放奖励:能量点 + 标记成员完成
+if (isCompleted) {
+    int points = challenge.getRewardPoints() != null ? challenge.getRewardPoints() : 0;
+    for (FamilyChallengeProgress p : progresses) {
+        if (p.getProgressValue() != null && p.getProgressValue() >= challenge.getTargetValue()) {
+            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());
+            }
+        }
+    }
+}
+```
+
+- [ ] **步骤 4:运行测试确认通过**
+
+运行:`cd cfc-backend && mvn test -Dtest=FamilyChallengeServiceTest -pl .`
+预期:PASS
+
+- [ ] **步骤 5:Commit**
+
+```bash
+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:编写失败的测试(查询时惰性结算)**
+
+```java
+@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());
+
+    service.getActiveChallenges(10L);
+
+    // 到期挑战应被结算为 completed
+    verify(familyChallengeMapper).updateById(argThat(c -> "completed".equals(c.getStatus())));
+}
+```
+
+- [ ] **步骤 2:运行测试确认失败**
+
+运行:`cd cfc-backend && mvn test -Dtest=FamilyChallengeServiceTest#getActiveChallenges_shouldSettleExpiredChallenges -pl .`
+预期:FAIL(当前 `getActiveChallenges` 只查不改)
+
+- [ ] **步骤 3:实现惰性结算 + 角色校验**
+
+在 `getActiveChallenges` 中,`selectList` 之后、`fillProgress` 之前插入:
+
+```java
+// 惰性结算:遍历 active 挑战,到期的触发 checkAndSettle
+for (FamilyChallenge c : active) {
+    if (c.getEndDate() != null && new Date().after(c.getEndDate())) {
+        checkAndSettle(c);
+    }
+}
+// 结算后重新查询一次(排除刚结算的)
+active = familyChallengeMapper.selectList(wrapper);
+```
+
+在 `FamilyChallengeController` 各端点加角色校验(模式参考项目其他 controller):
+
+```java
+// list/history:任何登录角色可看(parent/child),保持现状
+// progress:仅 child 可打卡(parent/teacher 拒绝)
+@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("仅孩子可打卡");
+    }
+    // ... 原逻辑
+}
+
+// create/update/delete:仅 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("仅家长可创建挑战");
+    }
+    // ... 原逻辑
+}
+// update/delete 同理加 "仅家长" 校验
+```
+
+- [ ] **步骤 4:运行测试确认通过**
+
+运行:`cd cfc-backend && mvn test -Dtest=FamilyChallengeServiceTest -pl .`
+预期:PASS
+
+- [ ] **步骤 5:验证编译**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS(无编译错误)
+
+- [ ] **步骤 6:Commit**
+
+```bash
+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`(如需传 `childId`)
+- 修改:`cfc-frontend/pages/home-pages/parent-index.vue`(如需传 `childId`)
+
+- [ ] **步骤 1:修复 cancelled 状态显示**
+
+`FamilyChallengeCard.vue` 第 5-7 行当前:
+
+```html
+<text class="card-status" :class="statusClass">
+  {{ challenge.status === 'active' ? '进行中' : '已完成' }}
+</text>
+```
+
+改为(区分三态):
+
+```html
+<text class="card-status" :class="statusClass">
+  {{ challenge.status === 'active' ? '进行中' : (challenge.status === 'cancelled' ? '已取消' : '已完成') }}
+</text>
+```
+
+并在 `statusClass` 计算属性加 cancelled 分支:
+
+```js
+statusClass: function() {
+  var s = this.challenge && this.challenge.status ? this.challenge.status : 'active'
+  return 'status-' + s
+}
+```
+
+- [ ] **步骤 2:加打卡按钮(孩子端显示)**
+
+在卡片底部(`card-detail-row` 之后)加:
+
+```html
+<view class="card-action" v-if="showCheckin && challenge.status === 'active'">
+  <view class="checkin-btn" @click="onCheckin">✅ 打卡</view>
+</view>
+```
+
+在 script 加 props 与方法:
+
+```js
+props: {
+  challenge: { type: Object, default: null },
+  showCheckin: { type: Boolean, default: false },
+  childId: { type: [Number, String], default: null }
+},
+methods: {
+  onCheckin: function() {
+    var self = this
+    if (!this.childId) {
+      uni.showToast({ title: '请先选择家庭成员', icon: 'none' })
+      return
+    }
+    uni.showModal({
+      title: '确认打卡',
+      content: '确认完成今日打卡吗?',
+      success: function(res) {
+        if (res.confirm) {
+          self.$emit('checkin', { challengeId: self.challenge.id, childId: self.childId })
+        }
+      }
+    })
+  }
+}
+```
+
+- [ ] **步骤 3:index-home.vue 传入 childId + 处理打卡事件**
+
+`cfc-frontend/pages/index-home/index.vue` 第 98-105 行当前嵌入卡片未传 childId。改为:
+
+```html
+<view class="section challenge-section" v-if="isLoggedIn() && activeChallenges.length > 0">
+  <FamilyChallengeCard
+    v-for="ch in activeChallenges"
+    :key="getItemKey(ch)"
+    :challenge="ch"
+    :showCheckin="true"
+    :childId="currentChildId"
+    @checkin="onChallengeCheckin" />
+</view>
+```
+
+并在 methods 加(`currentChildId` 从 Vuex/store 取当前孩子成员 id,若为空则用 `currentMemberId` 逻辑):
+
+```js
+onChallengeCheckin: function(payload) {
+  var self = this
+  updateChallengeProgress({ challengeId: payload.challengeId, memberId: payload.childId, delta: 1 })
+    .then(function(res) {
+      if (res && res.code === 0) {
+        uni.showToast({ title: '打卡成功', icon: 'success' })
+        self.loadChallenges()
+      } else {
+        uni.showToast({ title: (res && res.message) || '打卡失败', icon: 'none' })
+      }
+    })
+},
+getItemKey: function(item) {
+  return item.id || item.challengeId || ''
+}
+```
+
+> 注意:`getItemKey` 若已在页面 methods 中存在(用于其他列表),复用;`currentChildId` 若页面已存在直接复用,否则从 Vuex store 读当前成员。
+
+- [ ] **步骤 4:构建验证**
+
+运行:`cd cfc-frontend && npm run build:mp-weixin`
+预期:构建成功,无语法错误(重点检查无可选链 `?.`、无 `:key` 表达式)
+
+- [ ] **步骤 5:Commit**
+
+```bash
+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` 中读取角色,非 parent 时提示并返回:
+
+```js
+onLoad: function() {
+  var role = this.getUserRole()
+  if (role !== 'parent') {
+    uni.showToast({ title: '仅家长可管理挑战', icon: 'none' })
+    setTimeout(function() { uni.navigateBack() }, 1000)
+    return
+  }
+  // ... 原有加载逻辑
+},
+getUserRole: function() {
+  // 从 store 读取当前角色
+  return this.$store && this.$store.state && this.$store.state.userInfo && this.$store.state.userInfo.role
+}
+```
+
+- [ ] **步骤 2:验证现有加载逻辑未被破坏**
+
+读取 `challenge-manage.vue` 的完整 `onLoad`/`onShow` 及 `created` 钩子,确认插入位置正确、原有逻辑保留。
+
+- [ ] **步骤 3:构建验证**
+
+运行:`cd cfc-frontend && npm run build:mp-weixin`
+预期:构建成功
+
+- [ ] **步骤 4:Commit**
+
+```bash
+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:路由重复检查**
+
+运行:
+```bash
+cd cfc-backend && grep -rn '@Mapping' src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u | grep challenge
+```
+预期:仅 `list`、`history`、`progress`、`templates`、`create`、`update`、`delete` 各出现一次
+
+- [ ] **步骤 4:最终 Commit(若有遗漏改动)**
+
+```bash
+git add -A -- cfc-backend/src/main/java/com/etotem/cfc cfc-frontend/components/FamilyChallengeCard.vue cfc-frontend/pages
+git commit -m "fix: 家庭挑战断点修复收尾"
+```
+
+---
+
+## 自检
+
+**1. 规格覆盖度(spec §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`/`getItemKey` 命名一致;`getItemKey` 已在项目其他页面存在时复用。
+
+**执行交接:** 计划已保存到 `docs/superpowers/plans/2026-08-06-family-challenge-fix.md`。