|
|
@@ -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 的"整库备份"是回滚的最后保险,务必在执行前完成并校验可用。
|
|
|
+-- ============================================================================
|