|
@@ -100,6 +100,25 @@ public class FamilyChallengeServiceTest {
|
|
|
assertEquals(Integer.valueOf(500), result.getCode());
|
|
assertEquals(Integer.valueOf(500), result.getCode());
|
|
|
verify(familyChallengeProgressMapper, never()).updateById(any());
|
|
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());
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
```
|
|
```
|
|
|
|
|
|
|
@@ -107,8 +126,8 @@ public class FamilyChallengeServiceTest {
|
|
|
|
|
|
|
|
- [ ] **步骤 2:运行测试确认失败**
|
|
- [ ] **步骤 2:运行测试确认失败**
|
|
|
|
|
|
|
|
-运行:`cd cfc-backend && mvn test -Dtest=FamilyChallengeServiceTest#updateProgress_shouldRejectDuplicateCheckinSameDay -pl .`
|
|
|
|
|
-预期:FAIL(`updateProgress` 目前接受重复打卡,不会返回 error)
|
|
|
|
|
|
|
+运行:`cd cfc-backend && mvn test -Dtest=FamilyChallengeServiceTest#updateProgress_shouldRejectDuplicateCheckinSameDay+updateProgress_shouldRejectWhenChallengeNotActive -pl .`
|
|
|
|
|
+预期:FAIL(`updateProgress` 目前接受重复打卡、也接受非 active 挑战打卡,不会返回 error)
|
|
|
|
|
|
|
|
- [ ] **步骤 3:实现限次 + 归属校验**
|
|
- [ ] **步骤 3:实现限次 + 归属校验**
|
|
|
|
|
|
|
@@ -127,7 +146,15 @@ public Result<Void> updateProgress(Long challengeId, Long childId, int delta) {
|
|
|
return Result.error("挑战不存在");
|
|
return Result.error("挑战不存在");
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 2. memberId 归属校验:childId 对应的 family_member 必须属于挑战所属家庭(防刷他人进度)
|
|
|
|
|
|
|
+ // 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>()
|
|
LambdaQueryWrapper<FamilyMember> memberCheck = new LambdaQueryWrapper<FamilyMember>()
|
|
|
.eq(FamilyMember::getId, childId)
|
|
.eq(FamilyMember::getId, childId)
|
|
|
.eq(FamilyMember::getFamilyId, challenge.getFamilyId());
|
|
.eq(FamilyMember::getFamilyId, challenge.getFamilyId());
|
|
@@ -135,13 +162,13 @@ public Result<Void> updateProgress(Long challengeId, Long childId, int delta) {
|
|
|
return Result.error("无权为该成员打卡");
|
|
return Result.error("无权为该成员打卡");
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 3. 查该成员在本挑战的进度
|
|
|
|
|
|
|
+ // 4. 查该成员在本挑战的进度
|
|
|
LambdaQueryWrapper<FamilyChallengeProgress> wrapper = new LambdaQueryWrapper<FamilyChallengeProgress>()
|
|
LambdaQueryWrapper<FamilyChallengeProgress> wrapper = new LambdaQueryWrapper<FamilyChallengeProgress>()
|
|
|
.eq(FamilyChallengeProgress::getChallengeId, challengeId)
|
|
.eq(FamilyChallengeProgress::getChallengeId, challengeId)
|
|
|
.eq(FamilyChallengeProgress::getChildId, childId);
|
|
.eq(FamilyChallengeProgress::getChildId, childId);
|
|
|
FamilyChallengeProgress progress = familyChallengeProgressMapper.selectOne(wrapper);
|
|
FamilyChallengeProgress progress = familyChallengeProgressMapper.selectOne(wrapper);
|
|
|
|
|
|
|
|
- // 4. 每日限次:同一 challengeId+childId 当天只能打卡一次(按 updatedAt 判断)
|
|
|
|
|
|
|
+ // 5. 每日限次:同一 challengeId+childId 当天只能打卡一次(按 updatedAt 判断)
|
|
|
if (progress != null && progress.getUpdatedAt() != null) {
|
|
if (progress != null && progress.getUpdatedAt() != null) {
|
|
|
java.util.Calendar today = java.util.Calendar.getInstance();
|
|
java.util.Calendar today = java.util.Calendar.getInstance();
|
|
|
today.setTime(new Date());
|
|
today.setTime(new Date());
|
|
@@ -153,7 +180,7 @@ public Result<Void> updateProgress(Long challengeId, Long childId, int delta) {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 5. 累加进度(原有逻辑)
|
|
|
|
|
|
|
+ // 6. 累加进度(原有逻辑)
|
|
|
if (progress == null) {
|
|
if (progress == null) {
|
|
|
progress = new FamilyChallengeProgress();
|
|
progress = new FamilyChallengeProgress();
|
|
|
progress.setChallengeId(challengeId);
|
|
progress.setChallengeId(challengeId);
|
|
@@ -169,7 +196,7 @@ public Result<Void> updateProgress(Long challengeId, Long childId, int delta) {
|
|
|
familyChallengeProgressMapper.updateById(progress);
|
|
familyChallengeProgressMapper.updateById(progress);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 6. 结算判定(原有逻辑,复用已查出的 challenge,不再重复 selectById)
|
|
|
|
|
|
|
+ // 7. 结算判定(原有逻辑,复用已查出的 challenge,不再重复 selectById)
|
|
|
if ("active".equals(challenge.getStatus())) {
|
|
if ("active".equals(challenge.getStatus())) {
|
|
|
checkAndSettle(challenge);
|
|
checkAndSettle(challenge);
|
|
|
}
|
|
}
|
|
@@ -221,11 +248,13 @@ void checkAndSettle_shouldGrantPointsWhenCompleted() {
|
|
|
when(familyChallengeMapper.selectById(1L)).thenReturn(challenge);
|
|
when(familyChallengeMapper.selectById(1L)).thenReturn(challenge);
|
|
|
when(familyChallengeProgressMapper.selectList(any())).thenReturn(Arrays.asList(p));
|
|
when(familyChallengeProgressMapper.selectList(any())).thenReturn(Arrays.asList(p));
|
|
|
when(familyMemberMapper.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.settleChallenge(1L);
|
|
service.settleChallenge(1L);
|
|
|
|
|
|
|
|
- // 挑战应标记 completed
|
|
|
|
|
- verify(familyChallengeMapper).updateById(argThat(c -> "completed".equals(c.getStatus())));
|
|
|
|
|
|
|
+ // 挑战应通过条件更新标记 completed(不再是 updateById)
|
|
|
|
|
+ verify(familyChallengeMapper).update(any(), any());
|
|
|
// 进度应标记完成
|
|
// 进度应标记完成
|
|
|
verify(familyChallengeProgressMapper).updateById(argThat(p2 -> Boolean.TRUE.equals(p2.getCompleted())));
|
|
verify(familyChallengeProgressMapper).updateById(argThat(p2 -> Boolean.TRUE.equals(p2.getCompleted())));
|
|
|
// 应发放积分(PointsService.awardSystemPoints)
|
|
// 应发放积分(PointsService.awardSystemPoints)
|
|
@@ -238,7 +267,7 @@ void checkAndSettle_shouldGrantPointsWhenCompleted() {
|
|
|
- [ ] **步骤 2:运行测试确认失败**
|
|
- [ ] **步骤 2:运行测试确认失败**
|
|
|
|
|
|
|
|
运行:`cd cfc-backend && mvn test -Dtest=FamilyChallengeServiceTest#checkAndSettle_shouldGrantPointsWhenCompleted -pl .`
|
|
运行:`cd cfc-backend && mvn test -Dtest=FamilyChallengeServiceTest#checkAndSettle_shouldGrantPointsWhenCompleted -pl .`
|
|
|
-预期:FAIL(当前 `checkAndSettle` 不改 progress.completed、不发积分)
|
|
|
|
|
|
|
+预期:FAIL(当前 `checkAndSettle` 用 `updateById` 而非条件更新、不改 progress.completed、不发积分)
|
|
|
|
|
|
|
|
- [ ] **步骤 3:实现奖励发放**
|
|
- [ ] **步骤 3:实现奖励发放**
|
|
|
|
|
|
|
@@ -250,28 +279,46 @@ private PointsService pointsService;
|
|
|
```
|
|
```
|
|
|
|
|
|
|
|
> `PointsService` 与 `FamilyChallengeService` 同包(`com.etotem.cfc.service`),无需 import。字段名 `pointsService` 与类型默认 Bean Name 一致。
|
|
> `PointsService` 与 `FamilyChallengeService` 同包(`com.etotem.cfc.service`),无需 import。字段名 `pointsService` 与类型默认 Bean Name 一致。
|
|
|
|
|
+>
|
|
|
|
|
+> 同时新增 import(在 L3 `LambdaQueryWrapper` 之后):`import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;`(3b 的幂等条件更新需要)。
|
|
|
|
|
|
|
|
-**3b. 在 `checkAndSettle` 中,`isCompleted` 判定通过后、`challenge.setStatus("completed")` 之前插入:**
|
|
|
|
|
|
|
+**3b. 在 `checkAndSettle` 中,把结算判定块(L176-181,`if (isCompleted || endDate…) { challenge.setStatus("completed"); updateById; }`)整体替换为带幂等保护的结算 + 发奖:**
|
|
|
|
|
|
|
|
```java
|
|
```java
|
|
|
-// 发放奖励:能量点 + 标记成员完成
|
|
|
|
|
-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());
|
|
|
|
|
|
|
+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 判断)**:
|
|
> **为何对所有参与成员发放(不按个人 progressValue >= targetValue 判断)**:
|
|
|
> - **aggregate 模式**(如"全家总运动 300 分钟",targetValue=300):挑战达成条件是家庭**合计**达标,个人进度几乎不可能达到 300 → 若按个人目标判断则**无人得奖**,违背"奖励发放"修复意图(spec §7 #3)。挑战整体达成后,所有**参与成员**(有 progress 记录)都应获得奖励。
|
|
> - **aggregate 模式**(如"全家总运动 300 分钟",targetValue=300):挑战达成条件是家庭**合计**达标,个人进度几乎不可能达到 300 → 若按个人目标判断则**无人得奖**,违背"奖励发放"修复意图(spec §7 #3)。挑战整体达成后,所有**参与成员**(有 progress 记录)都应获得奖励。
|
|
|
> - **all_members 模式**:挑战达成条件本就是**每个成员都达到** targetValue,结算时所有成员必然已达标 → 同样应全员发放。
|
|
> - **all_members 模式**:挑战达成条件本就是**每个成员都达到** targetValue,结算时所有成员必然已达标 → 同样应全员发放。
|
|
@@ -314,11 +361,13 @@ void getActiveChallenges_shouldSettleExpiredChallenges() {
|
|
|
when(familyChallengeMapper.selectList(any())).thenReturn(Arrays.asList(expired));
|
|
when(familyChallengeMapper.selectList(any())).thenReturn(Arrays.asList(expired));
|
|
|
when(familyChallengeProgressMapper.selectList(any())).thenReturn(java.util.Collections.emptyList());
|
|
when(familyChallengeProgressMapper.selectList(any())).thenReturn(java.util.Collections.emptyList());
|
|
|
when(familyMemberMapper.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);
|
|
service.getActiveChallenges(10L);
|
|
|
|
|
|
|
|
- // 到期挑战应被结算为 completed
|
|
|
|
|
- verify(familyChallengeMapper).updateById(argThat(c -> "completed".equals(c.getStatus())));
|
|
|
|
|
|
|
+ // 到期挑战应通过条件更新结算为 completed
|
|
|
|
|
+ verify(familyChallengeMapper).update(any(), any());
|
|
|
}
|
|
}
|
|
|
```
|
|
```
|
|
|
|
|
|
|
@@ -618,11 +667,19 @@ git commit -m "feat(frontend): 家庭挑战管理页仅家长可访问"
|
|
|
|
|
|
|
|
- [ ] **步骤 3:路由重复检查**
|
|
- [ ] **步骤 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
|
|
```bash
|
|
|
-cd cfc-backend && grep -rn '@Mapping' src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u | grep challenge
|
|
|
|
|
|
|
+cd cfc-backend && grep -rn '@RequestMapping' src/main/java/com/etotem/cfc/controller/ | grep 'api/health/challenge'
|
|
|
```
|
|
```
|
|
|
-预期:仅 `list`、`history`、`progress`、`templates`、`create`、`update`、`delete` 各出现一次
|
|
|
|
|
|
|
+预期:仅 `FamilyChallengeController.java` 一行命中。
|
|
|
|
|
|
|
|
- [ ] **步骤 4:最终 Commit(若有遗漏改动)**
|
|
- [ ] **步骤 4:最终 Commit(若有遗漏改动)**
|
|
|
|
|
|
|
@@ -658,7 +715,7 @@ git commit -m "fix: 家庭挑战断点修复收尾"
|
|
|
|
|
|
|
|
**2. 占位符扫描:** 无 TBD/TODO。任务 2 已核实 `PointsService.awardSystemPoints` 签名并写死到计划中,无"实现时确认"残留。
|
|
**2. 占位符扫描:** 无 TBD/TODO。任务 2 已核实 `PointsService.awardSystemPoints` 签名并写死到计划中,无"实现时确认"残留。
|
|
|
|
|
|
|
|
-**3. 类型一致性:** `updateProgress(challengeId, childId, delta)` 签名全计划一致;`onCheckin`/`onChallengeCheckin` 命名一致;卡片 `:key="ch.id"` 为简单属性访问(合规,不违反"禁止 :key 表达式"规范);`currentChildId` 为 index-home 现有 data 属性直接复用。
|
|
|
|
|
|
|
+**3. 类型一致性:** `updateProgress(challengeId, childId, delta)` 签名全计划一致;`onCheckin`/`onChallengeCheckin` 命名一致;卡片 `:key="ch.id"` 为简单属性访问(合规,不违反"禁止 :key 表达式"规范);`currentChildId` 为 index-home 现有 data 属性直接复用;结算统一走条件更新 `familyChallengeMapper.update(null, uw)`(返回 int),测试 mock `thenReturn(1)` 与实现一致。
|
|
|
|
|
|
|
|
**4. 与代码库事实核对(本计划所有引用均已验证):**
|
|
**4. 与代码库事实核对(本计划所有引用均已验证):**
|
|
|
- `Result.success()` → `code=200`,`Result.error()` → `code=500`(`common/Result.java`),前端统一 `res.code === 200` 判断
|
|
- `Result.success()` → `code=200`,`Result.error()` → `code=500`(`common/Result.java`),前端统一 `res.code === 200` 判断
|
|
@@ -667,7 +724,12 @@ git commit -m "fix: 家庭挑战断点修复收尾"
|
|
|
- `FamilyChallengeCard.vue` 的 `statusClass` 计算属性已存在且通用,仅缺 `.status-cancelled` CSS
|
|
- `FamilyChallengeCard.vue` 的 `statusClass` 计算属性已存在且通用,仅缺 `.status-cancelled` CSS
|
|
|
- index-home 现有 `section-header`("🔥 家庭挑战")不重复渲染;`currentChildId` 是现有 data 属性(L458);`loadChallenges` 是现有方法(L703)
|
|
- index-home 现有 `section-header`("🔥 家庭挑战")不重复渲染;`currentChildId` 是现有 data 属性(L458);`loadChallenges` 是现有方法(L703)
|
|
|
- `challenge-manage.vue` 现有 `onLoad` 仅调三个 load 方法;store 角色字段是 `state.role`(`store/index.js:15`)
|
|
- `challenge-manage.vue` 现有 `onLoad` 仅调三个 load 方法;store 角色字段是 `state.role`(`store/index.js:15`)
|
|
|
-- `FamilyChallengeController` 各端点现有签名已读取(L19-72),替换片段基于真实代码
|
|
|
|
|
|
|
+- `FamilyChallengeController` 各端点现有签名已读取(L19-72),替换片段基于真实代码;类级前缀 `/api/health/challenge` 全局唯一
|
|
|
- 仓库有 42 个遗留未提交文件,所有 git 命令均为明确路径,禁止 `git add -A`
|
|
- 仓库有 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`。
|
|
**执行交接:** 计划已保存到 `docs/superpowers/plans/2026-08-06-family-challenge-fix.md`。
|