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

fix(task-complete): 双身份确定性解析 + 诊断/合并 SQL 脚本产出(T24-C,只提交不执行)

双身份根因(同一 user_id 多条 family_members"家长本人"行):
- JwtInterceptor 旧 .last("LIMIT 1") 无排序,currentMemberId 请求间漂移;
- FamilyMemberService.listMembers 去重比较器用 .max() + 反向比较器
  (active 返回 -1、updated_at 用 b.compareTo(a)),实际选中"非 active /
  最早 updated_at"行,与注释意图相反,isSelf 标错行 → 前端呈现两个身份。

代码改动:
- JwtInterceptor:177-182:改为确定性 ORDER BY
  status='active' → is_admin=1 → id 最小;
- FamilyMemberService.listMembers:修复反向比较器,新增 statusRank 静态
  助手,选主成员口径与 JwtInterceptor 一致(非 recycled → is_admin → id 最小);
- schema.sql:tasks 表补齐 family_member_id 列(与迁移 2134-2141 同步,
  修复既有 schema.sql 缺口)。

脚本产出(均【只提交,不执行】,供 DBA 在只读从库演练、受控窗口执行):
- 2026-10-25-dual-identity-diagnostic.sql:只读诊断(重复组/家长本人型/
  外键分布/疑似悬空),零风险,备份库可直接跑;
- 2026-10-25-dual-identity-merge.sql:高危合并迁移
  · canonical 规则:ref_total 最多 → status='active' → id 最小;
  · 备份表 family_members_bak_20261025 + 映射表 fm_merge_map(回滚依赖);
  · 92 个 (表,列) 引用迁移(information_schema 守卫,schema 漂移自动跳过并告警);
  · 唯一键冲突预检 + 冲突处理(画像/偏好/徽章/能量/关系边等以 canonical 为准,
    cf_balance 专项求和并入 canonical,不丢 CF 值);
  · JSON 数组用 JSON_TABLE 重组(不破坏格式),VARCHAR 逗号串按边界 REPLACE;
  · tasks.child_id 仅对 executor_type 非 parent 改写(防误改 users.id);
  · DELETE 重复行 + UNIQUE(user_id, family_id)(NULL 不违反唯一,可容本地成员);
  · 执行后校验 + 逐组/全量回滚说明。
- PROJECT-OVERVIEW.md:登记两份新计划文档与实施状态。

验证:mvn compile 通过(exit 0);改动 .vue 均未变(本次无前端改动)。
agent 未连接任何生产库、未执行任何 DB 写操作。
Sisyphus 2 недель назад
Родитель
Сommit
fbec199c11

+ 6 - 1
cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java

@@ -174,11 +174,16 @@ private static final String[] PUBLIC_PATHS = {
             Long familyId = (Long) request.getAttribute("familyId");
             Long familyId = (Long) request.getAttribute("familyId");
             if (familyId != null) {
             if (familyId != null) {
                 try {
                 try {
+                    // T24-C:同 (user_id, family_id) 可能存在多条"家长本人"重复记录,
+                    // 旧实现 .last("LIMIT 1") 无排序、取值不确定,导致 currentMemberId 在
+                    // 双身份下请求间漂移(睡眠打卡 500 根因之一)。改为确定性取值:
+                    // 非 recycled(优先 status='active')→ is_admin=1(主成员)→ id 最小。
                     FamilyMember member = familyMemberMapper.selectOne(
                     FamilyMember member = familyMemberMapper.selectOne(
                         new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<FamilyMember>()
                         new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<FamilyMember>()
                             .eq(FamilyMember::getUserId, userId)
                             .eq(FamilyMember::getUserId, userId)
                             .eq(FamilyMember::getFamilyId, familyId)
                             .eq(FamilyMember::getFamilyId, familyId)
-                            .last("LIMIT 1")
+                            .last("ORDER BY (CASE WHEN status = 'active' THEN 0 WHEN status = 'recycled' THEN 2 ELSE 1 END) ASC, "
+                                + "(is_admin = 1) DESC, id ASC LIMIT 1")
                     );
                     );
                     if (member != null) {
                     if (member != null) {
                         request.setAttribute("currentMemberId", member.getId());
                         request.setAttribute("currentMemberId", member.getId());

+ 26 - 10
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java

@@ -263,17 +263,23 @@ public class FamilyMemberService {
                 if (group.size() == 1) {
                 if (group.size() == 1) {
                     deduped.add(group.get(0));
                     deduped.add(group.get(0));
                 } else {
                 } else {
-                    // status=active 优先,其次 updated_at 最新
+                    // T24-C:确定性选"主成员",与 JwtInterceptor:177-182 的解析口径一致:
+                    //   status='active' 最优先 → 其余状态其次 → recycled 最后;
+                    //   同级内 is_admin=1(主成员)优先;再以 id 最小兜底。
+                    // 旧实现用 .max() + 反向比较器(active 返回 -1、updated_at 用 b.compareTo(a)),
+                    // 实际选中"非 active / 最早 updated_at"行,与注释意图相反,导致 isSelf
+                    // 标在错误的重复行上(前端呈现"本人 + 本人所对应的成员"两个身份)。
                     FamilyMember winner = group.stream()
                     FamilyMember winner = group.stream()
-                            .max((a, b) -> {
-                                boolean aActive = "active".equals(a.getStatus());
-                                boolean bActive = "active".equals(b.getStatus());
-                                if (aActive && !bActive) return -1;
-                                if (!aActive && bActive) return 1;
-                                if (a.getUpdatedAt() != null && b.getUpdatedAt() != null) {
-                                    return b.getUpdatedAt().compareTo(a.getUpdatedAt());
-                                }
-                                return 0;
+                            .min((a, b) -> {
+                                int aRank = statusRank(a.getStatus());
+                                int bRank = statusRank(b.getStatus());
+                                int statusCmp = Integer.compare(aRank, bRank);
+                                if (statusCmp != 0) return statusCmp;
+                                int aAdmin = a.getIsAdmin() != null ? a.getIsAdmin() : 0;
+                                int bAdmin = b.getIsAdmin() != null ? b.getIsAdmin() : 0;
+                                int adminCmp = Integer.compare(bAdmin, aAdmin);
+                                if (adminCmp != 0) return adminCmp;
+                                return Long.compare(a.getId(), b.getId());
                             })
                             })
                             .orElse(group.get(0));
                             .orElse(group.get(0));
                     deduped.add(winner);
                     deduped.add(winner);
@@ -1086,6 +1092,16 @@ vo.setIsSelf(viewerMember != null && viewerMember.getId().equals(fm.getId()));
 
 
     // ===== 内部方法 =====
     // ===== 内部方法 =====
 
 
+    /**
+     * T24-C:成员状态优先级排名(供重复行选"主成员"使用,与 JwtInterceptor 解析口径一致):
+     * active=0 最优先,其余状态(如 invited/pending)=1,recycled=2 最次。
+     */
+    private static int statusRank(String status) {
+        if ("active".equals(status)) return 0;
+        if ("recycled".equals(status)) return 2;
+        return 1;
+    }
+
     /**
     /**
      * 解析成员显示名:优先关联 users 表的 nickname,其次 family_members.nickname。
      * 解析成员显示名:优先关联 users 表的 nickname,其次 family_members.nickname。
      * 同一人可能同时存在于两表(创建者/受邀登录成员),getVisibleMembers 家长段显示的正是 users.nickname,
      * 同一人可能同时存在于两表(创建者/受邀登录成员),getVisibleMembers 家长段显示的正是 users.nickname,

+ 1 - 0
cfc-backend/src/main/resources/schema.sql

@@ -109,6 +109,7 @@ CREATE TABLE IF NOT EXISTS tasks (
     child_id BIGINT COMMENT '执行者ID(child任务时必填,parent任务时可空)',
     child_id BIGINT COMMENT '执行者ID(child任务时必填,parent任务时可空)',
     executor_type VARCHAR(20) COMMENT '执行者类型: child=非登录用户/parent=可登录家长/member=其他成员',
     executor_type VARCHAR(20) COMMENT '执行者类型: child=非登录用户/parent=可登录家长/member=其他成员',
     executor_id BIGINT COMMENT '执行者ID',
     executor_id BIGINT COMMENT '执行者ID',
+    family_member_id BIGINT COMMENT '家庭成员ID(关联 family_members.id)',
     title VARCHAR(200) NOT NULL,
     title VARCHAR(200) NOT NULL,
     description TEXT,
     description TEXT,
     points TINYINT DEFAULT 2 COMMENT '任务积分1-10',
     points TINYINT DEFAULT 2 COMMENT '任务积分1-10',

+ 3 - 0
docs/superpowers/PROJECT-OVERVIEW.md

@@ -407,6 +407,9 @@
 
 
 | 文件 | 状态 | 说明 |
 | 文件 | 状态 | 说明 |
 |------|:----:|------|
 |------|:----:|------|
+| `2026-10-25-task-complete-500-bug.md` | 🔵 实施中(T24-A 后端 completeTask 幂等热修 + T24-B 前端移除打卡冗余 /complete + T24-C 双身份诊断/合并 SQL 产出;A/B 代码已提交 feature/task-complete-500;C 脚本待 DBA 在受控环境执行) | 睡眠打卡完成接口 500 与双身份——根因诊断与修复设计 |
+| `2026-10-25-dual-identity-merge.sql` | 🔵 待 DBA 执行(高危写脚本:备份表 + canonical 选择(ref_total→active→id)+ 92 列外键迁移 + 唯一键冲突处理 + UNIQUE(user_id,family_id);含逐组回滚说明;仅提交不执行) | 双身份数据合并迁移脚本 |
+| `2026-10-25-dual-identity-diagnostic.sql` | 🟢 只读零风险(备份/从库可直接执行;与 T24-C 配套) | 双身份只读诊断 SQL |
 | `2026-10-10-address-contact-backfill-realname.md` | 🟡 待执行(4 任务:RealNameBackfillService 组件 TDD + UserAddressService 接线 + ConsigneeService 接线 + 整体验证;12 单测/纯 Mockito) | 地址联系人回填真实姓名实现计划 |
 | `2026-10-10-address-contact-backfill-realname.md` | 🟡 待执行(4 任务:RealNameBackfillService 组件 TDD + UserAddressService 接线 + ConsigneeService 接线 + 整体验证;12 单测/纯 Mockito) | 地址联系人回填真实姓名实现计划 |
 | `2026-10-20-limited-time-special-price.md` | ✅ 已实施(7 任务全栈完成:T1 迁移389/390+schema+实体Mapper、T2 SpecialPriceService+(computeRunStatus 存储 draft/active/disabled→运行态 7 值推导)+9 单测、T3 双控制器+白名单 3 处(JwtInterceptor PUBLIC_PATHS/WebConfig 两 excludes 均邻 /api/product/detail)+11 单测、T4 特价下单 createSpecialDealOrder+三处回滚+reviewUpdate 防护+11 单测、T5 小程序产品页/checkout 特价透传(node --check×3 + audit 0 error)、T6 cfc-web SpecialPriceManage(vue-cli-service build SUCCESS)、T7 API_REFERENCE 4.53/4.54 + PROJECT-OVERVIEW 更新;分支 cfclub 6 commits,未推送待用户批准) | 限时特价(扫码闪购)实现计划(设计稿:2026-10-20-limited-time-special-price-design.md) |
 | `2026-10-20-limited-time-special-price.md` | ✅ 已实施(7 任务全栈完成:T1 迁移389/390+schema+实体Mapper、T2 SpecialPriceService+(computeRunStatus 存储 draft/active/disabled→运行态 7 值推导)+9 单测、T3 双控制器+白名单 3 处(JwtInterceptor PUBLIC_PATHS/WebConfig 两 excludes 均邻 /api/product/detail)+11 单测、T4 特价下单 createSpecialDealOrder+三处回滚+reviewUpdate 防护+11 单测、T5 小程序产品页/checkout 特价透传(node --check×3 + audit 0 error)、T6 cfc-web SpecialPriceManage(vue-cli-service build SUCCESS)、T7 API_REFERENCE 4.53/4.54 + PROJECT-OVERVIEW 更新;分支 cfclub 6 commits,未推送待用户批准) | 限时特价(扫码闪购)实现计划(设计稿:2026-10-20-limited-time-special-price-design.md) |
 | `2026-10-09-indicator-change-curve-backend.md` | 🟢 已实施(11 任务全部完成:迁移 356-362 两新表 indicator_mapping_rules/indicator_correlations + indicator_definitions/indicator_values 升级、实体/Mapper、ReportDateExtractor(keyset 游标防挂死)、SpearmanUtil、IndicatorMappingService、NormalizeIndicatorPipeline 单一写者(三钩子)、IndicatorTrendService、IndicatorCorrelationService、4 视图 API + 2 admin Controller 5 端点、每日 03:00 IndicatorCalibrationJob、API_REFERENCE 4.48 同步;7 个新增单测类共 24 用例全绿,compile/test-compile EXIT=0) | 指标变化曲线·速查·关联 — 后端实施计划(设计稿:2026-10-09-indicator-change-curve-design.md) |
 | `2026-10-09-indicator-change-curve-backend.md` | 🟢 已实施(11 任务全部完成:迁移 356-362 两新表 indicator_mapping_rules/indicator_correlations + indicator_definitions/indicator_values 升级、实体/Mapper、ReportDateExtractor(keyset 游标防挂死)、SpearmanUtil、IndicatorMappingService、NormalizeIndicatorPipeline 单一写者(三钩子)、IndicatorTrendService、IndicatorCorrelationService、4 视图 API + 2 admin Controller 5 端点、每日 03:00 IndicatorCalibrationJob、API_REFERENCE 4.48 同步;7 个新增单测类共 24 用例全绿,compile/test-compile EXIT=0) | 指标变化曲线·速查·关联 — 后端实施计划(设计稿:2026-10-09-indicator-change-curve-design.md) |

+ 79 - 0
docs/superpowers/plans/2026-10-25-dual-identity-diagnostic.sql

@@ -0,0 +1,79 @@
+-- ============================================================
+-- 双身份诊断(只读,零风险):family_members 重复行 + 外键引用分布
+-- 运行位置:prod 只读从库 / 备份库(严禁在主库盲改,本文件不含任何写操作)
+-- 日期:2026-10-25
+-- 关联:docs/superpowers/plans/2026-10-25-task-complete-500-bug.md (T24-C)
+--       docs/superpowers/plans/2026-10-25-unified-member-selector.md (T25)
+-- ============================================================
+
+-- 1) 同一 (user_id, family_id) 的多余成员行(核心重复)
+SELECT user_id, family_id, COUNT(*) AS dup_cnt, GROUP_CONCAT(id ORDER BY id) AS member_ids
+FROM family_members
+GROUP BY user_id, family_id
+HAVING COUNT(*) > 1
+ORDER BY dup_cnt DESC, family_id;
+
+-- 2) 聚焦"家长本人"型:user_id 指向有 openid 的可登录用户,且存在多条
+SELECT fm.id, fm.family_id, fm.user_id, fm.nickname, fm.status, fm.is_admin,
+       fm.total_points, fm.streak_days, fm.created_at,
+       (u.openid IS NOT NULL) AS has_openid
+FROM family_members fm
+JOIN users 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;
+
+-- 3a) 重复行在 tasks.family_member_id 的引用分布
+SELECT fm.user_id, fm.family_id, fm.id AS member_id,
+       (SELECT COUNT(*) FROM tasks t WHERE t.family_member_id = fm.id) AS task_cnt
+FROM family_members fm
+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;
+
+-- 3b) 重复行在 task_executions.member_id 的引用分布
+SELECT fm.id AS member_id,
+       (SELECT COUNT(*) FROM task_executions te WHERE te.member_id = fm.id) AS exec_cnt
+FROM family_members fm
+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;
+
+-- 3c) 重复行在 points_log.child_id 的引用分布
+SELECT fm.id AS member_id,
+       (SELECT COUNT(*) FROM points_log pl WHERE pl.child_id = fm.id) AS points_cnt
+FROM family_members fm
+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;
+
+-- 4) 重复行引用总量(决定保留哪条:引用最多者优先保留)
+SELECT fm.user_id, fm.family_id, fm.id AS member_id,
+       (SELECT COUNT(*) FROM tasks t WHERE t.family_member_id = fm.id)
+     + (SELECT COUNT(*) FROM task_executions te WHERE te.member_id = fm.id)
+     + (SELECT COUNT(*) FROM points_log pl WHERE pl.child_id = fm.id) AS ref_total
+FROM family_members fm
+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, ref_total DESC;
+
+-- 5) 受影响家庭 / 用户总数(汇报用)
+SELECT COUNT(DISTINCT family_id) AS affected_families,
+       COUNT(DISTINCT user_id)    AS affected_users,
+       SUM(dup_cnt) - COUNT(*)    AS redundant_rows
+FROM (
+    SELECT user_id, family_id, COUNT(*) AS dup_cnt
+    FROM family_members
+    GROUP BY user_id, family_id
+    HAVING COUNT(*) > 1
+) x;

+ 728 - 0
docs/superpowers/plans/2026-10-25-dual-identity-merge.sql

@@ -0,0 +1,728 @@
+-- ============================================================================
+-- T24-C / T24-D · 双身份(family_members 重复"家长本人"行)数据合并迁移脚本
+-- ============================================================================
+-- 关联文档:docs/superpowers/plans/2026-10-25-task-complete-500-bug.md
+--           docs/superpowers/plans/2026-10-25-dual-identity-diagnostic.sql
+--           docs/superpowers/plans/2026-10-25-unified-member-selector.md
+-- 日期:2026-10-25
+--
+-- ⚠️⚠️ 高危写操作 · manual_only · 仅供 DBA 在受控环境执行 ⚠️⚠️
+--   · 本脚本由 AI 产出并已随代码提交,**尚未执行**;AI agent 不连接任何生产库。
+--   · 严禁直接在生产主库盲跑。正确顺序:
+--       1) 先跑只读诊断脚本 2026-10-25-dual-identity-diagnostic.sql(备份/只读从库)
+--       2) 在【备份库/从库】完整演练本脚本,核对 §8 校验结果
+--       3) 生产执行前:整库备份 + 确认维护窗口 + 保留回滚路径
+--   · 执行人必须在 §1 预检结果上签字确认"受影响范围可接受"后才可进入 §4。
+--
+-- 【canonical 保留规则】(与诊断 §4 的 ref_total 一致)
+--   对每组重复 (user_id, family_id):
+--     1) ref_total 最多者(tasks + task_executions + points_log 引用总数)
+--     2) 并列时 status='active' 优先
+--     3) 再并列时 id 最小
+--   其余重复行为 dup_id,其在业务表中的引用全部改写为 canonical_id 后删除。
+--
+-- 【UNIQUE 约束】
+--   MySQL 唯一索引不约束 NULL,故 UNIQUE(user_id, family_id) 允许多条 user_id IS NULL
+--   的 local(不能登录)成员共存,符合业务口径;无需改部分唯一索引。
+--
+-- 【执行环境】MySQL 8.0+(用到 ROW_NUMBER 窗口函数)
+-- ============================================================================
+
+
+-- ============================================================================
+-- §0 · 会话设置与辅助表(DDL,隐式提交,先于事务执行)
+-- ============================================================================
+SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION';
+SET SESSION foreign_key_checks = 1;
+
+-- 0.1 备份表(只增不改,保留被删行的完整快照,用于回滚)
+CREATE TABLE IF NOT EXISTS family_members_bak_20261025 AS SELECT * FROM family_members;
+
+-- 0.2 排名表:每个重复成员的 ref_total 与组内序号(rn=1 即 canonical)
+CREATE TABLE IF NOT EXISTS fm_dup_ranked_20261025 (
+    member_id  BIGINT       NOT NULL PRIMARY KEY,
+    user_id    BIGINT       NOT NULL,
+    family_id  BIGINT       NOT NULL,
+    status     VARCHAR(16)  DEFAULT NULL,
+    ref_total  INT          NOT NULL DEFAULT 0,
+    rn         INT          NOT NULL,
+    KEY idx_group (user_id, family_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='T24-C 重复成员组内排名(rn=1 为 canonical)';
+
+-- 0.3 引用计数表
+CREATE TABLE IF NOT EXISTS fm_ref_count_20261025 (
+    member_id  BIGINT NOT NULL PRIMARY KEY,
+    ref_total  INT    NOT NULL DEFAULT 0
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='T24-C 重复成员外键引用计数';
+
+-- 0.4 映射表:dup_id → canonical_id(回滚时同样依赖此表,勿删除)
+CREATE TABLE IF NOT EXISTS fm_merge_map (
+    user_id       BIGINT NOT NULL,
+    family_id     BIGINT NOT NULL,
+    dup_id        BIGINT NOT NULL,
+    canonical_id  BIGINT NOT NULL,
+    PRIMARY KEY (dup_id),
+    KEY idx_group (user_id, family_id),
+    KEY idx_canonical (canonical_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='T24-C 合并映射:dup_id → canonical_id';
+
+TRUNCATE TABLE fm_dup_ranked_20261025;
+TRUNCATE TABLE fm_ref_count_20261025;
+TRUNCATE TABLE fm_merge_map;
+
+
+-- ============================================================================
+-- §1 · 只读预检(执行前必看,签字确认后再往下走)
+-- ============================================================================
+-- 1.1 重复组概览
+SELECT user_id, family_id, COUNT(*) AS dup_cnt, GROUP_CONCAT(id ORDER BY id) AS member_ids
+FROM family_members
+WHERE user_id IS NOT NULL
+GROUP BY user_id, family_id
+HAVING COUNT(*) > 1
+ORDER BY dup_cnt DESC, family_id;
+
+-- 1.2 影响面总量(冗余行数 = 将被删除的行数)
+SELECT COUNT(DISTINCT family_id) AS affected_families,
+       COUNT(DISTINCT user_id)    AS affected_users,
+       SUM(dup_cnt) - COUNT(*)    AS redundant_rows
+FROM (
+    SELECT user_id, family_id, COUNT(*) AS dup_cnt
+    FROM family_members
+    WHERE user_id IS NOT NULL
+    GROUP BY user_id, family_id
+    HAVING COUNT(*) > 1
+) x;
+
+-- 1.3 唯一索引前置检查:确认目标唯一键尚不存在(否则本脚本 §7 跳过)
+SELECT COUNT(*) AS uk_already_exists
+FROM information_schema.STATISTICS
+WHERE TABLE_SCHEMA = DATABASE()
+  AND TABLE_NAME   = 'family_members'
+  AND INDEX_NAME   = 'uk_family_members_user_family';
+
+-- 1.4 ★ 签字栏(人工填写)★
+--    受影响家庭数:______  受影响用户数:______  冗余行数:______
+--    确认人:____________  确认时间:____________  备份单号:____________
+
+
+-- ============================================================================
+-- §2 · 构建排名与映射(DDL 已完成,以下为 DML;随 §3-§5 同一事务)
+-- ============================================================================
+-- 2.1 统计每个成员被业务表引用的次数(ref_total)
+INSERT INTO fm_ref_count_20261025 (member_id, ref_total)
+SELECT fm.id,
+       (SELECT COUNT(*) FROM tasks t           WHERE t.family_member_id = fm.id)
+     + (SELECT COUNT(*) FROM task_executions te WHERE te.member_id      = fm.id)
+     + (SELECT COUNT(*) FROM points_log pl      WHERE pl.child_id       = fm.id)
+FROM family_members fm
+WHERE fm.user_id IS NOT NULL
+  AND (fm.user_id, fm.family_id) IN (
+      SELECT user_id, family_id FROM family_members
+      WHERE user_id IS NOT NULL
+      GROUP BY user_id, family_id
+      HAVING COUNT(*) > 1
+  );
+
+-- 2.2 组内排名:ref_total DESC → status='active' DESC → id ASC
+INSERT INTO fm_dup_ranked_20261025 (member_id, user_id, family_id, status, ref_total, rn)
+SELECT fm.id, fm.user_id, fm.family_id, fm.status, COALESCE(rc.ref_total, 0),
+       ROW_NUMBER() OVER (
+           PARTITION BY fm.user_id, fm.family_id
+           ORDER BY COALESCE(rc.ref_total, 0) DESC,
+                    (fm.status = 'active') DESC,
+                    fm.id ASC
+       )
+FROM family_members fm
+LEFT JOIN fm_ref_count_20261025 rc ON rc.member_id = fm.id
+WHERE fm.user_id IS NOT NULL
+  AND (fm.user_id, fm.family_id) IN (
+      SELECT user_id, family_id FROM family_members
+      WHERE user_id IS NOT NULL
+      GROUP BY user_id, family_id
+      HAVING COUNT(*) > 1
+  );
+
+-- 2.3 生成映射:rn>1 为 dup,rn=1 为 canonical
+INSERT INTO fm_merge_map (user_id, family_id, dup_id, canonical_id)
+SELECT d.user_id, d.family_id, d.member_id,
+       (SELECT c.member_id FROM fm_dup_ranked_20261025 c
+         WHERE c.user_id = d.user_id AND c.family_id = d.family_id AND c.rn = 1)
+FROM fm_dup_ranked_20261025 d
+WHERE d.rn > 1;
+
+-- 2.4 映射结果人工核对(canonical 是否选在了数据最全的那一行)
+SELECT m.user_id, m.family_id, m.canonical_id, m.dup_id,
+       r.ref_total AS dup_ref_total,
+       c.ref_total AS canonical_ref_total,
+       r.status AS dup_status, c.status AS canonical_status
+FROM fm_merge_map m
+LEFT JOIN fm_dup_ranked_20261025 r ON r.member_id = m.dup_id
+LEFT JOIN fm_dup_ranked_20261025 c ON c.member_id = m.canonical_id
+ORDER BY m.user_id, m.family_id, m.dup_id;
+
+
+-- ============================================================================
+-- §3 · 唯一键冲突预检(DML 事务内;先看结果再决定是否继续)
+-- ============================================================================
+-- 3.1 profile_snapshot(UNIQUE member_id)
+SELECT 'profile_snapshot' AS tbl, m.dup_id, COUNT(p.id) AS conflict_rows
+FROM fm_merge_map m
+JOIN profile_snapshot p ON p.member_id = m.dup_id
+JOIN profile_snapshot c ON c.member_id = m.canonical_id
+GROUP BY m.dup_id;
+
+-- 3.2 cf_balance(UNIQUE member_id)
+SELECT 'cf_balance' AS tbl, m.dup_id, COUNT(b.id) AS conflict_rows
+FROM fm_merge_map m
+JOIN cf_balance b ON b.member_id = m.dup_id
+JOIN cf_balance c ON c.member_id = m.canonical_id
+GROUP BY m.dup_id;
+
+-- 3.3 diet_preferences(UNIQUE family_member_id)
+SELECT 'diet_preferences' AS tbl, m.dup_id, COUNT(d.id) AS conflict_rows
+FROM fm_merge_map m
+JOIN diet_preferences d ON d.family_member_id = m.dup_id
+JOIN diet_preferences c ON c.family_member_id = m.canonical_id
+GROUP BY m.dup_id;
+
+-- 3.4 关系边 / 挑战参与 / 报告授权 等复合唯一键冲突
+SELECT 'family_relationships' AS tbl, m.dup_id, COUNT(*) AS conflict_rows
+FROM fm_merge_map m
+JOIN family_relationships r ON r.from_member_id = m.dup_id
+JOIN family_relationships c ON c.from_member_id = m.canonical_id
+                            AND c.to_member_id   = r.to_member_id
+GROUP BY m.dup_id
+UNION ALL
+SELECT 'family_challenge_participant', m.dup_id, COUNT(*)
+FROM fm_merge_map m
+JOIN family_challenge_participant p ON p.member_id = m.dup_id
+JOIN family_challenge_participant c ON c.challenge_id = p.challenge_id
+                                  AND c.member_id    = m.canonical_id
+GROUP BY m.dup_id
+UNION ALL
+SELECT 'report_member_permissions', m.dup_id, COUNT(*)
+FROM fm_merge_map m
+JOIN report_member_permissions p ON p.target_member_id = m.dup_id
+JOIN report_member_permissions c ON c.family_id        = p.family_id
+                                AND c.target_member_id = m.canonical_id
+                                AND c.grantee_user_id = p.grantee_user_id
+GROUP BY m.dup_id
+UNION ALL
+SELECT 'challenge_progress', m.dup_id, COUNT(*)
+FROM fm_merge_map m
+JOIN challenge_progress p ON p.member_id = m.dup_id
+JOIN challenge_progress c ON c.member_id  = m.canonical_id
+                           AND c.task_key  = p.task_key
+                           AND c.week_num  = p.week_num
+GROUP BY m.dup_id;
+
+
+-- ============================================================================
+-- §4 · 事务开始(DML;DDL 不在事务内,MySQL 会隐式提交)
+-- ============================================================================
+START TRANSACTION;
+
+-- ---------------------------------------------------------------------------
+-- 4.1 唯一键冲突:删除 dup 行在"单成员唯一表"中的记录(保留 canonical 的行)
+--     策略说明:这些表每成员仅一行且可重算/可再生(画像快照、饮食偏好、关系边等),
+--     冲突时以 canonical 为准。★ 数据取舍点,DBA 须确认 §3 预检结果 ★
+-- ---------------------------------------------------------------------------
+DELETE p FROM profile_snapshot p
+JOIN fm_merge_map m ON m.dup_id = p.member_id
+JOIN profile_snapshot c ON c.member_id = m.canonical_id;
+
+DELETE d FROM diet_preferences d
+JOIN fm_merge_map m ON m.dup_id = d.family_member_id
+JOIN diet_preferences c ON c.family_member_id = m.canonical_id;
+
+DELETE a FROM family_member_attributes a
+JOIN fm_merge_map m ON m.dup_id = a.member_id
+JOIN family_member_attributes c ON c.member_id = m.canonical_id
+                                AND c.member_type = a.member_type;
+
+DELETE s FROM family_member_dimension_base_scores s
+JOIN fm_merge_map m ON m.dup_id = s.member_id
+JOIN family_member_dimension_base_scores c ON c.member_id      = m.canonical_id
+                                          AND c.dimension_code = s.dimension_code;
+
+DELETE p FROM innate_portrait_report p
+JOIN fm_merge_map m ON m.dup_id = p.member_id
+JOIN innate_portrait_report c ON c.member_id = m.canonical_id
+                              AND c.member_type = p.member_type;
+
+DELETE p FROM challenge_progress p
+JOIN fm_merge_map m ON m.dup_id = p.member_id
+JOIN challenge_progress c ON c.member_id = m.canonical_id
+                          AND c.task_key = p.task_key
+                          AND c.week_num = p.week_num;
+
+DELETE p FROM family_challenge_participant p
+JOIN fm_merge_map m ON m.dup_id = p.member_id
+JOIN family_challenge_participant c ON c.challenge_id = p.challenge_id
+                                    AND c.member_id    = m.canonical_id;
+
+DELETE p FROM report_member_permissions p
+JOIN fm_merge_map m ON m.dup_id = p.target_member_id
+JOIN report_member_permissions c ON c.family_id        = p.family_id
+                                    AND c.target_member_id = m.canonical_id
+                                    AND c.grantee_user_id = p.grantee_user_id;
+
+DELETE r FROM family_relationships r
+JOIN fm_merge_map m ON m.dup_id = r.from_member_id
+JOIN family_relationships c ON c.from_member_id = m.canonical_id
+                            AND c.to_member_id   = r.to_member_id;
+
+DELETE r FROM family_relationship_scores r
+JOIN fm_merge_map m ON m.dup_id = r.from_member_id
+JOIN family_relationship_scores c ON c.from_member_id = m.canonical_id
+                                  AND c.to_member_id   = r.to_member_id;
+
+DELETE s FROM social_circle_member s
+JOIN fm_merge_map m ON m.dup_id = s.member_id
+JOIN social_circle_member c ON c.circle_id = s.circle_id
+                            AND c.member_id = m.canonical_id;
+
+DELETE l FROM social_circle_like l
+JOIN fm_merge_map m ON m.dup_id = l.member_id
+JOIN social_circle_like c ON c.circle_id = l.circle_id
+                          AND c.post_id   = l.post_id
+                          AND c.member_id = m.canonical_id;
+
+-- cf_balance_log:幂等键 UNIQUE(member_id,type,ref_type,ref_id),冲突时保留 canonical 的流水
+DELETE l FROM cf_balance_log l
+JOIN fm_merge_map m ON m.dup_id = l.member_id
+JOIN cf_balance_log c ON c.member_id  = m.canonical_id
+                       AND c.type      = l.type
+                       AND c.ref_type  = l.ref_type
+                       AND (c.ref_id <=> l.ref_id);
+
+-- 其余含成员列的唯一键(冲突时以 canonical 为准,删除 dup 行)
+DELETE b FROM child_badge b
+JOIN fm_merge_map m ON m.dup_id = b.child_id
+JOIN child_badge c ON c.child_id = m.canonical_id
+                  AND c.badge_id = b.badge_id;
+
+DELETE e FROM energy_balance e
+JOIN fm_merge_map m ON m.dup_id = e.child_id
+JOIN energy_balance c ON c.child_id     = m.canonical_id
+                      AND c.dimension_id = e.dimension_id;
+
+DELETE t FROM tianpan_member_annual_energy t
+JOIN fm_merge_map m ON m.dup_id = t.member_id
+JOIN tianpan_member_annual_energy c ON c.member_id = m.canonical_id
+                                    AND c.year     = t.year;
+
+DELETE r FROM activity_registrations r
+JOIN fm_merge_map m ON m.dup_id = r.child_id
+JOIN activity_registrations c ON c.activity_id = r.activity_id
+                             AND c.child_id    = m.canonical_id;
+
+DELETE f FROM activity_feedback f
+JOIN fm_merge_map m ON m.dup_id = f.child_id
+JOIN activity_feedback c ON c.activity_id = f.activity_id
+                         AND c.child_id    = m.canonical_id;
+
+-- 关系边的"反向"冲突:dup 作为 to_member_id 时,可能与 canonical 撞同一对 (from,to)
+DELETE r FROM family_relationships r
+JOIN fm_merge_map m ON m.dup_id = r.to_member_id
+JOIN family_relationships c ON c.from_member_id = r.from_member_id
+                            AND c.to_member_id   = m.canonical_id;
+
+DELETE r FROM family_relationship_scores r
+JOIN fm_merge_map m ON m.dup_id = r.to_member_id
+JOIN family_relationship_scores c ON c.from_member_id = r.from_member_id
+                                  AND c.to_member_id   = m.canonical_id;
+
+-- ---------------------------------------------------------------------------
+-- 4.2 cf_balance 专项合并:余额类字段求和后并入 canonical(★不丢 CF 值★)
+--     canonical 无行 → 直接改写;canonical 已有行 → 累加后删除 dup 行
+-- ---------------------------------------------------------------------------
+UPDATE cf_balance b
+JOIN fm_merge_map m ON m.dup_id = b.member_id
+LEFT JOIN cf_balance c ON c.member_id = m.canonical_id
+SET b.member_id = m.canonical_id
+WHERE c.id IS NULL;
+
+UPDATE cf_balance b
+JOIN fm_merge_map m ON m.dup_id = b.member_id
+JOIN cf_balance c ON c.member_id = m.canonical_id
+SET c.total_earned = c.total_earned + b.total_earned,
+    c.available    = c.available    + b.available,
+    c.frozen       = c.frozen       + b.frozen,
+    c.withdrawn    = c.withdrawn    + b.withdrawn,
+    c.exchanged    = c.exchanged    + b.exchanged,
+    c.updated_at   = NOW()
+WHERE c.id <> b.id;
+
+DELETE b FROM cf_balance b
+JOIN fm_merge_map m ON m.dup_id = b.member_id
+JOIN cf_balance c ON c.member_id = m.canonical_id
+WHERE c.id <> b.id;
+
+-- ---------------------------------------------------------------------------
+-- 4.3 引用迁移(标量列):把 dup_id 改写为 canonical_id
+--     由存储过程遍历,运行时用 information_schema 判断表/列是否存在,
+--     避免生产 schema 漂移导致脚本中断。
+-- ---------------------------------------------------------------------------
+DROP PROCEDURE IF EXISTS fm_migrate_member_refs;
+DELIMITER $$
+CREATE PROCEDURE fm_migrate_member_refs()
+BEGIN
+    DECLARE v_done    INT DEFAULT 0;
+    DECLARE v_table   VARCHAR(64);
+    DECLARE v_column  VARCHAR(64);
+
+    DECLARE cur CURSOR FOR
+        SELECT TABLE_NAME, COLUMN_NAME FROM (
+            -- 任务体系
+            SELECT 'tasks'             AS TABLE_NAME, 'family_member_id' AS COLUMN_NAME
+            UNION ALL SELECT 'task_executions',   'member_id'
+            UNION ALL SELECT 'task_orchestration_executions', 'family_member_id'
+            UNION ALL SELECT 'task_reviews',      'child_id'
+            UNION ALL SELECT 'task_plan_instances','child_id'
+            -- 积分 / CF 值
+            UNION ALL SELECT 'points_log',          'child_id'
+            UNION ALL SELECT 'points_log',          'family_member_id'
+            UNION ALL SELECT 'points_reset_log',    'child_id'
+            UNION ALL SELECT 'rewards',             'child_id'
+            UNION ALL SELECT 'points_exchange_records', 'child_id'
+            UNION ALL SELECT 'points_exchange_records', 'family_member_id'
+            UNION ALL SELECT 'ppoint_consumption_log',  'member_id'
+            UNION ALL SELECT 'cf_balance_log',      'member_id'
+            UNION ALL SELECT 'cf_balance',          'member_id'
+            -- 成长 / 游戏 / 徽章
+            UNION ALL SELECT 'growth_records',      'child_id'
+            UNION ALL SELECT 'growth_records',      'family_member_id'
+            UNION ALL SELECT 'growth_plans',        'child_id'
+            UNION ALL SELECT 'game_records',        'child_id'
+            UNION ALL SELECT 'child_badge',         'child_id'
+            UNION ALL SELECT 'focus_sessions',      'child_id'
+            UNION ALL SELECT 'math_child_level',    'child_id'
+            UNION ALL SELECT 'math_quiz_logs',      'child_id'
+            UNION ALL SELECT 'article_reading_records', 'child_id'
+            UNION ALL SELECT 'article_quiz_records',    'member_id'
+            -- 测评 / 订单
+            UNION ALL SELECT 'dan_assessment_results', 'child_id'
+            UNION ALL SELECT 'dan_assessment_results', 'family_member_id'
+            UNION ALL SELECT 'dan_assessment_executions', 'child_id'
+            UNION ALL SELECT 'dan_report_uploads',   'family_member_id'
+            UNION ALL SELECT 'assessment_appointments', 'child_id'
+            UNION ALL SELECT 'assessment_orders',   'child_id'
+            UNION ALL SELECT 'assessment_records',  'child_id'
+            UNION ALL SELECT 'assessment_executions', 'child_id'
+            UNION ALL SELECT 'wishes',              'child_id'
+            UNION ALL SELECT 'activity_registrations', 'child_id'
+            UNION ALL SELECT 'activity_feedback',   'child_id'
+            UNION ALL SELECT 'indicator_values',    'subject_id'
+            UNION ALL SELECT 'tianpan_member_annual_energy', 'member_id'
+            -- 健康体系
+            UNION ALL SELECT 'health_checkins',      'child_id'
+            UNION ALL SELECT 'daily_checkin',        'member_id'
+            UNION ALL SELECT 'sleep_record',         'member_id'
+            UNION ALL SELECT 'water_record',         'member_id'
+            UNION ALL SELECT 'health_meal_record',   'member_id'
+            UNION ALL SELECT 'health_meditation_record', 'member_id'
+            UNION ALL SELECT 'exercise_record',      'member_id'
+            UNION ALL SELECT 'emotion_checkin',      'child_id'
+            UNION ALL SELECT 'emotion_alert',        'child_id'
+            UNION ALL SELECT 'health_alerts',        'child_id'
+            UNION ALL SELECT 'health_timeline_event', 'child_id'
+            UNION ALL SELECT 'health_dimension_scores', 'member_id'
+            UNION ALL SELECT 'health_data_sources',  'member_id'
+            UNION ALL SELECT 'health_status',        'member_id'
+            UNION ALL SELECT 'health_report_drafts', 'member_id'
+            UNION ALL SELECT 'micro_action_records', 'child_id'
+            UNION ALL SELECT 'daily_feedback',       'child_id'
+            -- 家庭关系 / 能量 / 五维
+            UNION ALL SELECT 'family_relationships', 'from_member_id'
+            UNION ALL SELECT 'family_relationships', 'to_member_id'
+            UNION ALL SELECT 'family_relationship_scores', 'from_member_id'
+            UNION ALL SELECT 'family_relationship_scores', 'to_member_id'
+            UNION ALL SELECT 'family_challenge_progress', 'child_id'
+            UNION ALL SELECT 'family_challenge_participant', 'member_id'
+            UNION ALL SELECT 'interaction_logs',     'from_member_id'
+            UNION ALL SELECT 'interaction_logs',     'to_member_id'
+            UNION ALL SELECT 'relationship_questionnaire_snapshots', 'family_member_id'
+            UNION ALL SELECT 'energy_log',           'child_id'
+            UNION ALL SELECT 'energy_balance',       'child_id'
+            UNION ALL SELECT 'five_dimension_scores', 'member_id'
+            UNION ALL SELECT 'five_dimension_self_checks', 'family_member_id'
+            UNION ALL SELECT 'family_member_dimension_base_scores', 'member_id'
+            UNION ALL SELECT 'profile_snapshot',     'member_id'
+            UNION ALL SELECT 'profile_history',      'member_id'
+            -- 饮食 / 营养
+            UNION ALL SELECT 'diet_preferences',     'family_member_id'
+            UNION ALL SELECT 'diet_records',         'member_id'
+            UNION ALL SELECT 'tongue_records',       'member_id'
+            UNION ALL SELECT 'gut_health_projects',  'member_id'
+            UNION ALL SELECT 'fat_weight_record',    'member_id'
+            UNION ALL SELECT 'fat_diet_record',      'member_id'
+            UNION ALL SELECT 'fat_exercise_record',  'member_id'
+            UNION ALL SELECT 'fat_goal',             'member_id'
+            UNION ALL SELECT 'survey_records',       'member_id'
+            -- 社交 / 活动 / 其他
+            UNION ALL SELECT 'social_circle_member', 'member_id'
+            UNION ALL SELECT 'social_circle_post',   'member_id'
+            UNION ALL SELECT 'social_circle_like',   'member_id'
+            UNION ALL SELECT 'social_circle_comment','member_id'
+            UNION ALL SELECT 'report_member_permissions', 'target_member_id'
+            UNION ALL SELECT 'report_feedback',      'member_id'
+            UNION ALL SELECT 'guide_popup_log',      'member_id'
+            UNION ALL SELECT 'doa_stage',            'member_id'
+            UNION ALL SELECT 'challenge_progress',   'member_id'
+            UNION ALL SELECT 'innate_portrait_report', 'member_id'
+            UNION ALL SELECT 'procurement_tasks',    'family_member_id'
+            UNION ALL SELECT 'contacts',             'family_member_id'
+            UNION ALL SELECT 'family_member_attributes', 'member_id'
+        ) t;
+
+    DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = 1;
+
+    OPEN cur;
+    read_loop: LOOP
+        FETCH cur INTO v_table, v_column;
+        IF v_done = 1 THEN
+            LEAVE read_loop;
+        END IF;
+
+        IF EXISTS (
+            SELECT 1 FROM information_schema.COLUMNS
+            WHERE TABLE_SCHEMA = DATABASE()
+              AND TABLE_NAME   = v_table
+              AND COLUMN_NAME  = v_column
+        ) THEN
+            SET @sql = CONCAT(
+                'UPDATE `', v_table, '` t ',
+                'JOIN fm_merge_map m ON m.dup_id = t.`', v_column, '` ',
+                'SET t.`', v_column, '` = m.canonical_id'
+            );
+            PREPARE stmt FROM @sql;
+            EXECUTE stmt;
+            DEALLOCATE PREPARE stmt;
+        ELSE
+            SELECT CONCAT('[SKIP] 表或列不存在: ', v_table, '.', v_column) AS skipped;
+        END IF;
+    END LOOP;
+    CLOSE cur;
+END$$
+DELIMITER ;
+
+CALL fm_migrate_member_refs();
+DROP PROCEDURE IF EXISTS fm_migrate_member_refs;
+
+-- ---------------------------------------------------------------------------
+-- 4.3.1 tasks.child_id 专项(legacy 列,语义可能混用 users.id / family_members.id)
+--     tasks.family_member_id 已在 §4.3 迁移;child_id 为历史遗留列,
+--     仅在 executor_type 非 parent(其值为"成员/孩子"语义)时改写,
+--     避免误改指向 users.id 的家长任务行。
+--     ★ 执行前请用下列 SQL 核对 child_id 取值域:
+--       SELECT DISTINCT executor_type FROM tasks WHERE child_id IS NOT NULL;
+-- ---------------------------------------------------------------------------
+UPDATE tasks t
+JOIN fm_merge_map m ON m.dup_id = t.child_id
+SET t.child_id = m.canonical_id
+WHERE t.executor_type IS NULL OR t.executor_type <> 'parent';
+
+-- ---------------------------------------------------------------------------
+-- 4.4 列表型引用(VARCHAR 逗号串 / JSON 数组):替换其中的 dup_id
+--     health_plans.member_ids                —— VARCHAR 逗号分隔
+--     meal_configs.participant_member_ids    —— JSON 数组
+--     diet_recommendations.participant_member_ids —— JSON 数组
+--     ★ JSON 列不能用字符串 REPLACE 改写(会破坏 JSON 格式),
+--       故用 JSON_TABLE 拆元素 → 替换 → JSON_ARRAYAGG 重组。
+-- ---------------------------------------------------------------------------
+-- 4.4.1 VARCHAR 逗号串
+UPDATE health_plans hp
+JOIN fm_merge_map m ON FIND_IN_SET(m.dup_id, hp.member_ids) > 0
+SET hp.member_ids = TRIM(BOTH ',' FROM REPLACE(
+        CONCAT(',', hp.member_ids, ','),
+        CONCAT(',', m.dup_id, ','),
+        CONCAT(',', m.canonical_id, ',')));
+
+-- 4.4.2 JSON 数组:meal_configs
+UPDATE meal_configs mc
+JOIN fm_merge_map m
+  ON JSON_CONTAINS(mc.participant_member_ids, CAST(m.dup_id AS JSON))
+SET mc.participant_member_ids = (
+    SELECT COALESCE(
+             JSON_ARRAYAGG(CASE WHEN j.mid = m.dup_id
+                                THEN CAST(m.canonical_id AS SIGNED)
+                                ELSE j.mid END),
+             JSON_ARRAY())
+    FROM JSON_TABLE(mc.participant_member_ids, '$[*]' COLUMNS (mid BIGINT PATH '$')) j
+);
+
+-- 4.4.3 JSON 数组:diet_recommendations
+UPDATE diet_recommendations dr
+JOIN fm_merge_map m
+  ON JSON_CONTAINS(dr.participant_member_ids, CAST(m.dup_id AS JSON))
+SET dr.participant_member_ids = (
+    SELECT COALESCE(
+             JSON_ARRAYAGG(CASE WHEN j.mid = m.dup_id
+                                THEN CAST(m.canonical_id AS SIGNED)
+                                ELSE j.mid END),
+             JSON_ARRAY())
+    FROM JSON_TABLE(dr.participant_member_ids, '$[*]' COLUMNS (mid BIGINT PATH '$')) j
+);
+
+-- ---------------------------------------------------------------------------
+-- 4.5 删除重复成员行(引用已全部改写到 canonical)
+-- ---------------------------------------------------------------------------
+DELETE fm FROM family_members fm
+JOIN fm_merge_map m ON m.dup_id = fm.id;
+
+-- 4.6 事务提交
+COMMIT;
+
+
+-- ============================================================================
+-- §5 · 加唯一约束(DDL,隐式提交;执行前请确认 §8 的重复校验已通过)
+-- ============================================================================
+-- 5.1 确认已无重复行(有结果则必须停止并排查)
+SELECT user_id, family_id, COUNT(*) AS still_dup
+FROM family_members
+WHERE user_id IS NOT NULL
+GROUP BY user_id, family_id
+HAVING COUNT(*) > 1;
+
+-- 5.2 加唯一索引(MySQL 唯一索引不约束 NULL → user_id IS NULL 的 local 成员可多条)
+ALTER TABLE family_members
+    ADD UNIQUE KEY uk_family_members_user_family (user_id, family_id);
+
+
+-- ============================================================================
+-- §6 · 幂等重入保护(脚本重复执行不会二次破坏数据)
+-- ============================================================================
+-- 6.1 唯一索引已存在时,§5.2 会报错;重入前先清理辅助表并跳过 §5.2
+--     (DELETE / UPDATE 均以 fm_merge_map 为驱动,映射表清空后即为 no-op)
+-- 6.2 重新演练时:
+--     DELETE FROM fm_merge_map;
+--     DELETE FROM fm_dup_ranked_20261025;
+--     DELETE FROM fm_ref_count_20261025;
+
+
+-- ============================================================================
+-- §7 · 业务侧收尾(应用层)
+-- ============================================================================
+-- 7.1 本分支已包含确定性解析改造(随代码发布):
+--       JwtInterceptor:177-182  → status='active' → is_admin=1 → id 最小
+--       FamilyMemberService.listMembers 去重口径与之一致
+-- 7.2 上线顺序建议:先发布代码(解析确定性 + 幂等 /complete),再执行本合并脚本,
+--     最后执行前端发版(T24-B 去掉冗余 /complete)。
+
+
+-- ============================================================================
+-- §8 · 执行后校验
+-- ============================================================================
+-- 8.1 重复行应为 0
+SELECT COUNT(*) AS remaining_dup_groups
+FROM (
+    SELECT user_id, family_id FROM family_members
+    WHERE user_id IS NOT NULL
+    GROUP BY user_id, family_id HAVING COUNT(*) > 1
+) x;
+
+-- 8.2 唯一索引应存在
+SELECT INDEX_NAME, NON_UNIQUE, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS cols
+FROM information_schema.STATISTICS
+WHERE TABLE_SCHEMA = DATABASE()
+  AND TABLE_NAME   = 'family_members'
+GROUP BY INDEX_NAME, NON_UNIQUE;
+
+-- 8.3 悬空引用检查:业务表里是否还有指向已删除 member 的行(应全部为 0)
+SELECT 'tasks.family_member_id' AS ref, COUNT(*) AS dangling
+FROM tasks t
+LEFT JOIN family_members fm ON fm.id = t.family_member_id
+WHERE t.family_member_id IS NOT NULL AND fm.id IS NULL
+UNION ALL
+SELECT 'task_executions.member_id', COUNT(*)
+FROM task_executions te
+LEFT JOIN family_members fm ON fm.id = te.member_id
+WHERE te.member_id IS NOT NULL AND fm.id IS NULL
+UNION ALL
+SELECT 'points_log.child_id', COUNT(*)
+FROM points_log pl
+LEFT JOIN family_members fm ON fm.id = pl.child_id
+WHERE fm.id IS NULL
+UNION ALL
+SELECT 'health_meal_record.member_id', COUNT(*)
+FROM health_meal_record h
+LEFT JOIN family_members fm ON fm.id = h.member_id
+WHERE h.member_id IS NOT NULL AND fm.id IS NULL
+UNION ALL
+SELECT 'sleep_record.member_id', COUNT(*)
+FROM sleep_record s
+LEFT JOIN family_members fm ON fm.id = s.member_id
+WHERE s.member_id IS NOT NULL AND fm.id IS NULL
+UNION ALL
+SELECT 'water_record.member_id', COUNT(*)
+FROM water_record w
+LEFT JOIN family_members fm ON fm.id = w.member_id
+WHERE w.member_id IS NOT NULL AND fm.id IS NULL
+UNION ALL
+SELECT 'exercise_record.member_id', COUNT(*)
+FROM exercise_record e
+LEFT JOIN family_members fm ON fm.id = e.member_id
+WHERE e.member_id IS NOT NULL AND fm.id IS NULL
+UNION ALL
+SELECT 'family_relationships.from_member_id', COUNT(*)
+FROM family_relationships r
+LEFT JOIN family_members fm ON fm.id = r.from_member_id
+WHERE fm.id IS NULL
+UNION ALL
+SELECT 'family_relationships.to_member_id', COUNT(*)
+FROM family_relationships r
+LEFT JOIN family_members fm ON fm.id = r.to_member_id
+WHERE fm.id IS NULL;
+
+-- 8.4 CF 值总额未被吞掉(合并前后总额应一致:把备份表 join 进来对比)
+SELECT
+    (SELECT COALESCE(SUM(available), 0) FROM cf_balance) AS after_available,
+    (SELECT COALESCE(SUM(b.available), 0)
+       FROM cf_balance b
+       JOIN family_members_bak_20261025 k ON k.id = b.member_id) AS before_available_in_backup;
+
+-- 8.5 合并规模复核
+SELECT COUNT(*) AS merged_groups,
+       SUM(member_cnt - 1) AS deleted_rows
+FROM (
+    SELECT user_id, family_id, COUNT(*) AS member_cnt
+    FROM fm_merge_map GROUP BY user_id, family_id
+) x;
+
+
+-- ============================================================================
+-- §9 · 回滚说明(逐组 / 全量)
+-- ============================================================================
+-- ⚠️ 回滚前提:family_members_bak_20261025 与 fm_merge_map 必须保留。
+--
+-- 9.1 整组回滚(把某一组被删的 dup 行整体还原):
+--     步骤 a) 用 §4.3 的同一存储过程反向执行(canonical_id → dup_id);
+--     步骤 b) 还原 family_members 行:
+--       INSERT INTO family_members
+--       SELECT k.* FROM family_members_bak_20261025 k
+--       JOIN fm_merge_map m ON m.dup_id = k.id
+--       WHERE m.user_id = <目标user_id> AND m.family_id = <目标family_id>;
+--     步骤 c) 删除唯一索引:
+--       ALTER TABLE family_members DROP INDEX uk_family_members_user_family;
+--
+-- 9.2 全量回滚(一次回到执行前状态):
+--     START TRANSACTION;
+--       -- a) 反向改写标量引用(canonical_id → dup_id)
+--       UPDATE tasks t JOIN fm_merge_map m ON m.canonical_id = t.family_member_id
+--       SET t.family_member_id = m.dup_id;
+--       -- 其余表同理,列清单与 §4.3 一致
+--       -- b) 还原 family_members
+--       INSERT INTO family_members
+--       SELECT k.* FROM family_members_bak_20261025 k
+--       WHERE NOT EXISTS (SELECT 1 FROM family_members f WHERE f.id = k.id);
+--     COMMIT;
+--     ALTER TABLE family_members DROP INDEX uk_family_members_user_family;
+--
+-- 9.3 回滚注意:
+--     · §4.1 删除的"唯一键冲突行"(画像快照/饮食偏好/关系边等)无法自动还原,
+--       需从整库备份(§1.4 备份单号)按 member_id 回捞。
+--     · §4.2 cf_balance 的求和合并无法自动拆分,需从整库备份回捞余额。
+--     · 因此 §1.4 的"整库备份"是回滚的最后保险,务必在执行前完成并校验可用。
+-- ============================================================================