面向 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 |
孩子端展示打卡入口(若卡片已含则跳过) | 修改 |
文件:
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)。
运行:cd cfc-backend && mvn test -Dtest=FamilyChallengeServiceTest#updateProgress_shouldRejectDuplicateCheckinSameDay+updateProgress_shouldRejectWhenChallengeNotActive -pl .
预期:FAIL(updateProgress 目前接受重复打卡、也接受非 active 挑战打卡,不会返回 error)
将 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();
}
运行: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 归属校验"
文件:
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)。
运行:cd cfc-backend && mvn test -Dtest=FamilyChallengeServiceTest#checkAndSettle_shouldGrantPointsWhenCompleted -pl .
预期:FAIL(当前 checkAndSettle 用 updateById 而非条件更新、不改 progress.completed、不发积分)
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 → 若按个人目标判断则无人得奖,违背"奖励发放"修复意图(spec §7 #3)。挑战整体达成后,所有参与成员(有 progress 记录)都应获得奖励。
- all_members 模式:挑战达成条件本就是每个成员都达到 targetValue,结算时所有成员必然已达标 → 同样应全员发放。
- 两种模式下"有 progress 记录 = 参与了该挑战",统一全员标记完成 + 发放,逻辑一致且简单。
运行: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): 家庭挑战结算发放能量点奖励并标记成员完成"
文件:
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 只查不改)
在 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)。
运行:cd cfc-backend && mvn test -Dtest=FamilyChallengeServiceTest -pl .
预期:PASS
运行: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): 家庭挑战到期惰性结算 + 角色校验(家长管理/孩子打卡)"
文件:
cfc-frontend/components/FamilyChallengeCard.vuecfc-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; }
在卡片底部(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 })
}
}
})
}
}
不要加
childIdprop 守卫:后端 progress 端点强制使用 JwtInterceptor 注入的currentMemberId(对role=child自动从 family_member 表查出,JwtInterceptor.java:147-159),前端不需要也不应传 childId。若在卡片加if (!this.childId)守卫会误拦合法孩子打卡——孩子登录时currentChildIdstorage 可能为空(store/index.js的setUserInfo不写该值、setChildren只写 state 不写 storage)。
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 无此方法,也不必要)。卡片已无childIdprop(见步骤 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守卫:孩子登录时currentChildIdstorage 可能为空(store/index.js的setUserInfo不写该值、setChildren只写 state 不写 storage),守卫会误拦合法打卡。后端已强制用 JWT 注入的currentMemberId(见任务 3 步骤 3),请求体不需要传memberId。loadChallenges是 index-home 现有方法(L703),直接复用。
运行: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 状态显示修复"
文件:
修改: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。
读取 challenge-manage.vue 的完整 onLoad/onShow 及 created 钩子,确认插入位置正确、原有逻辑保留。
运行:cd cfc-frontend && npm run build:mp-weixin
预期:构建成功
[ ] 步骤 4:Commit
git add cfc-frontend/pages/health/challenge-manage.vue
git commit -m "feat(frontend): 家庭挑战管理页仅家长可访问"
运行:cd cfc-backend && mvn clean compile && mvn test
预期:BUILD SUCCESS,全部测试通过
运行:cd cfc-frontend && npm run build:mp-weixin
预期:构建成功
⚠️ 注意:
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 一行命中。
⚠️ 仓库工作区存在 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 CSSsection-header("🔥 家庭挑战")不重复渲染;currentChildId 是现有 data 属性(L458);loadChallenges 是现有方法(L703)challenge-manage.vue 现有 onLoad 仅调三个 load 方法;store 角色字段是 state.role(store/index.js:15)FamilyChallengeController 各端点现有签名已读取(L19-72),替换片段基于真实代码;类级前缀 /api/health/challenge 全局唯一git add -A5. momus 复核 REJECT 修正记录(2026-08-06 第二轮):
grep '@Mapping' 匹配不到 @PostMapping、grep challenge 会过滤掉所有提取路径 → 改为两条已验证命令(7 个方法级路径 + 类前缀唯一性),已真实执行确认输出updateProgress 新增前置校验——挑战非 active 或已过 endDate 时返回 Result.error("挑战已结束"),杜绝过期/已完成挑战继续累加进度;新增测试 updateProgress_shouldRejectWhenChallengeNotActivecheckAndSettle 结算改为条件更新 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。