Prechádzať zdrojové kódy

docs(plan): 修复 momus 二轮评审问题(路由命令/打卡状态校验/奖励幂等条件更新)

Xiaogang Liao 3 mesiacov pred
rodič
commit
d381646de3

+ 93 - 31
docs/superpowers/plans/2026-08-06-family-challenge-fix.md

@@ -100,6 +100,25 @@ public class FamilyChallengeServiceTest {
         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());
+    }
 }
 ```
 
@@ -107,8 +126,8 @@ public class FamilyChallengeServiceTest {
 
 - [ ] **步骤 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:实现限次 + 归属校验**
 
@@ -127,7 +146,15 @@ public Result<Void> updateProgress(Long challengeId, Long childId, int delta) {
         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>()
             .eq(FamilyMember::getId, childId)
             .eq(FamilyMember::getFamilyId, challenge.getFamilyId());
@@ -135,13 +162,13 @@ public Result<Void> updateProgress(Long challengeId, Long childId, int delta) {
         return Result.error("无权为该成员打卡");
     }
 
-    // 3. 查该成员在本挑战的进度
+    // 4. 查该成员在本挑战的进度
     LambdaQueryWrapper<FamilyChallengeProgress> wrapper = new LambdaQueryWrapper<FamilyChallengeProgress>()
             .eq(FamilyChallengeProgress::getChallengeId, challengeId)
             .eq(FamilyChallengeProgress::getChildId, childId);
     FamilyChallengeProgress progress = familyChallengeProgressMapper.selectOne(wrapper);
 
-    // 4. 每日限次:同一 challengeId+childId 当天只能打卡一次(按 updatedAt 判断)
+    // 5. 每日限次:同一 challengeId+childId 当天只能打卡一次(按 updatedAt 判断)
     if (progress != null && progress.getUpdatedAt() != null) {
         java.util.Calendar today = java.util.Calendar.getInstance();
         today.setTime(new Date());
@@ -153,7 +180,7 @@ public Result<Void> updateProgress(Long challengeId, Long childId, int delta) {
         }
     }
 
-    // 5. 累加进度(原有逻辑)
+    // 6. 累加进度(原有逻辑)
     if (progress == null) {
         progress = new FamilyChallengeProgress();
         progress.setChallengeId(challengeId);
@@ -169,7 +196,7 @@ public Result<Void> updateProgress(Long challengeId, Long childId, int delta) {
         familyChallengeProgressMapper.updateById(progress);
     }
 
-    // 6. 结算判定(原有逻辑,复用已查出的 challenge,不再重复 selectById)
+    // 7. 结算判定(原有逻辑,复用已查出的 challenge,不再重复 selectById)
     if ("active".equals(challenge.getStatus())) {
         checkAndSettle(challenge);
     }
@@ -221,11 +248,13 @@ void checkAndSettle_shouldGrantPointsWhenCompleted() {
     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
-    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())));
     // 应发放积分(PointsService.awardSystemPoints)
@@ -238,7 +267,7 @@ void checkAndSettle_shouldGrantPointsWhenCompleted() {
 - [ ] **步骤 2:运行测试确认失败**
 
 运行:`cd cfc-backend && mvn test -Dtest=FamilyChallengeServiceTest#checkAndSettle_shouldGrantPointsWhenCompleted -pl .`
-预期:FAIL(当前 `checkAndSettle` 不改 progress.completed、不发积分)
+预期:FAIL(当前 `checkAndSettle` 用 `updateById` 而非条件更新、不改 progress.completed、不发积分)
 
 - [ ] **步骤 3:实现奖励发放**
 
@@ -250,28 +279,46 @@ 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` 中,`isCompleted` 判定通过后、`challenge.setStatus("completed")` 之前插入:**
+**3b. 在 `checkAndSettle` 中,把结算判定块(L176-181,`if (isCompleted || endDate…) { challenge.setStatus("completed"); updateById; }`)整体替换为带幂等保护的结算 + 发奖:**
 
 ```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 判断)**:
 > - **aggregate 模式**(如"全家总运动 300 分钟",targetValue=300):挑战达成条件是家庭**合计**达标,个人进度几乎不可能达到 300 → 若按个人目标判断则**无人得奖**,违背"奖励发放"修复意图(spec §7 #3)。挑战整体达成后,所有**参与成员**(有 progress 记录)都应获得奖励。
 > - **all_members 模式**:挑战达成条件本就是**每个成员都达到** targetValue,结算时所有成员必然已达标 → 同样应全员发放。
@@ -314,11 +361,13 @@ void getActiveChallenges_shouldSettleExpiredChallenges() {
     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).updateById(argThat(c -> "completed".equals(c.getStatus())));
+    // 到期挑战应通过条件更新结算为 completed
+    verify(familyChallengeMapper).update(any(), any());
 }
 ```
 
@@ -618,11 +667,19 @@ git commit -m "feat(frontend): 家庭挑战管理页仅家长可访问"
 
 - [ ] **步骤 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 '@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(若有遗漏改动)**
 
@@ -658,7 +715,7 @@ git commit -m "fix: 家庭挑战断点修复收尾"
 
 **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. 与代码库事实核对(本计划所有引用均已验证):**
 - `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
 - index-home 现有 `section-header`("🔥 家庭挑战")不重复渲染;`currentChildId` 是现有 data 属性(L458);`loadChallenges` 是现有方法(L703)
 - `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`
 
+**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`。