Explorar o código

fix(task): 修复多家庭成员账号打卡进度误记到他人任务

根因:同一 userId 绑定多个家庭成员(多家庭)时,updateProgress 用
family_members WHERE user_id=? LIMIT 1(无 ORDER BY)反查,命中错误成员,
导致打卡进度记到他人任务、本人任务永久 pending。
(用户12 11:20:39 误记 member 21 的 1773、11:21:45 误记 member 10 的 1752,
本人 member 12 的 1758 永久 pending → 任务列表显示「已打完还在待办」)

- TaskService.updateProgress 新增带 familyMemberId 的重载,消除 user_id 反查歧义
- HealthMeal/HealthExercise/HealthSleep 三个打卡控制器传入确定的 memberId
- calculateLatePenalty 对无 deadline 的每日打卡任务不再误判迟到扣分
- 新增 TaskProgressUpdateTest 3 个用例(unit 全绿)
Xiaogang Liao hai 6 días
pai
achega
13b398472c

+ 2 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthExerciseController.java

@@ -98,7 +98,7 @@ public class HealthExerciseController {
                         // 每日进度型任务:累计进度(taskKey=EXERCISE)
                         com.etotem.cfc.entity.FamilyMember fm = familyMemberMapper.selectById(record.getMemberId());
                         if (fm != null && fm.getUserId() != null) {
-                            taskService.updateProgress(fm.getUserId(), "EXERCISE", 1);
+                            taskService.updateProgress(fm.getUserId(), fm.getId(), "EXERCISE", 1);
                         }
                     } else {
                         // 一次性任务:直接完成
@@ -114,7 +114,7 @@ public class HealthExerciseController {
                 // 未带 taskId:累计今日每日运动打卡任务进度(无则 log.warn 跳过)
                 com.etotem.cfc.entity.FamilyMember fm = familyMemberMapper.selectById(record.getMemberId());
                 if (fm != null && fm.getUserId() != null) {
-                    taskService.updateProgress(fm.getUserId(), "EXERCISE", 1);
+                    taskService.updateProgress(fm.getUserId(), fm.getId(), "EXERCISE", 1);
                 }
             }
         } catch (Exception e) {

+ 2 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthMealController.java

@@ -258,7 +258,7 @@ public class HealthMealController {
                             // 每日进度型任务:累计进度(taskKey=DIET)
                             com.etotem.cfc.entity.FamilyMember fm = familyMemberMapper.selectById(record.getMemberId());
                             if (fm != null && fm.getUserId() != null) {
-                                taskService.updateProgress(fm.getUserId(), "DIET", 1);
+                                taskService.updateProgress(fm.getUserId(), fm.getId(), "DIET", 1);
                             }
                         } else {
                             // 一次性任务:直接完成
@@ -274,7 +274,7 @@ public class HealthMealController {
                     // 未带 taskId:仍累计今日每日打卡任务进度(无则 log.warn 跳过)
                     com.etotem.cfc.entity.FamilyMember fm = familyMemberMapper.selectById(record.getMemberId());
                     if (fm != null && fm.getUserId() != null) {
-                        taskService.updateProgress(fm.getUserId(), "DIET", 1);
+                        taskService.updateProgress(fm.getUserId(), fm.getId(), "DIET", 1);
                     }
                 }
             } catch (Exception e) {

+ 2 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthSleepController.java

@@ -76,7 +76,7 @@ public class HealthSleepController {
                         // 每日进度型任务:累计进度(taskKey=SLEEP)
                         com.etotem.cfc.entity.FamilyMember fm = familyMemberMapper.selectById(record.getMemberId());
                         if (fm != null && fm.getUserId() != null) {
-                            taskService.updateProgress(fm.getUserId(), "SLEEP", 1);
+                            taskService.updateProgress(fm.getUserId(), fm.getId(), "SLEEP", 1);
                         }
                     } else {
                         // 一次性任务:直接完成
@@ -92,7 +92,7 @@ public class HealthSleepController {
                 // 未带 taskId:累计今日每日睡眠打卡任务进度(无则 log.warn 跳过)
                 com.etotem.cfc.entity.FamilyMember fm = familyMemberMapper.selectById(record.getMemberId());
                 if (fm != null && fm.getUserId() != null) {
-                    taskService.updateProgress(fm.getUserId(), "SLEEP", 1);
+                    taskService.updateProgress(fm.getUserId(), fm.getId(), "SLEEP", 1);
                 }
             }
         } catch (Exception e) {

+ 33 - 6
cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java

@@ -968,7 +968,7 @@ public List<Task> getTodayTasks(Long memberId, String dimensionCode) {
 
         try {
             if (child.getUserId() != null) {
-                this.updateProgress(child.getUserId(), "DAILY_TASK", 1);
+                this.updateProgress(child.getUserId(), child.getId(), "DAILY_TASK", 1);
             }
         } catch (Exception e) {
             log.warn("成长任务进度更新失败: memberId={}, error={}", memberId, e.getMessage());
@@ -1199,7 +1199,11 @@ private int calculateLatePenalty(Task task, FamilyMember child, Long memberId) {
     if (child == null) {
         return 0;
     }
-    int points = task.getPoints();
+    int points = task.getPoints() != null ? task.getPoints() : 0;
+    // 无 deadline 的每日进度型任务(饮食/睡眠/运动打卡等)不存在"迟到"概念,发放基础分
+    if (task.getDeadline() == null) {
+        return points;
+    }
 
 if (child.getPenaltyEnabled() != null && child.getPenaltyEnabled() == 1) {
 // 检查今日已扣分数
@@ -1651,14 +1655,37 @@ return points; // 达到每日扣分上限:不扣分,任务完成仍发放
      */
     @Transactional
     public void updateProgress(Long userId, String taskKey, int increment) {
+        updateProgress(userId, null, taskKey, increment);
+    }
+
+    /**
+     * 统一任务系统:更新每日进度型任务的进度(带家庭成员精确定位)
+     * 按 (familyMemberId 或 userId→familyMemberId, taskKey) 找当日任务实例,累计进度,达标自动完成+发奖
+     * taskKey 匹配约定:title 精确匹配 OR title 以 "[taskKey]" 前缀匹配(迁移时 task_key 拼入 title)
+     *
+     * 打卡类调用必须显式传 familyMemberId:同一 userId 可能绑定多个家庭成员(多家庭场景),
+     * 仅按 user_id 反查(LIMIT 1 无排序)会命中错误成员,导致进度记到他人任务、本人任务永久 pending。
+     */
+    @Transactional
+    public void updateProgress(Long userId, Long familyMemberId, String taskKey, int increment) {
         if (userId == null) {
             // 未绑定登录账号的家庭成员(如仅建档的孩子)无成长任务进度
             return;
         }
-        FamilyMember member = familyMemberMapper.selectOne(
-                new LambdaQueryWrapper<FamilyMember>()
-                        .eq(FamilyMember::getUserId, userId)
-                        .last("LIMIT 1"));
+        FamilyMember member;
+        if (familyMemberId != null) {
+            member = familyMemberMapper.selectById(familyMemberId);
+            if (member == null || !Long.valueOf(userId).equals(member.getUserId())) {
+                log.warn("统一任务进度更新:成员{}不存在或不属于用户{},跳过 taskKey={}",
+                        familyMemberId, userId, taskKey);
+                return;
+            }
+        } else {
+            member = familyMemberMapper.selectOne(
+                    new LambdaQueryWrapper<FamilyMember>()
+                            .eq(FamilyMember::getUserId, userId)
+                            .last("LIMIT 1"));
+        }
         if (member == null) {
             return;
         }

+ 142 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/TaskProgressUpdateTest.java

@@ -0,0 +1,142 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.PointsLog;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.PointsLogMapper;
+import com.etotem.cfc.mapper.TaskExecutionMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.service.EnergyService;
+import com.etotem.cfc.service.FamilyPlatformPointsService;
+import com.etotem.cfc.service.PlatformPointsService;
+import com.etotem.cfc.service.StreakService;
+import com.etotem.cfc.service.TaskService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.lang.reflect.Field;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 统一任务进度回写单元测试
+ * 覆盖:
+ * 1. 显式 familyMemberId 精确更新(同一 userId 绑定多个家庭成员时不误更新他人任务)
+ * 2. 向后兼容:未传 familyMemberId 时按 userId 反查
+ * 3. 无 deadline 的每日打卡任务完成发放基础分,不误判"迟到"扣分
+ */
+class TaskProgressUpdateTest {
+
+    private TaskService service;
+    private TaskMapper taskMapper;
+    private FamilyMemberMapper familyMemberMapper;
+    private TaskExecutionMapper taskExecutionMapper;
+    private PointsLogMapper pointsLogMapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        service = new TaskService();
+        taskMapper = mock(TaskMapper.class);
+        familyMemberMapper = mock(FamilyMemberMapper.class);
+        taskExecutionMapper = mock(TaskExecutionMapper.class);
+        pointsLogMapper = mock(PointsLogMapper.class);
+        inject("taskMapper", taskMapper);
+        inject("familyMemberMapper", familyMemberMapper);
+        inject("taskExecutionMapper", taskExecutionMapper);
+        inject("pointsLogMapper", pointsLogMapper);
+        inject("streakService", mock(StreakService.class));
+        inject("energyService", mock(EnergyService.class));
+        inject("platformPointsService", mock(PlatformPointsService.class));
+        inject("familyPlatformPointsService", mock(FamilyPlatformPointsService.class));
+    }
+
+    private void inject(String field, Object value) throws Exception {
+        Field f = TaskService.class.getDeclaredField(field);
+        f.setAccessible(true);
+        f.set(service, value);
+    }
+
+    private FamilyMember member(long id, long userId, boolean penaltyEnabled) {
+        FamilyMember m = new FamilyMember();
+        m.setId(id);
+        m.setUserId(userId);
+        m.setFamilyId(1L);
+        m.setTotalPoints(100);
+        m.setStreakDays(0);
+        m.setPenaltyEnabled(penaltyEnabled ? 1 : 0);
+        return m;
+    }
+
+    private Task dietTask(long id, long familyMemberId) {
+        Task t = new Task();
+        t.setId(id);
+        t.setTitle("[DIET]饮食打卡");
+        t.setStatus("pending");
+        t.setPoints(5);
+        t.setTargetValue(1);
+        t.setIsDailyProgress(1);
+        t.setFamilyMemberId(familyMemberId);
+        t.setFamilyId(1L);
+        t.setDeadline(null);
+        t.setBonusRule(null);
+        return t;
+    }
+
+    @Test
+    void updateProgress_withExplicitFamilyMemberId_doesNotFallbackToUserIdLookup() {
+        // 同一 userId 绑定多个家庭成员时,必须按显式 familyMemberId 精确更新
+        when(familyMemberMapper.selectById(12L)).thenReturn(member(12L, 12L, false));
+        // 第一次为 DIET 任务,第二次为嵌套的 DAILY_TASK 查询(返回 null)
+        when(taskMapper.selectOne(any(LambdaQueryWrapper.class)))
+                .thenReturn(dietTask(1758L, 12L), null);
+        when(taskExecutionMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null);
+        when(taskMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.emptyList());
+
+        service.updateProgress(12L, 12L, "DIET", 1);
+
+        // 关键断言:显式指定成员时不做 user_id 反查(避免命中错误成员的任务)
+        verify(familyMemberMapper, atLeastOnce()).selectById(12L);
+        verify(familyMemberMapper, never()).selectOne(any());
+    }
+
+    @Test
+    void updateProgress_withoutFamilyMemberId_fallsBackToUserIdLookup() {
+        when(familyMemberMapper.selectOne(any(LambdaQueryWrapper.class)))
+                .thenReturn(member(12L, 12L, false));
+        when(taskMapper.selectOne(any(LambdaQueryWrapper.class)))
+                .thenReturn(dietTask(1758L, 12L), null);
+        when(taskExecutionMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null);
+        when(taskMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.emptyList());
+
+        service.updateProgress(12L, "DIET", 1);
+
+        verify(familyMemberMapper).selectOne(any(LambdaQueryWrapper.class));
+    }
+
+    @Test
+    void updateProgress_noDeadlineTask_awardsBasePointsInsteadOfPenalty() {
+        // 每日打卡任务无 deadline,即使开启扣分开关也不应被误判"迟到"扣分
+        when(familyMemberMapper.selectById(12L)).thenReturn(member(12L, 12L, true));
+        when(pointsLogMapper.selectSum(any(LambdaQueryWrapper.class))).thenReturn(0);
+        when(taskMapper.selectOne(any(LambdaQueryWrapper.class)))
+                .thenReturn(dietTask(1758L, 12L), null);
+        when(taskExecutionMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null);
+        when(taskMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.emptyList());
+
+        service.updateProgress(12L, 12L, "DIET", 1);
+
+        ArgumentCaptor<PointsLog> captor = ArgumentCaptor.forClass(PointsLog.class);
+        verify(pointsLogMapper).insert(captor.capture());
+        assertEquals(5, captor.getValue().getAmount());
+    }
+}