# 家庭挑战断点修复实现计划 > **面向 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:编写失败的测试(限次逻辑)** ```java 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 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 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)整体替换为以下实现(合并原有逻辑 + 新增校验,消除重复查询): ```java @Transactional public Result 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 memberCheck = new LambdaQueryWrapper() .eq(FamilyMember::getId, childId) .eq(FamilyMember::getFamilyId, challenge.getFamilyId()); if (familyMemberMapper.selectCount(memberCheck) == 0) { return Result.error("无权为该成员打卡"); } // 4. 查该成员在本挑战的进度 LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .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** ```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()); // 条件更新成功(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 之后): ```java @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; }`)整体替换为带幂等保护的结算 + 发奖:** ```java if (isCompleted || challenge.getEndDate() == null || new Date().after(challenge.getEndDate())) { // 幂等结算:仅当挑战仍是 active 时原子跃迁为 completed(条件更新 WHERE id=? AND status='active')。 // 并发打卡/重复触发结算时,只有一个请求能更新成功(影响行数=1),其余请求影响行数=0 直接跳过, // 从根源杜绝重复发奖。 LambdaUpdateWrapper uw = new LambdaUpdateWrapper() .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 → 若按个人目标判断则**无人得奖**,违背"奖励发放"修复意图(spec §7 #3)。挑战整体达成后,所有**参与成员**(有 progress 记录)都应获得奖励。 > - **all_members 模式**:挑战达成条件本就是**每个成员都达到** targetValue,结算时所有成员必然已达标 → 同样应全员发放。 > - 两种模式下"有 progress 记录 = 参与了该挑战",统一全员标记完成 + 发放,逻辑一致且简单。 - [ ] **步骤 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()); // 条件更新成功(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` 之前插入: ```java // 惰性结算:遍历 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 调用): ```java // list/history:任何登录角色可看(parent/child),保持现状不动(L19-33 不改) // progress:仅 child 可打卡;强制用 currentMemberId,忽略请求体 memberId(防刷他人进度) @PostMapping("/progress") public Result updateProgress(@RequestBody Map 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 createChallenge(@RequestBody Map 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 updateChallenge(@PathVariable("id") Long challengeId, @RequestBody Map 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 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** ```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`(孩子端打卡入口) - **不改**:`cfc-frontend/pages/home-pages/parent-index.vue`(家长端 L51-53 只传 `:challenge="ch"`,`showCheckin` 默认 false 不显示打卡按钮——后端仅允许 child 角色打卡,家长端不应出现打卡入口;状态显示修复在组件内部自动生效,无需改动此文件) - [ ] **步骤 1:修复 cancelled 状态显示** `FamilyChallengeCard.vue` 第 5-7 行当前: ```html {{ challenge.status === 'active' ? '进行中' : '已完成' }} ``` 改为(区分三态): ```html {{ challenge.status === 'active' ? '进行中' : (challenge.status === 'cancelled' ? '已取消' : '已完成') }} ``` `statusClass` 计算属性(L55-57)已存在且通用(返回 `'status-' + status`),**无需修改**。但 style 区缺 `.status-cancelled` 样式(现有 L115-116 只有 `.status-active`/`.status-completed`),在 L116 后新增: ```css .status-cancelled { background: #FEE2E2; color: #DC2626; } ``` - [ ] **步骤 2:加打卡按钮(仅 child 登录角色显示)** 在卡片底部(`card-detail-row` 之后)加: ```html ✅ 打卡 ``` 在 script 加 props 与方法: ```js 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): ```html ``` > 保留 `: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`,在其后追加): ```js 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`,不可打卡): ```js showCheckinBtn: function() { return (uni.getStorageSync('role') || 'parent') === 'child' } ``` **3c. 在 methods 加**: ```js 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** ```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` 开头(现有 `loadFamilyId()` 调用之前)插入角色判断: ```js 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** ```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:路由重复检查** > ⚠️ 注意:`grep -rn '@Mapping'` 匹配不到 `@PostMapping`(`@PostMapping` 不包含 `@Mapping` 子串),且方法级注解是相对路径(`/list` 等,不含 "challenge" 字样),所以 `grep ... | grep challenge` 会输出空。正确做法如下。 运行(检查 FamilyChallengeController 内部方法级路径无重复,共 7 行): ```bash 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 占用): ```bash 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 .`,必须用明确文件路径。 ```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 \ 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`。