Просмотр исходного кода

fix(task-complete): 睡眠打卡完成接口幂等热修 + 打卡页冗余 /complete 移除(T24-A / T24-B)

生产 cfc.etotem.com.cn 睡眠打卡 500:POST /api/tasks/{id}/complete 返回
{"code":500,"message":"任务不存在、无权完成或状态已变更"},但首页任务卡片已消失。

根因(见 docs/superpowers/plans/2026-10-25-task-complete-500-bug.md):
1. 打卡 create 落库后已按任务归属自动完成(updateCheckinTaskProgress →
   awardTaskCompletionRewards,status 真落库为 completed);
2. 前端 create 成功后仍冗余调用一次 /complete,且传操作视角成员 M_front;
3. 双身份(同一 user_id 多条 family_members 行)下 M_front ≠ task.familyMemberId
   命中旧 587 行;无双身份时任务已 completed 命中旧 591 行;
4. 两种触发旧代码均 return null → TaskController:139 吐 500。

T24-A(后端幂等热修):
- TaskService.completeTask:task=null 仍返回 null(任务不存在);
  已是 completed / pending_review → 幂等返回成功;
  成员不匹配 → 不改数据不发奖,幂等返回成功并打 warn 供排查双身份。
- 复用既有 result 结构:pointsEarned=0、newBalance=当前积分、
  alreadyCompleted=true;needReview 对 pending_review 保留 true。
- 新增私有 helper buildIdempotentCompleteResult。

T24-B(前端去冗余 + 饮水后端对齐):
- 删除 sleep/exercise/diet/water 四个打卡页的冗余 POST /complete
  (含 water-index.vue 重复 url 键)及无用的 completeTaskApi import;
- HealthWaterController 与睡眠/运动/饮食对齐:先按任务归属自纠成员,
  再按 taskId 调 updateCheckinTaskProgress(首页卡片精确消失);
  无 taskId 时 updateProgress 显式传 familyMemberId,避免 LIMIT 1 漂移。

验证:mvn compile 通过;4 个改动 .vue ESM 语法校验通过;
audit-mp-antipatterns 0 error;audit-duplicate-api-calls 0 error;
cfc-web webpack build 通过(小程序打包按规范仍由 HBuilderX 完成)。

【只提交不执行】双身份诊断/合并 SQL 在后续提交,供 DBA 在只读从库/受控窗口执行。
Sisyphus 2 недель назад
Родитель
Сommit
3d0b3b17f8

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

@@ -70,10 +70,18 @@ public class HealthWaterController {
             try {
             try {
                 Task linkedTask = taskMapper.selectById(record.getTaskId());
                 Task linkedTask = taskMapper.selectById(record.getTaskId());
                 if (linkedTask != null) {
                 if (linkedTask != null) {
+                    // 以任务归属为准修正记录成员,避免打卡记录与任务串号(家长切孩子视角场景)
+                    // T24-B:与睡眠/运动/饮食打卡对齐,先自纠成员再按【具体 taskId】关闭
+                    if (linkedTask.getFamilyMemberId() != null &&
+                        record.getMemberId() != null &&
+                        !linkedTask.getFamilyMemberId().equals(record.getMemberId())) {
+                        record.setMemberId(linkedTask.getFamilyMemberId());
+                    }
                     if (linkedTask.getIsDailyProgress() != null && linkedTask.getIsDailyProgress() == 1) {
                     if (linkedTask.getIsDailyProgress() != null && linkedTask.getIsDailyProgress() == 1) {
+                        // 每日进度型任务:按【具体 taskId】累计进度并达标关闭(首页卡片精确消失)
                         FamilyMember fm = familyMemberMapper.selectById(record.getMemberId());
                         FamilyMember fm = familyMemberMapper.selectById(record.getMemberId());
                         if (fm != null && fm.getUserId() != null) {
                         if (fm != null && fm.getUserId() != null) {
-                            taskService.updateProgress(fm.getUserId(), "WATER", 1);
+                            taskService.updateCheckinTaskProgress(record.getTaskId(), fm.getUserId(), fm.getId(), 1);
                         }
                         }
                     } else {
                     } else {
                         taskService.completeTask(
                         taskService.completeTask(
@@ -91,7 +99,8 @@ public class HealthWaterController {
             try {
             try {
                 FamilyMember fm = familyMemberMapper.selectById(record.getMemberId());
                 FamilyMember fm = familyMemberMapper.selectById(record.getMemberId());
                 if (fm != null && fm.getUserId() != null) {
                 if (fm != null && fm.getUserId() != null) {
-                    taskService.updateProgress(fm.getUserId(), "WATER", 1);
+                    // 显式传 familyMemberId,避免 3 参 updateProgress 按 user_id LIMIT 1 解析漂移
+                    taskService.updateProgress(fm.getUserId(), fm.getId(), "WATER", 1);
                 }
                 }
             } catch (Exception e) {
             } catch (Exception e) {
                 log.warn("喝水打卡进度回写失败: {}", e.getMessage());
                 log.warn("喝水打卡进度回写失败: {}", e.getMessage());

+ 45 - 4
cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java

@@ -581,12 +581,33 @@ public List<Task> getTodayTasks(Long memberId, String dimensionCode) {
     @Transactional
     @Transactional
     public Map<String, Object> completeTask(Long taskId, Long memberId, String photoUrl, String content, String contentType) {
     public Map<String, Object> completeTask(Long taskId, Long memberId, String photoUrl, String content, String contentType) {
         Task task = taskMapper.selectById(taskId);
         Task task = taskMapper.selectById(taskId);
-        if (task == null || !Objects.equals(task.getFamilyMemberId(), memberId)) {
-            return null;
+        if (task == null) {
+            return null; // 任务不存在
+        }
+
+        // ===== T24-A 幂等完成(生产 500 热修) =====
+        // 背景:睡眠/运动/饮食/饮水打卡属"每日进度型任务",POST /api/health/*/create 落库后会
+        //      按【任务归属】自动完成(updateCheckinTaskProgress → awardTaskCompletionRewards,
+        //      status 已真落库为 completed,见 TaskService:awardTaskCompletionRewards)。
+        //      前端历史版本在 create 成功后还会冗余调用一次 POST /api/tasks/{id}/complete,
+        //      且传入的是"操作视角成员" M_front。
+        // 双身份(family_members 同 (user_id,family_id) 存在多条"家长本人"行)下
+        //      M_front ≠ task.familyMemberId → 命中下方旧 587 行;
+        // 无双身份时任务已是 completed → 命中旧 591 行。
+        // 两种触发旧代码都 return null → TaskController:139 吐 500
+        // ("任务不存在、无权完成或状态已变更"),但用户其实已打卡成功、首页卡片已消失。
+        // 处理:这两种情况一律幂等返回成功(复用既有 result 结构),不改数据、不重复发奖。
+        if ("completed".equals(task.getStatus()) || "pending_review".equals(task.getStatus())) {
+            // 已完成 / 已提交待审核:幂等成功(pointsEarned=0,不重复发奖)
+            return buildIdempotentCompleteResult(task, memberId);
         }
         }
 
 
-        if ("completed".equals(task.getStatus()) || "pending_review".equals(task.getStatus())) {
-            return null; // 任务已完成或已提交待审核,不能重复提交
+        if (!Objects.equals(task.getFamilyMemberId(), memberId)) {
+            // 成员不匹配:可能是双身份下的解析漂移,也可能是真正的越权。
+            // 此处不修改任何数据、不发奖,仅返回幂等成功,避免打卡回写竞态把用户卡在 500。
+            log.warn("completeTask 成员不匹配,按幂等成功返回(疑似双身份解析漂移): taskId={}, taskMemberId={}, reqMemberId={}",
+                    taskId, task.getFamilyMemberId(), memberId);
+            return buildIdempotentCompleteResult(task, memberId);
         }
         }
 
 
         // TASK-SYS-248: 前置任务校验——前置未完成则拒绝
         // TASK-SYS-248: 前置任务校验——前置未完成则拒绝
@@ -668,6 +689,26 @@ public List<Task> getTodayTasks(Long memberId, String dimensionCode) {
         return result;
         return result;
     }
     }
 
 
+    /**
+     * T24-A:幂等完成结果(复用 completeTask 既定 result 结构)。
+     * <p>用于"任务已完成/已提交待审核"或"成员不匹配(双身份解析漂移)"两种触发,
+     * 让重复调用不再返回 null → TaskController:139 的 500;不修改任务、不发奖。</p>
+     *
+     * @param task     已存在的任务
+     * @param memberId 请求方传入的成员(为空时退化为任务归属成员)
+     */
+    private Map<String, Object> buildIdempotentCompleteResult(Task task, Long memberId) {
+        Long balanceMemberId = memberId != null ? memberId : task.getFamilyMemberId();
+        FamilyMember child = balanceMemberId != null ? familyMemberMapper.selectById(balanceMemberId) : null;
+        Map<String, Object> result = new HashMap<>();
+        result.put("pointsEarned", 0);
+        result.put("newBalance", child != null && child.getTotalPoints() != null ? child.getTotalPoints() : 0);
+        // 已完成 → false(与既有成功结构一致);已提交待审核 → true(保持前端"待审核"提示)
+        result.put("needReview", "pending_review".equals(task.getStatus()));
+        result.put("alreadyCompleted", true);
+        return result;
+    }
+
     @Transactional
     @Transactional
     public boolean reviewTask(Long taskId, TaskReviewDTO dto) {
     public boolean reviewTask(Long taskId, TaskReviewDTO dto) {
         Task task = taskMapper.selectById(taskId);
         Task task = taskMapper.selectById(taskId);

+ 3 - 6
cfc-frontend/pages/growth/diet-checkin.vue

@@ -162,7 +162,7 @@
 <script>
 <script>
 import config from '../../config'
 import config from '../../config'
 import { parseDate } from '../../utils/format.js'
 import { parseDate } from '../../utils/format.js'
-import { sttTranscribe, recognizeDietFood, getAllQuotaStatus, completeTask as completeTaskApi } from '../../utils/api.js'
+import { sttTranscribe, recognizeDietFood, getAllQuotaStatus } from '../../utils/api.js'
 import shareMixin from '../../components/share-mixin.js'
 import shareMixin from '../../components/share-mixin.js'
 import { pickImage } from '../../utils/image-picker.js'
 import { pickImage } from '../../utils/image-picker.js'
 import TagChip from '../../components/TagChip.vue'
 import TagChip from '../../components/TagChip.vue'
@@ -489,11 +489,8 @@ success: function(res) {
               self.submitting = false
               self.submitting = false
               if (res.data && res.data.code === 200) {
               if (res.data && res.data.code === 200) {
                 uni.showToast({ title: self.taskId ? '打卡成功,任务已完成' : '打卡成功', icon: 'success' })
                 uni.showToast({ title: self.taskId ? '打卡成功,任务已完成' : '打卡成功', icon: 'success' })
-                // 联动任务(来自首页「我的任务」?taskId=):显式将对应每日任务标记完成,
-                // 后端按 taskId 置为 completed,返回首页后卡片才会消失
-                if (self.taskId) {
-                  completeTaskApi(self.taskId, self.memberId || null, '').catch(function () {})
-                }
+                // 打卡成功:任务已完成(后端 create 已按任务归属自动完成并自纠成员,
+                // 无需再调 /api/tasks/{id}/complete——历史冗余调用在双身份/已完成时会触发 500)
                 setTimeout(function() {
                 setTimeout(function() {
                   uni.navigateBack()
                   uni.navigateBack()
                 }, 800)
                 }, 800)

+ 3 - 6
cfc-frontend/pages/growth/exercise-checkin.vue

@@ -151,7 +151,7 @@
 <script>
 <script>
 import config from '../../config'
 import config from '../../config'
 import { parseDate } from '../../utils/format.js'
 import { parseDate } from '../../utils/format.js'
-import { sttTranscribe, completeTask as completeTaskApi } from '../../utils/api.js'
+import { sttTranscribe } from '../../utils/api.js'
 import { pickImage } from '../../utils/image-picker.js'
 import { pickImage } from '../../utils/image-picker.js'
 import TagChip from '../../components/TagChip.vue'
 import TagChip from '../../components/TagChip.vue'
 import fontScaleMixin from '@/mixins/font-scale.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
@@ -474,11 +474,8 @@ export default {
             if (res.data && res.data.code === 200) {
             if (res.data && res.data.code === 200) {
               uni.showToast({ title: self.taskId ? '打卡成功,任务已完成' : '打卡成功', icon: 'success' })
               uni.showToast({ title: self.taskId ? '打卡成功,任务已完成' : '打卡成功', icon: 'success' })
               self.syncedStepText = ''
               self.syncedStepText = ''
-              // 联动任务(来自首页「我的任务」?taskId=):显式将对应每日任务标记完成,
-              // 后端按 taskId 置为 completed,返回首页后卡片才会消失
-              if (self.taskId) {
-                completeTaskApi(self.taskId, self.memberId || null, '').catch(function () {})
-              }
+              // 打卡成功:任务已完成(后端 create 已按任务归属自动完成并自纠成员,
+              // 无需再调 /api/tasks/{id}/complete——历史冗余调用在双身份/已完成时会触发 500)
               setTimeout(function() {
               setTimeout(function() {
                 uni.navigateBack()
                 uni.navigateBack()
               }, 800)
               }, 800)

+ 2 - 6
cfc-frontend/pages/growth/sleep-checkin.vue

@@ -160,7 +160,6 @@
 import config from '../../config'
 import config from '../../config'
 import { formatDate as formatDateUtil } from '../../utils/format.js'
 import { formatDate as formatDateUtil } from '../../utils/format.js'
 import { BrandColors } from '../../utils/brand-colors.js'
 import { BrandColors } from '../../utils/brand-colors.js'
-import { completeTask as completeTaskApi } from '../../utils/api.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 
 
 export default {
 export default {
@@ -445,11 +444,8 @@ export default {
               list.unshift(created)
               list.unshift(created)
               self.history = list.slice(0, 5)
               self.history = list.slice(0, 5)
             }
             }
-            // 联动任务(来自首页「我的任务」?taskId=):显式将对应每日任务标记完成,
-            // 后端按 taskId 置为 completed,返回首页后卡片才会消失
-            if (self.taskId) {
-              completeTaskApi(self.taskId, self.memberId || null, '').catch(function () {})
-            }
+            // 打卡成功:任务已完成(后端 create 已按任务归属自动完成并自纠成员,
+            // 无需再调 /api/tasks/{id}/complete——历史冗余调用在双身份/已完成时会触发 500)
             setTimeout(function () {
             setTimeout(function () {
               uni.navigateBack()
               uni.navigateBack()
             }, 800)
             }, 800)

+ 2 - 16
cfc-frontend/pages/health/water-index.vue

@@ -195,22 +195,8 @@ export default {
             self.form.amountMl = 200
             self.form.amountMl = 200
             self.form.waterType = 'plain'
             self.form.waterType = 'plain'
             self.loadHistory()
             self.loadHistory()
-            // 有任务关联:打卡即完成任务
-            if (self.taskId && self.memberId) {
-              uni.request({
-                url: config.API_BASE_URL + '/api/tasks/' + self.taskId + '/complete',
-                url: config.API_BASE_URL + '/api/tasks/' + self.taskId + '/complete',
-                method: 'POST',
-                data: { memberId: self.memberId, photoUrl: '', content: '饮水打卡完成', contentType: 'text' },
-                header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
-                success: function(cr) {
-                  if (cr.data && cr.data.code === 200) {
-                    uni.showToast({ title: '任务已完成', icon: 'success' })
-                  }
-                },
-                fail: function() {}
-              })
-            }
+            // T24-B:任务已完成由后端 create 按【具体 taskId】自动关闭(updateCheckinTaskProgress),
+            // 此处不再冗余调用 /api/tasks/{id}/complete——该调用在双身份/已完成时会触发 500
           } else {
           } else {
             var msg = (res.data && res.data.message) || '打卡失败'
             var msg = (res.data && res.data.message) || '打卡失败'
             uni.showToast({ title: msg, icon: 'none' })
             uni.showToast({ title: msg, icon: 'none' })

+ 151 - 0
docs/superpowers/plans/2026-10-25-task-complete-500-bug.md

@@ -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 文案 `"任务不存在、无权完成或状态已变更"` 把"已完成"与"无权限"混为一谈,既不友好也掩盖真实原因;建议拆分文案(已完成→成功/已领取;无权限→无权)。