|
|
@@ -0,0 +1,151 @@
|
|
|
+# 睡眠打卡完成接口 500 与双身份任务列表——根因诊断与修复设计
|
|
|
+
|
|
|
+> 环境:生产 `cfc.etotem.com.cn`
|
|
|
+> 现象(用户原话):从首页任务进睡眠打卡,完成后 `POST /api/tasks/2964/complete` 返回
|
|
|
+> `{"code":500,"message":"任务不存在、无权完成或状态已变更","data":null}`,但首页任务确实取消掉了。
|
|
|
+> 任务列表页面,本人和本人所对应的成员依然同时存在,且对应的任务列表不同。
|
|
|
+> 诊断日期:2026-10-25
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 一、结论速览(单一事实源)
|
|
|
+
|
|
|
+**500 的根因 = 双身份(数据)+ 重复完成(流程)叠加:**
|
|
|
+
|
|
|
+1. 睡眠打卡是每日进度型任务,由 `POST /api/health/sleep/create` 在落库后**自动完成**(达标即 `status=completed`)。
|
|
|
+2. 前端 `sleep-checkin.vue` 在 `create` 成功后又**冗余**调用一次 `POST /api/tasks/{id}/complete`(代码自述"后端按 taskId 置为 completed",但这一步其实已由 create 完成)。
|
|
|
+3. 用户存在**两条 `family_members` 行**("本人" 与 "本人所对应的成员",同 `user_id` 指向同一家长,即已知的"家长本人"重复记录)。
|
|
|
+ - 每日任务 2964 挂在成员 **M_task** 上(生成器写入的 `familyMemberId`)。
|
|
|
+ - 前端操作视角成员为 **M_front**(来自 URL `?memberId=` 或本地 `currentMemberId` 存储),与 M_task 不同。
|
|
|
+4. `create` 内部有"以任务归属为准修正成员"的逻辑(`HealthSleepController.create` 76–80 行),把打卡记录纠回 M_task 并完成 → 首页卡片消失(用户所见"确实取消掉了")。
|
|
|
+5. 但冗余 `/complete` 传入的是 **M_front ≠ M_task**,命中 `TaskService.completeTask` 第 587 行
|
|
|
+ `if (task == null || !Objects.equals(task.getFamilyMemberId(), memberId)) return null;`
|
|
|
+ → 返回 null → `TaskController.completeTask` 第 139 行返回 500。
|
|
|
+6. 任务列表页把 M_task 与 M_front 当作两个独立可切换身份呈现,二者 `familyMemberId` 不同,故"对应的任务列表不同"。
|
|
|
+
|
|
|
+> 即使不存在双身份(M_task == M_front),冗余 `/complete` 也会命中第 591 行(status 已是 `completed`)
|
|
|
+> 同样返回 null → 500。所以**双身份让 500 经行 587 触发,无双身份则经行 591 触发**,两种都成立。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 二、证据链(文件:行号,已逐行核实)
|
|
|
+
|
|
|
+### 2.1 报错出口
|
|
|
+- `controller/task/TaskController.java:109-140` `completeTask`:
|
|
|
+ - 118 行 `memberId = dto.getMemberId() != null ? dto.getMemberId() : currentMemberId;`(前端显式传 `self.memberId`)
|
|
|
+ - 137-139 行:`taskService.completeTask` 返回 null → `Result.error("任务不存在、无权完成或状态已变更")`。
|
|
|
+- `service/TaskService.java:585-672` `completeTask`:
|
|
|
+ - **587 行**:`if (task == null || !Objects.equals(task.getFamilyMemberId(), memberId)) return null;`(身份不匹配)
|
|
|
+ - **591 行**:`if ("completed".equals(task.getStatus()) || "pending_review".equals(task.getStatus())) return null;`(已完成)
|
|
|
+
|
|
|
+### 2.2 打卡"自动完成"链路(首页卡片消失的真正原因)
|
|
|
+- `controller/HealthSleepController.java:57-95` `create`:
|
|
|
+ - 76-80 行:若 `record.getMemberId() != linkedTask.getFamilyMemberId()`,则 `record.setMemberId(linkedTask.getFamilyMemberId());`(**以任务归属为准**,防串号)。
|
|
|
+ - 81-86 行:每日进度型任务调用
|
|
|
+ `taskService.updateCheckinTaskProgress(record.getTaskId(), fm.getUserId(), fm.getId(), 1);`
|
|
|
+ 其中 `fm.getId()` = 已修正为 **M_task**。
|
|
|
+- `service/TaskService.java:1784-1848` `updateCheckinTaskProgress`:
|
|
|
+ - 1795 行:校验 `task.getFamilyMemberId().equals(familyMemberId)`(M_task),不符则跳过。
|
|
|
+ - 1843-1847 行:progress≥target 时调用 `awardTaskCompletionRewards(...)`。
|
|
|
+- `service/TaskService.java:893-921` `awardTaskCompletionRewards`:
|
|
|
+ - **918 行** `task.setStatus("completed");` + **921 行** `taskMapper.updateById(task);`(**真落库**,`updateCheckinTaskProgress` 注释"status 写入 completed"属实)。
|
|
|
+
|
|
|
+### 2.3 前端冗余调用(bug 触发器)
|
|
|
+- `cfc-frontend/pages/growth/sleep-checkin.vue` `doCheckin`(413-465):
|
|
|
+ - 424 行 `POST /api/health/sleep/create`(带 `taskId`)。
|
|
|
+ - 439 行 toast「打卡成功,任务已完成」(前端已认定完成)。
|
|
|
+ - **448-452 行**:
|
|
|
+ ```js
|
|
|
+ // 联动任务(来自首页「我的任务」?taskId=):显式将对应每日任务标记完成,
|
|
|
+ // 后端按 taskId 置为 completed,返回首页后卡片才会消失
|
|
|
+ if (self.taskId) {
|
|
|
+ completeTaskApi(self.taskId, self.memberId || null, '').catch(function () {})
|
|
|
+ }
|
|
|
+ ```
|
|
|
+ 注释与实现错位:卡片消失其实由 `create` 的自动完成造成,这一步 `/complete` 是**多余的**,且传 `self.memberId`(M_front)。
|
|
|
+ - `self.memberId` 来源(293-305 行):`options.memberId || uni.getStorageSync('currentMemberId')`,即首页卡片下钻时带入的成员,可能与任务实际归属 M_task 不同。
|
|
|
+
|
|
|
+### 2.4 双身份数据根因("本人 / 本人所对应的成员")
|
|
|
+- `config/JwtInterceptor.java:174-190`:
|
|
|
+ - `currentMemberId` / `familyMemberId` 取自
|
|
|
+ `family_members WHERE user_id=userId AND family_id=familyId .last("LIMIT 1")`(177-182 行)。
|
|
|
+ - **多行同 (user_id, family_id) 时 `LIMIT 1` 非确定性取首行** → 不同请求可能解析到 M_task 或 M_front。
|
|
|
+- `service/EnergyService.java:121` 与 `:263` 注释明确:
|
|
|
+ > `family_members` 可能包含"家长本人"的重复记录(`user_id` 指向家长);
|
|
|
+ > 排除 `user_id` 指向家长本人的重复记录,并对同一孩子账号只保留一条。
|
|
|
+ → 说明该重复是**已知存量问题**,服务层已多处手工去重规避,但任务系统未统一处理。
|
|
|
+- `dto/FamilyMemberVO.java:88`:`isSelf`(是否当前登录用户本人)用于前端切换/移除权限判断;
|
|
|
+ 成员切换器把"本人"与"本人所对应的成员"列为两个可选项 → 任务列表按各自 `familyMemberId` 过滤,故不同。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 三、修复方案(按风险与收益排序)
|
|
|
+
|
|
|
+### 修复 A(生产热修 · 后端 · 必做 · 低危)
|
|
|
+让 `TaskService.completeTask` 对"已完成的本人任务"**幂等返回成功**,而非返回 null→500。
|
|
|
+- 在 587/591 行逻辑中:若 `task.familyMemberId == memberId`(或调用者为家庭管理员/家长且任务属本家庭)且 `status` 已是 `completed`/`pending_review`,返回一个**成功结果**
|
|
|
+ (复用既有 result 结构:`pointsEarned=0, newBalance=当前积分, needReview=false, alreadyCompleted=true`),不再吐 500。
|
|
|
+- 收益:不依赖前端发版即可立即消除全部用户的该 500(覆盖行 587 与行 591 两种触发)。
|
|
|
+- 风险:极低。幂等完成是 REST 标准做法;不会改变奖励(已在 `updateCheckinTaskProgress` 发过,`awardTaskCompletionRewards` 对已完成任务幂等跳过)。
|
|
|
+- 落点:`service/TaskService.java:585-593`;`controller/TaskController.java:118-139`(可选:将"已完成的本人任务"从 500 文案改为成功,或保留错误仅对本人任务放行)。
|
|
|
+
|
|
|
+### 修复 B(正本清源 · 前端 · 必做)
|
|
|
+删除 `sleep-checkin.vue` 448-452 行的冗余 `/complete` 调用。
|
|
|
+- 理由:`create` 已按任务归属自动完成(并自纠成员),`/complete` 纯属多余且会在双身份/已完成时失败。
|
|
|
+- 同步检查其它打卡页是否同样冗余调用 `/complete`:`pages/health/daily-checkin.vue`、`pages/growth/diet-checkin.vue`、`pages/growth/exercise-checkin.vue`、`pages/wealth-sub/checkin.vue`、`pages/mind-detail/emotion-checkin.vue`、`pages/body-detail/checkin.vue`。凡"打卡 create 已自动完成 + 又调 /complete"的一律移除或改为幂等。
|
|
|
+- 收益:消除重复完成路径,避免未来任何类似 500。
|
|
|
+
|
|
|
+### 修复 C(数据根治 · 双身份 · 中危 · 需授权)
|
|
|
+清理 `family_members` 中同 `(user_id, family_id)` 的重复"家长本人"记录,并加约束防止再生。
|
|
|
+- 诊断 SQL(先在从库/备份验证,禁止直连生产主库盲改):
|
|
|
+ ```sql
|
|
|
+ -- 1) 找出同一 (user_id, family_id) 的多余成员行
|
|
|
+ SELECT user_id, family_id, COUNT(*) c, GROUP_CONCAT(id) ids
|
|
|
+ FROM family_members
|
|
|
+ GROUP BY user_id, family_id
|
|
|
+ HAVING c > 1;
|
|
|
+
|
|
|
+ -- 2) 聚焦"家长本人"型(user_id 指向有 openid 的可登录用户,且存在多条)
|
|
|
+ SELECT fm.* FROM family_members fm
|
|
|
+ JOIN user u ON u.id = fm.user_id AND u.openid IS NOT NULL
|
|
|
+ WHERE (fm.user_id, fm.family_id) IN (
|
|
|
+ SELECT user_id, family_id FROM family_members
|
|
|
+ GROUP BY user_id, family_id HAVING COUNT(*) > 1
|
|
|
+ )
|
|
|
+ ORDER BY fm.user_id, fm.family_id, fm.id;
|
|
|
+ ```
|
|
|
+- 合并策略(需 DBA + 业务确认,严禁脚本自动删):
|
|
|
+ - 保留一条(优先保留 `status='active'`、积分/打卡数据更全、或被任务/报告外键引用最多的那行)作为该用户的唯一成员身份;
|
|
|
+ - 将其他重复行上的 `tasks.family_member_id`、`task_executions.member_id`、`health_*`、`points_log.child_id` 等外键**迁移**到保留行;
|
|
|
+ - 删除被合并的重复行;
|
|
|
+ - 加唯一索引 `UNIQUE (user_id, family_id)`(允许 `user_id IS NULL` 的 local 成员存在多条,用部分唯一索引或应用层兜底)。
|
|
|
+- 代码侧配合:
|
|
|
+ - `JwtInterceptor` 的 `LIMIT 1` 改为确定性取值(优先 `status='active'` 且 `userId` 匹配的"主成员"),避免解析漂移。
|
|
|
+ - 成员切换器(`FamilyMemberVO.isSelf`)对"本人"去重,不再把同一用户的两条成员当作两个身份。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 四、任务拆分(建议入池)
|
|
|
+
|
|
|
+| 编号 | 内容 | 类型 | 风险 | 是否需 prod 授权 |
|
|
|
+|------|------|------|------|------------------|
|
|
|
+| T24-A | `completeTask` 幂等:已完成本人任务返回成功 | 后端热修 | 低 | 是(仅部署到 prod 时需 `--allow-prod`)|
|
|
|
+| T24-B | 移除打卡页冗余 `/complete` 调用 + 全量排查 | 前端 | 低 | 否(发版流程)|
|
|
|
+| T24-C | `family_members` 重复行诊断 SQL 产出 | 数据 | 中 | 是(只读诊断可先跑)|
|
|
|
+| T24-D | 重复成员合并迁移 + 唯一约束 + 拦截器确定性解析 | 数据+后端 | 中高 | 是(写操作需授权)|
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 五、待用户拍板事项
|
|
|
+
|
|
|
+1. 是否同意**先上修复 A(后端幂等)作为生产热修**——可立即消除 500,不依赖前端发版。
|
|
|
+2. 是否授权对生产库**只读跑诊断 SQL(T24-C)**以确认受影响用户/家庭范围。
|
|
|
+3. 双身份合并(T24-D)涉及写库与外键迁移,**必须在备份 + DBA 复核后**单独授权执行,不在本次热修范围内。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 六、复盘教训(沉淀)
|
|
|
+
|
|
|
+- "打卡 create 自动完成" 与 "前端再调 /complete" 是**两条完成路径并存**的设计债:新增自动完成后未回收前端旧调用。
|
|
|
+- `family_members` 重复"家长本人"记录是跨模块存量问题,`EnergyService` 已多处手工规避,但任务/报告/打卡等模块未统一收敛;应在**数据层 + 拦截器解析层**一次性根治,而非各处打补丁。
|
|
|
+- 500 文案 `"任务不存在、无权完成或状态已变更"` 把"已完成"与"无权限"混为一谈,既不友好也掩盖真实原因;建议拆分文案(已完成→成功/已领取;无权限→无权)。
|