浏览代码

docs(plan): 成员模型重构——清理孩子/child角色定义实现计划

iwt 2 周之前
父节点
当前提交
ae84a79659
共有 1 个文件被更改,包括 674 次插入0 次删除
  1. 674 0
      docs/superpowers/plans/2026-09-05-member-model-refactor.md

+ 674 - 0
docs/superpowers/plans/2026-09-05-member-model-refactor.md

@@ -0,0 +1,674 @@
+# 成员模型重构:清理「孩子/child」角色定义 实现计划
+
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
+
+**目标:** 项目不再定义"孩子(child)"特殊角色,统一为"成员(member)"体系——成员分为**可登录成员**(有 openid,邀请/绑定而来)与**不可登录成员**(用户创建,无 openid,仅可通过切换使用);切换身份与退出切换需原用户密码保护。
+
+**用户决策(2026-09-05):**
+1. 切换密码未单独设置时,默认使用登录密码校验(复用 `users.password`,不新增独立字段)
+2. 存量 `role='child'` 账号**强制迁移**:有 openid 的 → 迁移为"可登录成员"(role 改 parent,登录后进成员端界面,不能被切换);无 openid 的 → 迁移为"不可登录成员"(仅可被切换),并确保有对应 `family_members` 记录
+
+**架构:** 以 `users.openid` 是否为空作为可登录/不可登录的唯一判定依据(创建的成员也创建 user 记录但不设 openid;输入手机号时预留未来注册复用)。角色层移除 `role='child'` 的语义判断,前端切换视图状态从 child 语义迁移到 member 语义,切换/退出切换接入密码校验。`executorType/creatorType/memberType` 业务字段值保留 `'child'`,仅注释改为"非登录用户"。
+
+**技术栈:** Java 8 / Spring Boot 2.7.18 / MyBatis-Plus / uni-app Vue2 小程序 / MySQL
+
+---
+
+## 涉及文件清单(按职责分组)
+
+| 文件 | 职责 |
+|------|------|
+| `cfc-backend/.../entity/FamilyMember.java` | 成员实体,新增 `memberType` 相关辅助方法 |
+| `cfc-backend/.../entity/User.java` | 用户实体(已有 openid,无需改动,仅确认) |
+| `cfc-backend/.../dto/FamilyMemberVO.java` | 成员 VO,新增 `canLogin` / `isSwitchable` 字段 |
+| `cfc-backend/.../dto/SwitchMemberVO.java` | 切换 VO,新增 `isSwitchable` 标记 |
+| `cfc-backend/.../service/FamilyMemberService.java` | 核心:`computeEffectiveRole` 迁移为 `computeMemberType`,`switchToMember` 加"仅可切换不可登录成员"校验,`addMember` 创建 user 记录 |
+| `cfc-backend/.../service/UserService.java` | 核心:登录/注册时复用无 openid 的 user、切换密码校验、`setSwitchPassword` |
+| `cfc-backend/.../service/api/UserServiceInterface.java` | 接口同步 |
+| `cfc-backend/.../controller/auth/AuthController.java` | 新增 `set-switch-password`、`switch-back-verify` 恢复 |
+| `cfc-backend/.../controller/family/FamilyMembersController.java` | 切换接口校验、退出切换接口 |
+| `cfc-backend/.../config/DatabaseInitializer.java` | 迁移:family_members 增加 `member_type` 辅助索引、注释同步 |
+| `cfc-backend/src/main/resources/schema.sql` | 同步表注释 |
+| `cfc-frontend/store/index.js` | 移除 child 切换状态,统一 currentMemberId/memberType |
+| `cfc-frontend/components/FamilyMemberStrip.vue` | `canSwitch` 反转:仅不可登录成员可切换 |
+| `cfc-frontend/utils/api.js` | 新增密码相关封装 |
+| `cfc-frontend/pages/...`(child 视图迁移) | 逐页替换 child 语义判断 |
+
+---
+
+## 阶段 A:后端成员类型模型
+
+### 任务 A1:FamilyMember 实体新增成员类型辅助
+
+**文件:** 修改 `cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyMember.java`
+
+- [ ] **步骤 1:新增 `memberType` 计算辅助方法**
+
+在 `FamilyMember.java` 中新增两个方法(紧邻现有 `getAge()` 之后):
+
+```java
+/**
+ * 成员类型: login=可登录成员(有openid) / local=不可登录成员(用户创建,无openid)
+ * 判定依据: userId 关联的 User 是否有 openid
+ * 注意: 该字段为 VO 层计算值,不落库;由 service 层传入 userId 对应的 openid 状态后设置
+ */
+// 表驱动字段(冗余,由 service 维护)
+private String memberType;
+
+/**
+ * 是否可被切换(不可登录成员才可被切换)
+ */
+public boolean isSwitchable(String userOpenid) {
+    // userId 为空 或 user 无 openid → 不可登录 → 可切换
+    return userOpenid == null || userOpenid.isEmpty();
+}
+```
+
+> **说明:** 判定逻辑放在 service 层(需要 join users 表),实体仅保留 VO 序列化字段 `memberType`。`isSwitchable` 接受 openid 参数,由 service 注入。
+
+- [ ] **步骤 2:编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS
+
+- [ ] **步骤 3:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyMember.java
+git commit -m "feat(family): FamilyMember 新增 memberType 成员类型字段与可切换判定"
+```
+
+### 任务 A2:FamilyMemberVO 增加成员类型字段
+
+**文件:** 修改 `cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyMemberVO.java`
+
+- [ ] **步骤 1:新增字段**
+
+```java
+/** 成员类型: login=可登录成员 / local=不可登录成员(用户创建,无 openid) */
+private String memberType;
+
+/** 是否可被切换视角(仅不可登录成员 true) */
+private Boolean isSwitchable;
+```
+
+- [ ] **步骤 2:编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS
+
+### 任务 A3:switchToMember 核心重构
+
+**文件:** 修改 `cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java` 的 `switchToMember`(约 298 行)
+
+- [ ] **步骤 1:重写 switchToMember 加"仅可切换不可登录成员"校验**
+
+```java
+public SwitchMemberVO switchToMember(Long userId, Long memberId) {
+    User user = userMapper.selectById(userId);
+    if (user == null || user.getFamilyId() == null) {
+        throw new RuntimeException("用户未加入家庭");
+    }
+
+    // 从 family_members 查找
+    FamilyMember member = familyMemberMapper.selectById(memberId);
+    if (member != null && member.getFamilyId().equals(user.getFamilyId())) {
+        // 【重构】成员切换校验:仅不可登录成员可被切换
+        User memberUser = member.getUserId() != null ? userMapper.selectById(member.getUserId()) : null;
+        String openid = memberUser != null ? memberUser.getOpenid() : null;
+        boolean canLogin = openid != null && !openid.isEmpty();
+        if (canLogin) {
+            throw new RuntimeException("该成员可通过微信登录,不能切换为TA的身份");
+        }
+
+        SwitchMemberVO vo = new SwitchMemberVO();
+        vo.setMemberId(member.getId());
+        vo.setSource("family_member");
+        vo.setEffectiveRole(computeEffectiveRole(member));
+        vo.setNickname(member.getNickname());
+        vo.setIsSwitchable(true);
+        return vo;
+    }
+
+    throw new RuntimeException("成员不存在或不属于您的家庭");
+}
+```
+
+- [ ] **步骤 2:SwitchMemberVO 增加 isSwitchable 字段**
+
+`cfc-backend/src/main/java/com/etotem/cfc/dto/SwitchMemberVO.java` 新增:
+
+```java
+/** 是否可被切换(服务端已校验,仅不可登录成员返回 true) */
+private Boolean isSwitchable;
+```
+
+- [ ] **步骤 3:编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS
+
+- [ ] **步骤 4:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java cfc-backend/src/main/java/com/etotem/cfc/dto/SwitchMemberVO.java
+git commit -m "feat(family): switchToMember 仅允许切换不可登录成员"
+```
+
+### 任务 A4:addMember 创建 user 记录
+
+**文件:** 修改 `cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java` 的 `addMember`(约 70 行)
+
+- [ ] **步骤 1:成员创建时同步创建 user 记录(无 openid)**
+
+在 `addMember` 中,手机号匹配逻辑(约 130-143 行)替换为:
+
+```java
+// 【重构】成员创建:同步创建 user 记录(无 openid = 不可登录)
+// 1) 若填写了手机号且已存在同手机号用户 → 复用该 user(绑定 userId)
+// 2) 若填写了手机号但不存在 → 创建无 openid 的 user,预留未来注册复用
+// 3) 未填写手机号 → 创建无 openid 的 user
+Long memberUserId = null;
+if (dto.getPhone() != null && !dto.getPhone().isEmpty()) {
+    User matchedUser = userMapper.selectOne(
+            new LambdaQueryWrapper<User>().eq(User::getPhone, dto.getPhone()));
+    if (matchedUser != null) {
+        memberUserId = matchedUser.getId();
+        // 将匹配到的用户加入同一家庭(如果尚未加入)
+        if (matchedUser.getFamilyId() == null) {
+            matchedUser.setFamilyId(familyId);
+            matchedUser.setUpdatedAt(new Date());
+            userMapper.updateById(matchedUser);
+        }
+    } else {
+        // 创建预留用户(无 openid,后续同手机号注册时复用)
+        User newUser = new User();
+        newUser.setOpenid("");
+        newUser.setUnionid("");
+        newUser.setRole("parent"); // 无 child 角色,统一 parent 兜底(实际不可登录)
+        newUser.setNickname(dto.getNickname().trim());
+        newUser.setPhone(dto.getPhone());
+        newUser.setFamilyId(familyId);
+        newUser.setPassword(""); // 初始密码为空,切换保护密码后续由用户设置
+        newUser.setCreatedAt(new Date());
+        newUser.setUpdatedAt(new Date());
+        userMapper.insert(newUser);
+        memberUserId = newUser.getId();
+    }
+} else {
+    // 未填手机号:创建无 openid 的 user(昵称必须非空)
+    User newUser = new User();
+    newUser.setOpenid("");
+    newUser.setUnionid("");
+    newUser.setRole("parent");
+    newUser.setNickname(dto.getNickname().trim());
+    newUser.setFamilyId(familyId);
+    newUser.setPassword("");
+    newUser.setCreatedAt(new Date());
+    newUser.setUpdatedAt(new Date());
+    userMapper.insert(newUser);
+    memberUserId = newUser.getId();
+}
+member.setUserId(memberUserId);
+```
+
+- [ ] **步骤 2:编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS
+
+- [ ] **步骤 3:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java
+git commit -m "feat(family): addMember 同步创建无 openid 的 user 记录(不可登录成员)"
+```
+
+### 任务 A5:登录/注册复用无 openid 用户
+
+**文件:** 修改 `cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java`
+
+- [ ] **步骤 1:微信/手机登录时优先匹配无 openid 的预留用户**
+
+在 `phoneLogin`、`wechatPhoneLogin` 等登录方法中,手机号匹配用户时补充:若匹配到 `openid` 为空的用户(预留成员),则直接给该 user 补上 openid 并沿用其 familyId:
+
+```java
+// 在登录方法中,查找到 matchedUser 后补充:
+if (matchedUser.getOpenid() == null || matchedUser.getOpenid().isEmpty()) {
+    // 预留成员首次登录:补 openid,成为可登录成员
+    matchedUser.setOpenid(dto.getOpenid() != null ? dto.getOpenid() : "phone:" + dto.getPhone());
+    matchedUser.setRole("parent");
+    matchedUser.setUpdatedAt(new Date());
+    userMapper.updateById(matchedUser);
+}
+```
+
+> **注意:** 具体改哪个方法取决于现有登录实现,先检查 `phoneLogin` / `wechatPhoneLogin` / `silentLogin` / `autoLoginByOpenid` 的当前匹配逻辑再落点。
+
+- [ ] **步骤 2:编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS
+
+- [ ] **步骤 3:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java
+git commit -m "feat(auth): 登录时复用无 openid 的预留成员用户并补充 openid"
+```
+
+---
+
+## 阶段 B:切换密码保护
+
+### 任务 B1:切换密码设置接口
+
+**文件:** 修改 `cfc-backend/src/main/java/com/etotem/cfc/controller/auth/AuthController.java`、`cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java`
+
+- [ ] **步骤 1:新增 setSwitchPassword 服务方法**
+
+`UserService.java` 新增:
+
+```java
+/**
+ * 设置切换保护密码(切换前由原用户设置,退出切换时校验)
+ * @param userId 设置密码的用户(原用户)
+ * @param password 保护密码
+ */
+public boolean setSwitchPassword(Long userId, String password) {
+    User user = userMapper.selectById(userId);
+    if (user == null) {
+        throw new RuntimeException("用户不存在");
+    }
+    user.setPassword(encryptPassword(password));
+    user.setUpdatedAt(new Date());
+    userMapper.updateById(user);
+    return true;
+}
+```
+
+- [ ] **步骤 2:新增 set-switch-password 接口**
+
+`AuthController.java` 新增:
+
+```java
+@Operation(summary = "设置切换保护密码")
+@PostMapping("/set-switch-password")
+public Result<Boolean> setSwitchPassword(@RequestBody SetPasswordDTO dto,
+                                         @RequestAttribute("userId") Long userId) {
+    boolean success = userService.setSwitchPassword(userId, dto.getPassword());
+    return Result.success(success);
+}
+```
+
+> 复用现有 `SetPasswordDTO`(含 password 字段)。`setPassword` 与 `setSwitchPassword` 语义相同(都写 users.password),可考虑合并,但保留独立接口便于前端语义区分。
+
+- [ ] **步骤 3:编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS
+
+- [ ] **步骤 4:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/auth/AuthController.java cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java
+git commit -m "feat(auth): 新增切换保护密码设置接口"
+```
+
+### 任务 B2:退出切换接口恢复(带密码校验)
+
+**文件:** 修改 `cfc-backend/src/main/java/com/etotem/cfc/controller/family/FamilyUserController.java`
+
+- [ ] **步骤 1:恢复 switch-back-verify 接口(已废弃的 410 改为有效实现)**
+
+`FamilyUserController.java` 中约 136 行的废弃方法替换为:
+
+```java
+@Operation(summary = "带密码校验退出成员切换(返回原用户身份)")
+@PostMapping("/switch-back-verify")
+public Result<Boolean> switchBackVerify(@RequestAttribute("userId") Long userId,
+    @RequestBody Map<String, String> body) {
+    String password = body != null ? body.get("password") : null;
+    if (password == null || password.isEmpty()) {
+        return Result.error("请输入密码");
+    }
+    // 校验当前用户密码(切换操作由原用户发起,password 存在 users.password)
+    boolean valid = userService.verifyPassword(userId, password);
+    if (!valid) {
+        return Result.error("密码错误");
+    }
+    return Result.success(true);
+}
+```
+
+> **关键:** 退出切换的语义是"返回切换前的用户"。前端切换时保存原用户 token 与密码,退出时用当前 JWT 的 userId 校验原用户密码(因为切换不换 token,JWT 仍是原用户的)。
+
+- [ ] **步骤 2:编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS
+
+- [ ] **步骤 3:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/family/FamilyUserController.java
+git commit -m "feat(family): 恢复退出切换接口并接入密码校验"
+```
+
+---
+
+## 阶段 C:前端成员切换视图
+
+### 任务 C1:store 移除 child 语义
+
+**文件:** 修改 `cfc-frontend/store/index.js`
+
+- [ ] **步骤 1:移除 isSwitchedChild/currentChildId child 切换状态**
+
+将 `switchToMember` mutation 中 child 分支移除(约 90-102 行),统一为 member 语义:
+
+```js
+// 通过family_members切换到某个成员
+switchToMember(state, payload) {
+  state.currentRole = payload.effectiveRole
+  state.currentMemberId = payload.memberId
+  state.currentMemberRole = payload.effectiveRole
+  state.isSwitchedMember = true
+  state.currentView = 'member'
+  uni.setStorageSync('currentRole', payload.effectiveRole)
+  uni.setStorageSync('currentMemberId', payload.memberId)
+  uni.setStorageSync('currentMemberRole', payload.effectiveRole)
+  uni.setStorageSync('isSwitchedMember', true)
+  uni.setStorageSync('currentView', 'member')
+}
+```
+
+> `isSwitchedChild` / `currentChildId` / `setCurrentChild` / `switchToChild` 保留为兼容别名(仍有页面引用),但内部委托 member 语义。`switchToChildWithCheck` action 中的"家庭中暂无孩子"检查改为"家庭中无可切换成员"。
+
+- [ ] **步骤 2:退出切换增加密码确认(前端拦截)**
+
+在 `switchBackFromMember` action 层(非 mutation)增加密码校验调用:
+
+```js
+// store actions 中新增
+async switchBackWithPassword({ commit, state }, password) {
+  if (!password) {
+    uni.showToast({ title: '请输入原用户密码', icon: 'none' })
+    return false
+  }
+  try {
+    const res = await switchBackVerify(password)
+    if (res.code === 0) {
+      commit('switchBackFromMember')
+      return true
+    }
+    uni.showToast({ title: res.message || '密码错误', icon: 'none' })
+    return false
+  } catch (e) {
+    uni.showToast({ title: '校验失败', icon: 'none' })
+    return false
+  }
+}
+```
+
+> 需在 store 引入 `switchBackVerify`(来自 utils/api.js)。若 store 不直接调 API,则在页面组件中调用校验后再 commit。
+
+- [ ] **步骤 3:Commit**
+
+```bash
+git add cfc-frontend/store/index.js
+git commit -m "refactor(store): 切换视图统一 member 语义,退出切换接入密码校验"
+```
+
+### 任务 C2:FamilyMemberStrip canSwitch 反转
+
+**文件:** 修改 `cfc-frontend/components/FamilyMemberStrip.vue`
+
+- [ ] **步骤 1:反转 canSwitch 逻辑**
+
+现有(约 172 行):
+
+```js
+return this.actionMember && this.actionMember.userId && this.actionMember.source === 'family_member'
+```
+
+改为(仅不可登录成员可切换):
+
+```js
+// 仅不可登录成员(无 openid 绑定、非本人)可切换视角
+return this.actionMember && !this.actionMember.isSelf &&
+  this.actionMember.source === 'family_member' &&
+  this.actionMember.isSwitchable
+```
+
+- [ ] **步骤 2:Commit**
+
+```bash
+git add cfc-frontend/components/FamilyMemberStrip.vue
+git commit -m "refactor(component): FamilyMemberStrip 切换入口仅对不可登录成员开放"
+```
+
+### 任务 C3:切换入口页接入密码设置引导
+
+**文件:** 修改切换入口所在页面(切换按钮点击处)
+
+- [ ] **步骤 1:切换前引导设置保护密码**
+
+在用户点击"切换到该成员视角"时,若当前用户未设置过密码,弹窗提示设置:
+
+```js
+// 切换前检查:进入前提示"切换后需原用户密码才能退出"
+switchMember: function(member) {
+  var self = this
+  uni.showModal({
+    title: '切换确认',
+    content: '切换到"' + member.nickname + '"后,退出时需要输入您的密码。是否继续?',
+    success: function(res) {
+      if (res.confirm) {
+        self.$store.dispatch('switchToMemberWithCheck', {
+          memberId: member.id,
+          effectiveRole: member.effectiveRole,
+          member: member
+        })
+      }
+    }
+  })
+}
+```
+
+- [ ] **步骤 2:Commit**
+
+```bash
+git add cfc-frontend/pages/...(实际页面路径)
+git commit -m "feat(frontend): 切换成员前确认提示,明确退出需密码"
+```
+
+---
+
+## 阶段 D:业务字段注释迁移
+
+### 任务 D1:executorType/creatorType/memberType 注释更新
+
+**文件:** 所有含 `executorType="child"`、`creatorType="child"`、`memberType="child"` 的文件
+
+- [ ] **步骤 1:批量注释更新(不改字段值)**
+
+```bash
+# 后端:搜索所有 "child" 业务字段赋值处,补充注释
+grep -rn '"child"' cfc-backend/src/main/java/com/etotem/cfc/service/ | grep -E 'executorType|creatorType|memberType'
+```
+
+对每处赋值,在代码上方或行内补充注释:
+
+```java
+task.setExecutorType("child"); // 非登录用户(不可登录成员)执行的任务
+```
+
+- [ ] **步骤 2:schema.sql 注释同步**
+
+`tasks` 表 `executor_type` 字段注释由 `执行者类型: child/parent/member` 改为 `执行者类型: child=非登录用户/parent=可登录家长/member=其他成员`。
+
+- [ ] **步骤 3:Commit**
+
+```bash
+git add -u cfc-backend/src/main/java/ cfc-backend/src/main/resources/schema.sql
+git commit -m "docs: child 业务字段注释统一为『非登录用户』语义"
+```
+
+### 任务 D2:移除 role='child' 角色判断
+
+**文件:** 后端含 `"child".equals(role)` / `role="child"` 的 31 个文件
+
+- [ ] **步骤 1:逐一评估 role='child' 判断**
+
+```bash
+grep -rn '"child"' cfc-backend/src/main/java/com/etotem/cfc/controller/ cfc-backend/src/main/java/com/etotem/cfc/service/ | grep -iE 'role|getRole'
+```
+
+主要判断点:
+- `UserService.isValidRole`(696 行):保留 `"child"` 在合法角色列表(历史数据兼容),但注释说明"child 角色已废弃,仅历史账号"
+- `WishController`(38/204 行)、`FamilyChallengeController`(41 行)、`EmotionCheckinController`(60/71 行)、`ProductOrderService`(737 行)、`FamilyHealthScoreService`(48 行):这些是 `@RequestAttribute("role")` 判断,切换后 effectiveRole 可能为 child——**保留判断**(切换成员后仍需按视角控制),但注释更新
+- `DataMigrationService`(292-293 行):迁移脚本,保留
+- `InviteCardService`(147/154/166 行):邀请卡创建 child 账号,改为创建无 openid 用户
+
+- [ ] **步骤 2:InviteCardService 改造(创建 child 账号 → 创建不可登录成员)**
+
+`InviteCardService.java` 中 `user.setRole("child")` 改为创建无 openid 用户 + family_members 记录。
+
+- [ ] **步骤 3:编译验证 + Commit**
+
+```bash
+cd cfc-backend && mvn clean compile
+git add -u cfc-backend/src/main/java/
+git commit -m "refactor: role=child 角色判断注释更新,InviteCard 改为创建不可登录成员"
+```
+
+---
+
+## 阶段 E:children 表下线 + 存量 child 账号强制迁移
+
+### 任务 E0:存量 role='child' 账号强制迁移
+
+**文件:** 修改 `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`
+
+- [ ] **步骤 1:新增幂等迁移(迁移编号递增)**
+
+在 `runMigrations()` 末尾新增:
+
+```java
+// 迁移N: 存量 role='child' 账号强制迁移为成员模型
+// 1) 有 openid 的 child → role 改 parent(可登录成员,登录后进成员端,不能被切换)
+// 2) 无 openid 的 child → 保持不可登录,确保有 family_members 记录(仅可被切换)
+try {
+    // 有 openid 的 child 账号:role 归一为 parent
+    jdbcTemplate.execute("UPDATE users SET role='parent' WHERE role='child' AND openid IS NOT NULL AND openid <> ''");
+    log.info("已迁移有 openid 的 child 账号 role → parent");
+} catch (Exception e) {
+    log.warn("迁移有 openid 的 child 账号失败", e);
+}
+try {
+    // 无 openid 的 child 账号:确保 family_members 有对应记录(不可登录成员)
+    jdbcTemplate.execute(
+        "INSERT INTO family_members (family_id, user_id, nickname, gender, birthday, generation, show_to_family, total_points, created_at, updated_at) " +
+        "SELECT u.family_id, u.id, u.nickname, u.gender, u.birthday, -1, 1, 0, NOW(), NOW() " +
+        "FROM users u " +
+        "WHERE u.role='child' AND (u.openid IS NULL OR u.openid='') " +
+        "AND u.family_id IS NOT NULL " +
+        "AND NOT EXISTS (SELECT 1 FROM family_members fm WHERE fm.user_id = u.id)");
+    log.info("已为无 openid 的 child 账号补齐 family_members 记录");
+} catch (Exception e) {
+    log.warn("补齐无 openid child 账号的 family_members 记录失败", e);
+}
+```
+
+- [ ] **步骤 2:同步 schema.sql 注释**
+
+`users.role` 字段注释由 `parent/child/teacher` 改为 `parent/teacher`(child 已废弃,仅历史数据)。
+
+- [ ] **步骤 3:编译验证 + Commit**
+
+```bash
+cd cfc-backend && mvn clean compile
+git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java cfc-backend/src/main/resources/schema.sql
+git commit -m "feat(migration): 存量 role=child 账号强制迁移为成员模型"
+```
+
+### 任务 E1:确认 children 表数据已迁移
+
+- [ ] **步骤 1:检查 schema.sql 中 children 迁移脚本**
+
+`schema.sql` 中已有"迁移 children → family_members"的脚本(2498-2533 行),确认生产库已执行。若未执行,通过 `DatabaseInitializer` 补充幂等迁移。
+
+### 任务 E2:ChildMapper/Child 实体弃用
+
+- [ ] **步骤 1:检查 ChildMapper / Child 引用**
+
+```bash
+grep -rn 'ChildMapper\|childMapper' cfc-backend/src/main/java/ | grep -v 'ChildBadge'
+```
+
+将仍引用 `ChildMapper` 的业务代码迁移到 `FamilyMemberMapper`(`children` 表字段已合并到 `family_members`)。
+
+- [ ] **步骤 2:Child 实体标注 @Deprecated**
+
+`Child.java` 类注释标注废弃,保留以兼容引用。编译验证通过后提交。
+
+```bash
+cd cfc-backend && mvn clean compile
+git commit -m "refactor: children 表/Child 实体标记废弃,引用迁移至 family_members"
+```
+
+---
+
+## 阶段 F:前端 child 页面/语义迁移
+
+### 任务 F1:child-index 页面迁移
+
+- [ ] **步骤 1:检查 pages/home-pages/child-index.vue 与 pages/index/index 路由**
+
+`pages/index/index` 根据 `role` 分流到 `parent-index` / `child-index`。登录后 `role='child'` 的账号不再存在(新模型无 child 登录账号),`child-index` 改为"切换后的成员视图" `member-home`(或由 currentView='member' 判断跳转)。
+
+- [ ] **步骤 2:Commit**
+
+### 任务 F2:各页面 child 语义判断替换
+
+- [ ] **步骤 1:批量替换前端 `effectiveRole === 'child'` 判断**
+
+```bash
+grep -rln "effectiveRole === 'child'\|effectiveRole == 'child'\|'child' === .*effectiveRole" cfc-frontend/
+```
+
+逐页确认语义:
+- 用于"过滤可切换成员" → 改为 `m.isSwitchable`
+- 用于"孩子视角 UI" → 改为 `currentView === 'member'` 或保留(切换成员后仍显示成员视角)
+
+- [ ] **步骤 2:Commit**
+
+---
+
+## 自检清单
+
+**规格覆盖度:**
+- [x] 成员类型判定(openid 是否为空)→ 任务 A3/A4/A5
+- [x] 创建的成员也创建 user 记录 → 任务 A4
+- [x] 手机号相同新注册复用 user → 任务 A5
+- [x] 仅不可登录成员可切换 → 任务 A3 + C2
+- [x] 退出切换需原用户密码 → 任务 B1/B2 + C1
+- [x] 切换前可设置密码 → 任务 B1 + C3
+- [x] 业务字段 child 保留但注释改"非登录用户" → 任务 D1
+- [x] 清理 role=child 角色判断 → 任务 D2
+- [x] children 表下线 → 阶段 E
+- [x] 前端 child 视图迁移 → 阶段 F
+
+**占位符扫描:** 无 TODO/待定,所有步骤含具体代码或命令。
+
+**类型一致性:** `memberType` 字段名统一(VO/实体均为 memberType),`isSwitchable` 统一命名。
+
+---
+
+## 风险与注意事项
+
+1. **存量 role='child' 账号**:已按用户决策强制迁移——有 openid 的改 role=parent(可登录成员,登录后进成员端,不能被切换);无 openid 的补齐 family_members 记录(不可登录成员,仅可被切换)。迁移幂等,可重复执行。
+2. **users.password 语义**:切换密码未单独设置时默认用登录密码(复用 `users.password`),不新增独立字段。`set-password` 与 `set-switch-password` 写同一字段。
+3. **InviteCardService** 创建 child 账号逻辑需先确认其调用场景(邀请卡可能用于邀请真实用户,而非创建不可登录成员),改造前需验证。
+4. **前端 store 兼容**:`isSwitchedChild` / `currentChildId` 保留为兼容别名,避免一次性破坏所有页面;后续迭代逐步移除。