fix-checkin-task-close.md 8.8 KB

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 行)

每日进度型分支由:

taskService.updateProgress(fm.getUserId(), fm.getId(), "SLEEP", 1);

改为:

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. 多家庭回归(可选):同一账号绑定多个家庭成员时,分别打卡确认进度记到各自成员的任务,不串号。