# fix-checkin-task-close:打卡完成后首页任务不关闭 > 状态:已完成修复并通过编译(`mvn clean compile` 退出码 0) > 范围:仅改动任务回写链路相关文件(1 个 Service + 3 个 Controller),未触碰 BASIC 会员配额相关文件(BasicQuotaService / ProductOrderService / MembershipService 等),未改动 dist / unpackage,未执行 git commit(遵守任务护栏)。 --- ## 一、根因确认:updateProgress 的实际行为 ### 1. 原回写链路 三个健康打卡 Controller(睡眠 / 运动 / 饮食)的 create 方法在插入打卡记录后都有「任务回写挂钩」: - 前端打卡页把 `taskId` 透传到请求体(如 `sleep-checkin.vue` 的 `doCheckin`,`taskId: self.taskId || undefined`)。 - 后端查出 `linkedTask = taskMapper.selectById(record.getTaskId())`。 - **若 `linkedTask.getIsDailyProgress() == 1`(每日进度型)**:原来调用 `taskService.updateProgress(fm.getUserId(), fm.getId(), "SLEEP"/"EXERCISE"/"DIET", 1)` 只做进度累加。 - **否则(一次性任务)**:调用 `taskService.completeTask(...)` 直接完成。 ### 2. updateProgress 的真实行为(已核实) `TaskService.updateProgress(Long userId, Long familyMemberId, String taskKey, int increment)`(TaskService.java ~1679 行): 1. 按 `(familyMemberId, is_daily_progress=1, created_at ∈ 今日, title 精确匹配 taskKey OR title LIKE '[taskKey]%')` 查询当日任务实例,**`LIMIT 1` 且无排序**。 2. 读 `task_executions.extra.progress`,累加 `increment`。 3. 写入/更新 `task_executions`(extra 记录 `{"progress":x,"target":y}`)。 4. **达标自动完成**:`if (progress >= target) awardTaskCompletionRewards(task, memberId, now)` —— 该私有方法(~893 行)内部会 `task.setStatus("completed")` 并 `taskMapper.updateById(task)`,同时发积分/能量/CF/连胜。 结论:**updateProgress 在进度达标时确实会把任务状态置为 completed**(每日内置打卡任务 `targetValue=1`,一次打卡即可达标)。 ### 3. 为什么首页卡片仍不关闭 问题不在「达标后是否置 completed」,而在于 **updateProgress 不是按前端透传的 `record.getTaskId()` 定位任务,而是按 title 前缀 `[taskKey]` + `LIMIT 1` 无排序匹配**: - **场景 A(重复实例)**:同一 taskKey 当日存在多个实例(例如 `RepeatTaskGenerator.createTaskFromTemplate` 从 `admin_task_templates` 生成的「deadline 型」实例与内置 `[SLEEP]睡眠打卡` 实例并存,或历史重复数据),`selectOne(LIMIT 1)` 完成的是**任意一条**,用户点击的那条任务卡片可能永远保持 `pending`。 - **场景 B(createdAt 不在今日窗口)**:首页 `getTodayTasks` 会通过「deadline 今日」分支返回每日进度型任务(`created_at` 可能是昨天生成的),而 `updateProgress` 只按「created_at ∈ 今日」查找 → `task == null` → `log.warn` 后直接 return,点击的实例永不被关闭。 - 且整个回写块被 Controller 级 `try { } catch { log.warn }` 包裹,失败静默,前端不会再重试。 前端侧已排除:`index-home/index.vue` 的 `onShow → _refreshOnShow → loadFamilyData → loadTodayTasks` 会重新拉 `/api/tasks/today`,并按 `t.status !== 'pending'` 过滤 —— 只要后端把**那条任务**置为 completed,卡片就会消失。问题确实出在后端「关闭的不是用户点的那条」。 ## 二、改动文件清单与每处改动 ### 1. `cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java` 新增公开方法 `updateCheckinTaskProgress(Long taskId, Long userId, Long familyMemberId, int increment)`(紧随 `updateProgress(familyMemberId, taskKey, increment)` 之后): - 按**具体 `taskId`** 精确加载任务实例(`taskMapper.selectById`),而非 title 前缀匹配。 - 成员校验:`familyMemberId` 必须属于 `userId`(多家庭防串号,与既有约定一致)。 - 任务归属校验:任务 `familyMemberId` 必须等于打卡成员;非每日进度型直接跳过(避免误关一次性任务)。 - 幂等:`status=completed` 直接 return(防重复发奖)。 - 进度读取/写入逻辑与 `updateProgress` 完全一致(读 `task_executions.extra.progress`、累加、写回)。 - **达标(progress >= targetValue,缺省 1)调用 `awardTaskCompletionRewards(task, memberId, now)`**,把这条具体的任务置为 `completed`,并复用积分/能量/CF/连胜奖励链路。 ### 2. `cfc-backend/src/main/java/com/etotem/cfc/controller/HealthSleepController.java`(create,~75-80 行) 每日进度型分支由: ```java taskService.updateProgress(fm.getUserId(), fm.getId(), "SLEEP", 1); ``` 改为: ```java taskService.updateCheckinTaskProgress(record.getTaskId(), fm.getUserId(), fm.getId(), 1); ``` 未带 taskId 的兜底分支(`else`)保持 `updateProgress(..., "SLEEP", 1)` 不变(此时没有具体实例可关)。 ### 3. `cfc-backend/src/main/java/com/etotem/cfc/controller/HealthExerciseController.java`(create,~97-102 行) 同样把 `updateProgress(fm.getUserId(), fm.getId(), "EXERCISE", 1)` 改为 `updateCheckinTaskProgress(record.getTaskId(), fm.getUserId(), fm.getId(), 1)`;无 taskId 兜底分支不变。 ### 4. `cfc-backend/src/main/java/com/etotem/cfc/controller/HealthMealController.java`(create,~257-262 行) 同样把 `updateProgress(fm.getUserId(), fm.getId(), "DIET", 1)` 改为 `updateCheckinTaskProgress(record.getTaskId(), fm.getUserId(), fm.getId(), 1)`;`isBackfill`(补打)守卫与无 taskId 兜底分支不变。 > 说明:`HealthWaterController` 使用独立的 `updateProgress(userId, "WATER", 1)` 且不接收 taskId,不属于本次三打卡(睡眠/运动/饮食)范围,未改动;如后续需要可单独评估。 ## 三、每日型与一次性任务两种语义的处理说明 | 任务类型 | 判定 | 处理 | 语义 | |---------|------|------|------| | 每日进度型 | `is_daily_progress = 1` | `updateCheckinTaskProgress(taskId, ...)`:按具体实例累计进度,达标后置 `completed` 并发奖 | **每天达成后当天关闭**;次日 `RepeatTaskGenerator.generateBuiltinCheckinTasks` 按 `(member, title, 当日)` 幂等判断,重新生成当天新实例(`targetValue=1`、`status=pending`),卡片次日重新出现。关闭今天实例不影响明天实例生成。 | | 一次性任务 | `is_daily_progress != 1` | 原 `completeTask(...)` 链路(不变) | 一次性完成即关闭,不参与每日重置。 | 兼容性: - 未传 `taskId` 的旧调用路径仍走 title-key `updateProgress`,行为完全不变。 - 新方法对「非每日进度型」「任务已 completed」「任务不属于该成员」均幂等跳过,不引入重复发奖。 - Java 8 语法兼容(无 var / 无 lambda 之外的新特性,仅用现有 `LambdaQueryWrapper`、`Calendar`、`JSON`、`@Transactional` 等既有设施)。 ## 四、前端改动 **无。** 前端(`index-home/index.vue` 的 onShow 刷新、`sleep-checkin.vue` / `diet-checkin.vue` / `exercise-checkin.vue` 透传 taskId)已满足需求:打卡页已传 `taskId`,首页返回后 onShow 会重新拉取 `/api/tasks/today` 并按 `status` 过滤。本次仅修复后端「关闭的不是用户点击的那条任务」的问题,不涉及 dist / unpackage,无需重新打包。 ## 五、编译结果 ``` cd E:/cfc/cfc-backend && mvn clean compile [INFO] BUILD SUCCESS exitcode=0 ``` ## 六、建议回归步骤 1. 启动后端(`mvn spring-boot:run`,9082)与测试库(192.168.16.251:3306/zxyj)。 2. 确认当日已生成睡眠/运动/饮食三条每日任务(`RepeatTaskGenerator` 每小时第 5 分执行;如当日未生成可等下一个整点或手工触发)。 3. 首页「我的任务」确认三条卡片均为 `pending`,显示「去完成」。 4. 点「去完成」进入睡眠打卡页,填写入睡/醒来/质量,提交 → 提示「打卡成功,任务已完成」。 5. 返回首页(触发 onShow 刷新): - ✅ 睡眠卡片从「我的任务」消失(`tasks.status='completed'`,前端按 pending 过滤)。 - ✅ `task_executions` 出现该任务的执行记录,`extra.progress=1, target=1`;`tasks.status='completed'`、`completed_at` 有值。 - ✅ 积分/能量/CF 正常发放(`awardTaskCompletionRewards` 链路),且再次重复打卡不会重复发奖(`completed` 幂等 return)。 6. 运动、饮食打卡同样执行一遍,确认三卡片均能关闭(三者行为一致)。 7. 次日(或手动触发 `RepeatTaskGenerator`)确认三条任务重新以 `pending` 出现,可再次打卡。 8. 一次性任务回归:创建一条普通一次性任务(如含 deadline 的任务),打卡完成后确认走 `completeTask` 关闭且发奖正常。 9. 多家庭回归(可选):同一账号绑定多个家庭成员时,分别打卡确认进度记到各自成员的任务,不串号。