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

docs: T24 睡眠打卡500+双身份 bug 设计 / T25 统一成员选择器设计 / 双身份只读诊断SQL

zxyj-web 2 недель назад
Родитель
Сommit
e05c611b91

+ 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;

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

+ 80 - 0
docs/superpowers/plans/2026-10-25-unified-member-selector.md

@@ -0,0 +1,80 @@
+# 统一成员选择器(去重双身份)设计
+
+> 关联:T24(生产 bug:睡眠打卡 500 + 双身份)。本文件是 T24-C「前端统一选择器」子项的单一事实源。
+> 日期:2026-10-25
+> 触发:用户指出"食材查询页面也显示了本人和本人对应的成员;凡事有成员选择的地方,应使用一个组件统一处理"。
+
+---
+
+## 一、问题定义
+
+### 1.1 现象
+- 任务列表页、食材查询页等**所有带成员选择的页面**,同一用户被显示成两项:「本人」与「本人所对应的成员」。
+- 两项切换后**任务/数据列表不同**(因为两条 `family_members` 行各自挂着不同的任务外键)。
+
+### 1.2 根因(已核实)
+1. **数据层**:`family_members` 对同一 `user_id` 存在多条"家长本人"重复记录(已知存量,见 `EnergyService.java:121/263`)。
+2. **后端 `isSelf` 单点标记**:`FamilyMemberService.java:344` `vo.setIsSelf(viewerMember.getId().equals(fm.getId()))`——`viewerMember` 由 `JwtInterceptor` 用 `LIMIT 1` 解析(非确定性)。两条重复行里只有被解析到的那条标 `isSelf=true`,另一条沦为普通"成员"。
+3. **前端无去重**:所有选择器直接迭代成员列表,重复行原样渲染成两个选项。
+4. **身份键不统一**:部分页面用 `familyMemberId`(如 `tasks.vue`、`FamilyMemberStrip`、`DimensionMemberTabs`),部分用 `userId`(如 `food-query.vue`:`currentMemberId === m.userId`、`switchMember` 设 `member.userId`)。`userId` 在重复行下完全无法区分两条记录,必然重复。
+
+### 1.3 关键工程判断(务必先读)
+**纯前端去重会丢数据。** 两条重复行各自挂着不同任务(`tasks.family_member_id`、`task_executions.member_id`、`health_*`、`points_log.child_id` 等)。若仅在 UI 隐藏一行,被隐藏行的数据将不可达。
+→ 因此正确顺序是:**先做数据去重与合并迁移(T24-C 数据部分,根),再做统一组件(防御 + 一致性)**。组件去重只是过渡期防护,不是最终解。
+
+---
+
+## 二、统一方案(三层)
+
+### 层 1 · 数据根治(T24-C 数据部分,根,必做,需授权)
+- 诊断 SQL(见 `2026-10-25-task-complete-500-bug.md` §三)确认 `(user_id, family_id)` 重复范围。
+- 合并:保留一条主成员(优先 `status='active'`、外键引用最多、积分/打卡数据最全),将其余重复行的外键迁移到保留行,删除重复行。
+- 加约束 `UNIQUE(user_id, family_id)`(允许 `user_id IS NULL` 的 local 成员多条)。
+
+### 层 2 · 后端确定性(T24-C 后端部分,必做)
+- `JwtInterceptor:177-182` 的 `LIMIT 1` 改为确定性取值(优先 `status='active'` 且 `userId` 匹配的"主成员")。
+- 成员列表接口(`FamilyMemberService` 列表方法)与 `isSelf` 计算去重感知:同一 `user_id` 只暴露一条,且 `isSelf` 唯一。
+- 若暂未合并数据,列表接口应在后端先按 `user_id` 折叠(同 user 多行 → 一行,canonical `id` = 主成员),避免重复下发给前端。
+
+### 层 3 · 前端统一组件(本文件主体,必做,防御 + 一致性)
+- 新增共享**去重规范化工具** `cfc-frontend/utils/memberNormalize.js`:
+  - 入参:原始成员数组 + 当前 viewer(`userId` 或 `memberId`)。
+  - 折叠同 `user_id` 的多行为一条;canonical `memberId` 选取优先级:`isSelf` 行 → `status='active'` → 外键数据最多 → `id` 最小。
+  - 保证每个真实用户**至多一条**且 `isSelf` 唯一;输出稳定 `memberId`。
+  - 统一身份键为 **`familyMemberId`(即 `id`)**;`userId` 仅作为折叠依据,不再作为选择器主键。
+- 新增统一组件 `cfc-frontend/components/MemberSelector.vue`,支持 `variant` 形态:
+  - `strip`:替代 `FamilyMemberStrip`(大卡 + 长按操作菜单)。
+  - `tabs`:替代 `DimensionMemberTabs`(轻 chip)。
+  - `minimal`:替代 `food-query.vue` 等内联 `member-bar`(含「家庭总览 / 个人 / 各成员」形态)。
+  - Props:`members`(可选,缺省自取)、`selectedMemberId`、`variant`、`dimensionCode`(tabs 用)、`scoreMap`、`showOverview`(minimal 用)。
+  - 内部先调 `memberNormalize` 再渲染;`emit('select', member)` 带 canonical `memberId`;`emit('switch')` 等保持。
+- 改造现有两个组件与高优页面,**全部改用 `MemberSelector` + `memberNormalize`**:
+  - P1(用户可见 bug 页,必做):`food-query.vue`(迁移 `userId`→`memberId`)、`tasks.vue`、改用 `MemberSelector` 替代 `FamilyMemberStrip` 与 `DimensionMemberTabs`(4 个维度页 + 3 个 strip 页)。
+  - P2(其余内联选择器,列表见 §四):逐页替换为 `MemberSelector`,消除 `userId` 作主键的写法。
+
+---
+
+## 三、food-query.vue 专项(用户点名页)
+- 现:`/api/food/recommendation/members` 返回成员,选择器按 `m.userId` 匹配与切换(`currentMemberId === m.userId`、`switchMember` 设 `member.userId`)。
+- 改:调用 `memberNormalize` 折叠后渲染;选择器主键改为 `member.id`(familyMemberId);`switchMember` 设 `this.currentMemberId = member.id`;「个人」项 = `isSelf` 行,「家庭总览」保持聚合。
+- 后端 `/api/food/recommendation/members` 须返回 `familyMemberId` 字段(若无则前端用 `id`);若该接口内部也按 `userId` 聚合,需同步改为按 canonical `memberId` 聚合,避免重复下发。
+
+---
+
+## 四、内联选择器清单(P2,按出现顺序,需逐页替换)
+health/survey-questionnaire、indicator-curve、health-survey、report-list、water-index、survey-history、indicator-quicklook、sleep-index、health-plan-summary、profile-main/profile、promotion/team、diet/index、diet/records、diet/record-detail、psych-assessment/index、index-home/index、diet/preferences、gut-flora-detail、gut-flora-foods-detail、doa-*、energy/index、health/daily-checkin、health/diet-index、exercise-index、growth-main/index、profile/portrait-edit、home-pages/child-index、profile/message-history、teacher/review-family、growth/exercise-checkin、growth/diet-checkin、growth/sleep-checkin、mind-detail/emotion-checkin、health/bodyfat-confirm、home-pages/child-index、mind-detail/emotion-trend、growth/caution-detail、growth/challenge-progress、tasks/task-upload-purchase、action-detail/interaction-log、shop/payment/payment 等(凡含 `memberList`/`currentMemberId`/内联 `member-tab` 且未用统一组件者)。
+
+---
+
+## 五、验收
+- 全量回归:任一成员选择页,同一用户**只出现一次**「本人/个人」项,不再有"本人 + 本人所对应的成员"两项。
+- 切换「本人」→ 路由到 canonical `memberId`,其下任务/打卡/健康数据**全部可见**(验证去重未丢数据,需数据已合并或规范化覆盖)。
+- 身份键统一为 `familyMemberId`;`userId` 不再作为选择器主键(grep 全仓确认)。
+- `mvn clean compile` + `cfc-frontend` `node --check` / `npm run build` 通过;无新增 ESLint 阻断。
+
+---
+
+## 六、风险与边界
+- **数据合并(层 1)是唯一真解**;层 3 组件去重是过渡防护,单独上线无法消除"列表不同"(仅隐藏一项会丢数据)——故层 1 必须先于或同步于层 3 全量落地。
+- 层 1 写库 + 外键迁移属高危,必须备份 + DBA 复核 + 用户授权,不在前端分支范围。
+- `FamilyMemberStrip` 的长按操作菜单(移除/切换/编辑)逻辑复杂,迁移到 `MemberSelector` 的 `strip` 变体时须完整保留。