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

docs: 新用户注册引导+个人信息按需补充/回写实施计划

Xiaogang Liao 3 месяцев назад
Родитель
Сommit
e6591a6088
1 измененных файлов с 1037 добавлено и 0 удалено
  1. 1037 0
      docs/superpowers/plans/2026-08-04-registration-profile-backfill.md

+ 1037 - 0
docs/superpowers/plans/2026-08-04-registration-profile-backfill.md

@@ -0,0 +1,1037 @@
+# 实施计划:新用户注册引导 + 个人信息按需补充与回写
+
+- **日期**: 2026-08-04
+- **依据**: `docs/superpowers/specs/2026-08-04-registration-profile-backfill-design.md`(3 个默认值按推荐值执行:统一 backfill 接口 / 外部报名人不回写 / 14 字段口径)
+- **状态**: 待实施
+- **验证基线**: 后端 `mvn clean compile`(在 `cfc-backend` 下);前端 `npm run build:mp-weixin`(在 `cfc-frontend` 下)
+
+## 0. 总览
+
+新用户注册不再自动创建家庭(`familyId=null`),注册引导只收集头像+昵称(可跳过);个人中心新增「已完善 X/14 字段」卡片;功能场景(试点:活动报名)实现「选择用户 → 预填 → 就地补充 → 自动回写」,新增统一 backfill 接口(后端字段白名单)。
+
+- 迁移:`users.family_id` 允许 NULL(迁移 155)
+- 后端:5 条注册路径删除建家庭逻辑;`LoginResultDTO` 新增 `needsFamily`;新增 `POST /api/user/profile/backfill`
+- 前端:`user-edit.vue` 新用户流程最小化;`profile.vue` 补充卡片;`utils/profile-field-usage.js` + `utils/profile-backfill.js`;活动报名接入回写
+- P2:无家庭引导组件 `components/family-guard.vue` 接入 3 个核心页
+
+## 1. 关键参考代码(实施前必读)
+
+| 参考 | 路径 | 用途 |
+|------|------|------|
+| 迁移模式 | `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`(末尾迁移154块) | 新增迁移155模板 |
+| 注册路径 | `cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java` `wechatLogin`(L71-168)/`phoneLogin`(L173-247)/`directRegister`(L382-430)/`registerWithIdCard`(L742-809)/`registerWithInviteCode`(L1181+) | 删除建家庭逻辑 |
+| 懒建家庭 | 同上 `ensureUserHasFamily`(L922-938) + 调用点 L143/L227/L289/L325 | 删除(连同调用点) |
+| 登录响应装配 | 同上各 login 方法末尾 `LoginResultDTO result = new LoginResultDTO()` | 加 `needsFamily` |
+| birthday 解析 | 同上 `updateUserInfo`(L962) `SimpleDateFormat("yyyy-MM-dd")` | backfill 复用同款解析 |
+| 实体字段 | `cfc-backend/src/main/java/com/etotem/cfc/entity/User.java`(birthHour L53/gender L55/dietPreferences L87/address L79/phone L43)、`FamilyMember.java`(phone L30/gender L36/birthday L39/birthHour L42) | 白名单字段 | 
+| 控制器 | `cfc-backend/src/main/java/com/etotem/cfc/controller/UserController.java`(`/api/user`,L66 `/info`、L77 `/update`) | 新增 `/profile/backfill` |
+| schema.sql | `cfc-backend/src/main/resources/schema.sql` L19 `family_id BIGINT NOT NULL` | 改为 `BIGINT NULL` |
+| 前端表单 | `cfc-frontend/pages/user-edit/user-edit.vue`(家庭绑定区 L212-233、`saveUserInfo` L463、`skip` L635) | 新用户流程最小化 |
+| 个人中心 | `cfc-frontend/pages/profile/profile.vue`(L17 ProfileHeader + `@avatar-click="goToEditInfo"`,**goToEditInfo 方法缺失需补**) | 加补充卡片 |
+| 活动报名 | `cfc-frontend/pages/activity/activity-detail/activity-detail.vue`(`loadFamilyMembers` L570-602、`toggleFamilyMember` L609-622、`addManualEntry` L623-630、`submitRegistration` L671、`doCreateOrder` L708) | 接入回写 |
+| API 封装 | `cfc-frontend/utils/api.js`(L252 getUserInfo、L264 createFamily、L269 getFamilyMembers) | 加 backfillProfile |
+| 无家庭页 | `cfc-frontend/pages/wealth-sub/insurance-planning.vue`、`cfc-frontend/pages/profile/components/ProfileMenu.vue`、`cfc-frontend/pages/growth/index.vue`(均有「请先加入家庭」) | P2 接入引导 |
+
+**项目约定(必须遵守)**:
+- 接口一律 `@PostMapping`;DI 用 `@Resource`;响应统一 `Result<T>`;实体 `@TableName` + `@TableId(type = IdType.AUTO)` + `@Data`
+- 迁移编号递增:本计划新增为 **迁移155**(当前最大 154)
+- 迁移幂等:try-catch 包裹,注释 `// 迁移155: ...`
+- schema.sql 必须与 DatabaseInitializer 同步
+- 前端禁止可选链 `?.`、禁止 `:key` 表达式、禁止 CSS Grid、禁止 `new Date(string)`(用 `parseDate()`)
+- 提交信息风格 `feat: 注册引导-xxx`
+- 后端验证以 `mvn clean compile` 为准(测试目录有 ~166 个预先存在失败,**不要**尝试修复,也不要把本次功能写进现有测试套件)
+
+---
+
+## 任务 1:数据库迁移 155 + schema.sql 同步
+
+### 1.1 修改 `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`
+
+在 `runMigrations()` 中**最后一个迁移块(迁移154)之后、方法闭合 `}` 之前**追加:
+
+```java
+        // 迁移155: users.family_id 允许 NULL(注册不再自动创建家庭)
+        try {
+            jdbcTemplate.execute("ALTER TABLE users MODIFY COLUMN family_id BIGINT NULL COMMENT '所属家庭ID(注册时可为空,首次使用家庭功能时创建/加入)'");
+            log.info("已修改users.family_id允许NULL");
+        } catch (Exception e) {
+            // 忽略
+        }
+```
+
+### 1.2 同步 `cfc-backend/src/main/resources/schema.sql`
+
+在 `users` 表定义中把(L19):
+
+```sql
+family_id BIGINT NOT NULL,
+```
+
+改为:
+
+```sql
+family_id BIGINT NULL COMMENT '所属家庭ID(注册时可为空,首次使用家庭功能时创建/加入)',
+```
+
+**验证**: `mvn clean compile`(在 `cfc-backend`)退出码 0。
+**提交**: `feat: 注册引导-迁移155 users.family_id可空`
+
+---
+
+## 任务 2:LoginResultDTO 新增 needsFamily
+
+### 2.1 修改 `cfc-backend/src/main/java/com/etotem/cfc/dto/LoginResultDTO.java`
+
+在 `needsRoleSelect` 字段之后(L16 后)追加:
+
+```java
+    private Boolean needsFamily; // familyId 为空或 0 时为 true,前端据此决定是否引导建家庭
+```
+
+### 2.2 修改 `cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java` 各登录方法
+
+在每个 `LoginResultDTO result = new LoginResultDTO();` 装配块中,于 `result.setNeedsRoleSelect(...)`(无则放 `result.setNickname(...)` 之后)追加:
+
+```java
+        result.setNeedsFamily(user.getFamilyId() == null || user.getFamilyId() == 0L);
+```
+
+**涉及装配点(8 处)**:
+1. `wechatLogin`(~L153-167)
+2. `phoneLogin`(~L235-246)
+3. `silentLogin`(~L294-305)
+4. `autoLoginByOpenid`(~L329-339)
+5. `directRegister`(~L419-429)
+6. `registerWithIdCard`(~L800-808)
+7. `registerWithInviteCode` 分销分支(~L1212-1221)
+8. `registerWithInviteCode` 普通分支(~L1287-1292)
+
+**验证**: `mvn clean compile` 退出码 0。
+**提交**: `feat: 注册引导-登录响应新增needsFamily标记`
+
+---
+
+## 任务 3:注册路径删除建家庭逻辑(核心解耦)
+
+修改 `cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java`。
+
+### 3.1 `wechatLogin`(L103-108 + L113)
+
+删除新用户分支中的建家庭代码块:
+
+```java
+                Family family = new Family();
+                family.setName("我的家庭");
+                family.setInviteCode(generateInviteCode());
+                family.setCreatedAt(new Date());
+                family.setUpdatedAt(new Date());
+                familyMapper.insert(family);
+```
+
+并把 L113 `user.setFamilyId(family.getId());` 删除(保持默认 null)。
+
+### 3.2 `phoneLogin`(L192-197 + L202)
+
+删除同样的建家庭代码块(L192-197)和 L202 `user.setFamilyId(family.getId());`。
+删除 L216-217 `family.setCreatorId(user.getId()); familyMapper.updateById(family);`(family 变量已不存在)。
+
+### 3.3 `directRegister`(L384-389 + L395)
+
+删除建家庭代码块(L384-389)和 L395 `user.setFamilyId(family.getId());`。
+保留 L408-414 邀请码加入家庭逻辑(`joinFamily(user.getId(), dto.getInviteCode())`)不变。
+
+### 3.4 `registerWithIdCard`(L775-780 + L785)
+
+**仅新用户分支**(`else` 块)删除建家庭代码块(L775-780)和 L785 `user.setFamilyId(family.getId());`。
+- L753 `Family family;` 声明删除
+- 老用户分支 L764 `family = familyMapper.selectById(user.getFamilyId());` 删除(变量已删)
+- L802 `result.setFamilyId(user.getFamilyId())` 保留(老用户有 familyId 时正常返回)
+
+### 3.5 `registerWithInviteCode` 分销分支(L1188-1194 + L1199)
+
+**仅 distResult 命中分支**删除建家庭代码块(L1189-1194)和 L1199 `user.setFamilyId(family.getId());`,但保留 `distributionService.registerByInviteCode(inviteCode, user.getId())` 与 token 装配。
+**普通邀请码分支(L1224+)不动**:该分支语义是「加入已有家庭」(`familyMapper.selectOne(inviteCode)`),新用户 `user.setFamilyId(family.getId())` 保留。
+
+### 3.6 删除 `ensureUserHasFamily` 方法及 4 个调用点
+
+- 删除方法定义(L917-938,含 javadoc 注释)
+- 删除调用点:L143(wechatLogin)、L227(phoneLogin else 分支)、L289(silentLogin)、L325(autoLoginByOpenid)
+
+**注意**: 调用点在 if/else 块内,删除时保持块结构完整(如 phoneLogin L219-230 的 else 块删除 L227 后仍保留 `user.setUpdatedAt` + `userMapper.updateById`)。
+
+**验证**: `mvn clean compile` 退出码 0。
+**提交**: `feat: 注册引导-注册路径不再自动创建家庭`
+
+---
+
+## 任务 4:统一 backfill 接口
+
+### 4.1 新建 `cfc-backend/src/main/java/com/etotem/cfc/dto/ProfileBackfillDTO.java`
+
+```java
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.util.Map;
+
+@Data
+public class ProfileBackfillDTO {
+    /** 回写目标类型: user=本人 / member=家庭成员 */
+    private String targetType;
+    /** targetType=member 时的 family_members.id;user 时可为空 */
+    private Long targetId;
+    /** 回写字段: { phone: "138...", birthday: "2015-06-01", ... } */
+    private Map<String, String> fields;
+}
+```
+
+### 4.2 修改 `cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java`
+
+新增常量与方法(放在 `getUserInfo` 方法 L363 之后):
+
+```java
+    /** 回写字段白名单(user 目标) */
+    private static final String[] USER_BACKFILL_WHITELIST = {"phone", "gender", "birthday", "birthHour", "dietPreferences", "address"};
+
+    /** 回写字段白名单(member 目标,family_members 表无 dietPreferences/address 列) */
+    private static final String[] MEMBER_BACKFILL_WHITELIST = {"phone", "gender", "birthday", "birthHour"};
+
+    /**
+     * 就地补充信息回写(字段白名单校验 + 目标归属校验)
+     * @return Map: { updatedFields: List&lt;String&gt;, profile: Object(最新完整信息) }
+     */
+    public Map<String, Object> backfillProfile(Long userId, ProfileBackfillDTO dto) {
+        if (dto == null || dto.getFields() == null || dto.getFields().isEmpty()) {
+            throw new RuntimeException("回写字段不能为空");
+        }
+        User me = userMapper.selectById(userId);
+        if (me == null) {
+            throw new RuntimeException("用户不存在");
+        }
+        List<String> updatedFields = new ArrayList<>();
+        Object profile;
+
+        if ("user".equals(dto.getTargetType())) {
+            // 仅允许回写自己
+            if (dto.getTargetId() != null && !dto.getTargetId().equals(userId)) {
+                throw new RuntimeException("仅能回写本人的个人信息");
+            }
+            for (String field : USER_BACKFILL_WHITELIST) {
+                String value = dto.getFields().get(field);
+                if (value == null || value.isEmpty()) continue;
+                applyUserBackfillField(me, field, value.trim());
+                updatedFields.add(field);
+            }
+            me.setUpdatedAt(new Date());
+            userMapper.updateById(me);
+            profile = me;
+        } else if ("member".equals(dto.getTargetType())) {
+            Long memberId = dto.getTargetId();
+            if (memberId == null) {
+                throw new RuntimeException("回写家庭成员时 targetId 不能为空");
+            }
+            FamilyMember member = familyMemberMapper.selectById(memberId);
+            if (member == null) {
+                throw new RuntimeException("家庭成员不存在");
+            }
+            // 归属校验:成员必须属于当前用户家庭
+            Long myFamilyId = me.getFamilyId();
+            if (myFamilyId == null || !myFamilyId.equals(member.getFamilyId())) {
+                throw new RuntimeException("该成员不属于您的家庭");
+            }
+            for (String field : MEMBER_BACKFILL_WHITELIST) {
+                String value = dto.getFields().get(field);
+                if (value == null || value.isEmpty()) continue;
+                applyMemberBackfillField(member, field, value.trim());
+                updatedFields.add(field);
+            }
+            familyMemberMapper.updateById(member);
+            profile = member;
+        } else {
+            throw new RuntimeException("targetType 仅支持 user/member");
+        }
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("updatedFields", updatedFields);
+        result.put("profile", profile);
+        return result;
+    }
+
+    private void applyUserBackfillField(User user, String field, String value) {
+        switch (field) {
+            case "phone":
+                if (!value.matches("^1[3-9]\\d{9}$")) throw new RuntimeException("手机号格式不正确");
+                user.setPhone(value);
+                break;
+            case "gender":
+                if (!"male".equals(value) && !"female".equals(value)) throw new RuntimeException("性别取值非法");
+                user.setGender(value);
+                break;
+            case "birthday":
+                try {
+                    java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd");
+                    user.setBirthday(sdf.parse(value));
+                } catch (Exception e) {
+                    throw new RuntimeException("生日格式不正确,应为 yyyy-MM-dd");
+                }
+                break;
+            case "birthHour":
+                if (!"子丑寅卯辰巳午未申酉戌亥".contains(value)) throw new RuntimeException("出生时辰取值非法");
+                user.setBirthHour(value);
+                break;
+            case "dietPreferences":
+                user.setDietPreferences(value);
+                break;
+            case "address":
+                user.setAddress(value);
+                break;
+            default:
+                break;
+        }
+    }
+
+    private void applyMemberBackfillField(FamilyMember member, String field, String value) {
+        switch (field) {
+            case "phone":
+                if (!value.matches("^1[3-9]\\d{9}$")) throw new RuntimeException("手机号格式不正确");
+                member.setPhone(value);
+                break;
+            case "gender":
+                if (!"male".equals(value) && !"female".equals(value)) throw new RuntimeException("性别取值非法");
+                member.setGender(value);
+                break;
+            case "birthday":
+                try {
+                    java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd");
+                    member.setBirthday(sdf.parse(value));
+                } catch (Exception e) {
+                    throw new RuntimeException("生日格式不正确,应为 yyyy-MM-dd");
+                }
+                break;
+            case "birthHour":
+                if (!"子丑寅卯辰巳午未申酉戌亥".contains(value)) throw new RuntimeException("出生时辰取值非法");
+                member.setBirthHour(value);
+                break;
+            default:
+                break;
+        }
+    }
+```
+
+> 确认 UserService 已 import `java.util.HashMap`/`java.util.Map`/`java.util.List`/`java.util.ArrayList`(文件已使用 Map/List)。
+
+### 4.3 修改 `cfc-backend/src/main/java/com/etotem/cfc/controller/UserController.java`
+
+在 `@PostMapping("/update")`(L105 之后)新增:
+
+```java
+    @PostMapping("/profile/backfill")
+    @Operation(summary = "就地补充信息回写(个人资料按需补充)")
+    public Result<Map<String, Object>> backfillProfile(HttpServletRequest request, @RequestBody ProfileBackfillDTO dto) {
+        Long userId = (Long) request.getAttribute("userId");
+        try {
+            Map<String, Object> result = userService.backfillProfile(userId, dto);
+            return Result.success(result);
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+```
+
+确认文件已 import `com.etotem.cfc.dto.ProfileBackfillDTO`、`java.util.Map`(文件已使用 Map)。
+
+**验证**: `mvn clean compile` 退出码 0。
+**提交**: `feat: 注册引导-新增统一backfill回写接口`
+
+---
+
+## 任务 5:前端 api.js 新增 backfillProfile
+
+修改 `cfc-frontend/utils/api.js`,在 `updateUserInfo`(L256-258)之后追加:
+
+```js
+// 就地补充信息回写(活动报名等场景按需补充后自动回写个人资料)
+export const backfillProfile = (data) => {
+	return request('/api/user/profile/backfill', 'POST', data)
+}
+```
+
+**验证**: `npm run build:mp-weixin` 通过。
+**提交**: `feat: 注册引导-前端api新增backfillProfile`
+
+---
+
+## 任务 6:新建 `cfc-frontend/utils/profile-field-usage.js`
+
+**新增文件**(完整内容):
+
+```js
+/**
+ * 个人信息使用关联表
+ * 记录个人信息的各个字段在哪些功能场景中被使用。
+ * 用途:
+ *  1. 功能页自查「本场景用到哪些字段、当前人是否缺失」
+ *  2. 回写时校验字段确实属于本场景(只从使用处回写)
+ *  3. 个人中心卡片统计已填字段数
+ */
+module.exports = {
+  phone: {
+    label: '手机号',
+    scenes: [
+      { scene: 'activity-signup', page: 'pages/activity/activity-detail', usage: '活动报名联系人手机号', backfillTarget: ['user', 'member'] },
+      { scene: 'shop-order', page: 'pages/shop/checkout', usage: '订单收货人手机号', backfillTarget: ['user'] },
+      { scene: 'assessment-order', page: 'pages/assessment/purchase', usage: '测评预约联系人手机号', backfillTarget: ['user'] }
+    ]
+  },
+  birthday: {
+    label: '生日',
+    scenes: [
+      { scene: 'assessment', page: 'pages/assessment/*', usage: '测评年龄计算', backfillTarget: ['user', 'member'] },
+      { scene: 'tianpan', page: 'pages/tianpan/*', usage: '天盘命理计算', backfillTarget: ['user', 'member'] },
+      { scene: 'health-report', page: 'pages/health/*', usage: '健康报告年龄相关', backfillTarget: ['member'] }
+    ]
+  },
+  gender: {
+    label: '性别',
+    scenes: [
+      { scene: 'assessment', page: 'pages/assessment/*', usage: '测评维度计算', backfillTarget: ['user', 'member'] },
+      { scene: 'tianpan', page: 'pages/tianpan/*', usage: '天盘计算', backfillTarget: ['user', 'member'] }
+    ]
+  },
+  birthHour: {
+    label: '出生时辰',
+    scenes: [
+      { scene: 'tianpan', page: 'pages/tianpan/*', usage: '天盘命理计算', backfillTarget: ['user', 'member'] }
+    ]
+  },
+  dietPreferences: {
+    label: '饮食偏好',
+    scenes: [
+      { scene: 'nutrition-recommend', page: 'pages/health/nutrition-profile', usage: '饮食推荐个性化', backfillTarget: ['user', 'member'] }
+    ]
+  },
+  address: {
+    label: '地址',
+    scenes: [
+      { scene: 'shop-order', page: 'pages/shop/checkout', usage: '收货地址', backfillTarget: ['user'] },
+      { scene: 'activity-signup', page: 'pages/activity/activity-detail', usage: '活动地点信息', backfillTarget: ['user'] }
+    ]
+  }
+}
+```
+
+**验证**: 文件可被 require(语法正确)。`npm run build:mp-weixin` 通过。
+**提交**: `feat: 注册引导-个人信息使用关联表profile-field-usage`
+
+---
+
+## 任务 7:新建 `cfc-frontend/utils/profile-backfill.js`
+
+**新增文件**(完整内容):
+
+```js
+import { backfillProfile as apiBackfill } from './api.js'
+const fieldUsage = require('./profile-field-usage.js')
+
+/**
+ * 就地补充 → 自动回写个人信息
+ * @param {Object} opts
+ *   targetType: 'user' | 'member'
+ *   targetId:   memberId(targetType=member 时)
+ *   fields:     { phone: '138...', birthday: '...' }
+ *   scene:      'activity-signup'(必传,校验字段是否属于本场景)
+ *   prevValues: { phone: '旧值' } 当前人已有值(用于「修改已有值」判定)
+ * @returns {Promise<Object>} { updatedFields: [], skippedFields: [] }
+ */
+export function backfillProfile(opts) {
+  return new Promise(function(resolve, reject) {
+    var scene = opts.scene
+    var fields = opts.fields || {}
+    var fieldNames = Object.keys(fields)
+    if (!scene || fieldNames.length === 0) {
+      resolve({ updatedFields: [], skippedFields: fieldNames })
+      return
+    }
+
+    // 1. 场景校验:只回写本场景登记过的字段
+    var allowedFields = {}
+    var skippedFields = []
+    for (var i = 0; i < fieldNames.length; i++) {
+      var name = fieldNames[i]
+      var usage = fieldUsage[name]
+      if (!usage) {
+        skippedFields.push(name)
+        continue
+      }
+      var inScene = false
+      var targetsOk = false
+      for (var j = 0; j < usage.scenes.length; j++) {
+        var sc = usage.scenes[j]
+        if (sc.scene === scene) {
+          inScene = true
+          if (sc.backfillTarget.indexOf(opts.targetType) >= 0) {
+            targetsOk = true
+          }
+        }
+      }
+      if (inScene && targetsOk) {
+        allowedFields[name] = fields[name]
+      } else {
+        skippedFields.push(name)
+      }
+    }
+
+    if (Object.keys(allowedFields).length === 0) {
+      resolve({ updatedFields: [], skippedFields: skippedFields })
+      return
+    }
+
+    // 2. 修改已有值 → 弹确认
+    var prev = opts.prevValues || {}
+    var changedNames = []
+    for (var k in allowedFields) {
+      var oldVal = prev[k]
+      var newVal = allowedFields[k]
+      if (oldVal && oldVal !== newVal && !(newVal === '' && oldVal === newVal)) {
+        changedNames.push(fieldUsage[k].label)
+      }
+    }
+
+    function doBackfill() {
+      apiBackfill({
+        targetType: opts.targetType,
+        targetId: opts.targetId || null,
+        fields: allowedFields
+      }).then(function(res) {
+        if (res && res.code === 200) {
+          // 3. 成功后更新本地缓存
+          var profile = (res.data && res.data.profile) || {}
+          if (opts.targetType === 'user') {
+            var userInfo = uni.getStorageSync('userInfo') || {}
+            for (var f in allowedFields) {
+              if (profile[f] !== undefined) {
+                userInfo[f] = profile[f]
+              }
+            }
+            uni.setStorageSync('userInfo', userInfo)
+          }
+          resolve({
+            updatedFields: (res.data && res.data.updatedFields) || [],
+            skippedFields: skippedFields
+          })
+        } else {
+          reject(new Error((res && res.message) || '回写失败'))
+        }
+      }).catch(function(err) {
+        reject(err)
+      })
+    }
+
+    if (changedNames.length > 0) {
+      uni.showModal({
+        title: '同步更新资料',
+        content: '检测到您修改了' + changedNames.join('、') + ',是否同步更新' + (opts.targetType === 'member' ? '该家人的' : '您的') + '个人信息?',
+        confirmText: '同步更新',
+        cancelText: '仅本次使用',
+        success: function(modalRes) {
+          if (modalRes.confirm) {
+            doBackfill()
+          } else {
+            resolve({ updatedFields: [], skippedFields: skippedFields })
+          }
+        }
+      })
+    } else {
+      doBackfill()
+    }
+  })
+}
+```
+
+**验证**: 语法正确;`npm run build:mp-weixin` 通过。
+**提交**: `feat: 注册引导-回写工具profile-backfill`
+
+---
+
+## 任务 8:user-edit.vue 新用户流程最小化
+
+修改 `cfc-frontend/pages/user-edit/user-edit.vue`。
+
+### 8.1 隐藏新用户流程的非最小字段
+
+在模板中,给「真实姓名」form-item(L64)所在的整段包裹一个容器并加 `v-if`。具体:在 L62 昵称 form-item 的 `</view>` 之后插入:
+
+```html
+			<!-- 新用户流程:仅展示头像+昵称,其余字段按需在功能页补充 -->
+			<view v-if="!isNewUserFlow">
+```
+
+并在「饮食偏好」form-item(L155)的 `</view>` 之后、`<!-- 成长规划师额外字段 -->`(L157)之前插入闭合标签 `</view>`。
+
+> 注意:`form.role === 'teacher'` 的字段块(L158-188)不在包裹内(teacher 新用户流程仍需展示规划师字段,且 `isNewUserFlow && form.role === 'teacher'` 时家庭绑定区已用 `form.role !== 'teacher'` 排除)。
+
+### 8.2 家庭绑定区改造(L212-233)
+
+把整个家庭绑定区(L212-233,含邀请码输入 + 创建/加入家庭按钮)替换为:
+
+```html
+		<!-- 新用户流程:保存资料 / 跳过(不再创建或加入家庭) -->
+		<view class="family-section" v-if="isEdit && form.role !== 'teacher' && isNewUserFlow">
+			<view class="family-actions">
+				<button class="btn btn-primary" @click="saveUserInfo" :loading="loading">
+					保存资料
+				</button>
+				<button class="btn btn-skip" @click="skip">
+					跳过
+				</button>
+			</view>
+		</view>
+```
+
+### 8.3 `saveUserInfo` 适配新用户流程(L463-501)
+
+替换方法体为:
+
+```js
+		async saveUserInfo() {
+			// 新用户流程:昵称可跳过(跳过时用默认昵称);老用户流程:昵称必填
+			if (!this.isNewUserFlow && !this.form.nickname) {
+				uni.showToast({ title: '请输入昵称', icon: 'none' })
+				return
+			}
+			if (this.isNewUserFlow && !this.form.nickname) {
+				var phone = this.form.phone || ''
+				this.form.nickname = phone ? '用户' + phone.substring(phone.length - 4) : '微信用户'
+			}
+
+			// 从 AddressPicker 读取地址文本
+			if (this.$refs.addressPicker && this.$refs.addressPicker.fullAddress) {
+				this.form.address = this.$refs.addressPicker.fullAddress
+			}
+
+			this.loading = true
+			try {
+				await updateUserInfo({
+					nickname: this.form.nickname,
+					realName: this.form.realName,
+					gender: this.form.gender,
+					idCard: this.form.idCard,
+					birthday: this.form.birthday,
+					avatar: this.form.avatar,
+					mascot: this.form.mascot,
+					ethnicity: this.form.ethnicity,
+					bloodType: this.form.bloodType,
+					highestEducation: this.form.highestEducation,
+					maritalStatus: this.form.maritalStatus,
+					hobbies: this.form.hobbies,
+					dietPreferences: this.form.dietPreferences,
+					address: this.form.address
+				})
+				uni.showToast({ title: '保存成功', icon: 'success' })
+				setTimeout(() => {
+					if (this.isNewUserFlow) {
+						this.navigateToHome()
+					} else {
+						uni.navigateBack()
+					}
+				}, 1500)
+			} catch (e) {
+				uni.showToast({ title: '保存失败', icon: 'none' })
+			} finally {
+				this.loading = false
+			}
+		},
+```
+
+### 8.4 保留逻辑
+
+- `joinFamilyAction`(L545)与 `createFamilyAction`(L593)方法体保留(非新用户流程/其他入口可能复用),但模板已无引用——保留方法不报错
+- `skip()`(L635)与 `navigateToHome()`(L638)不变
+
+**验证**: `npm run build:mp-weixin` 通过。
+**提交**: `feat: 注册引导-user-edit新用户流程最小化`
+
+---
+
+## 任务 9:profile.vue 个人中心补充卡片
+
+修改 `cfc-frontend/pages/profile/profile.vue`。
+
+### 9.1 模板:ProfileHeader 之后加卡片(L17 后)
+
+```html
+		<!-- 个人信息完善卡片(全部填满后隐藏) -->
+		<view class="profile-card" v-if="isLoggedIn && profileProgress < 14" @click="goToEditInfo">
+			<view class="profile-card-left">
+				<text class="profile-card-title">个人信息</text>
+				<text class="profile-card-desc">已完善 {{ profileProgress }}/14 个字段,点击继续补充</text>
+			</view>
+			<text class="profile-card-arrow">›</text>
+		</view>
+```
+
+### 9.2 data 新增(L66 data() 内)
+
+```js
+			profileProgress: 0,
+```
+
+### 9.3 onShow 中加载(L110 `this.loadHealthScoreData()` 之后)
+
+```js
+		this.loadProfileProgress()
+```
+
+### 9.4 methods 新增
+
+```js
+		loadProfileProgress() {
+			var self = this
+			getUserInfo().then(function(res) {
+				if (res && res.code === 200 && res.data) {
+					var user = res.data
+					var fields = ['nickname', 'avatar', 'realName', 'gender', 'birthday', 'birthHour', 'ethnicity', 'bloodType', 'highestEducation', 'maritalStatus', 'address', 'hobbies', 'dietPreferences', 'phone']
+					var filled = 0
+					for (var i = 0; i < fields.length; i++) {
+						var v = user[fields[i]]
+						if (v !== null && v !== undefined && v !== '') filled++
+					}
+					self.profileProgress = filled
+				}
+			}).catch(function() {})
+		},
+		// 修复:ProfileHeader 头像点击进入编辑页(原方法缺失)
+		goToEditInfo() {
+			uni.navigateTo({ url: '/pages/user-edit/user-edit' })
+		},
+```
+
+### 9.5 import 补充(L46 ProfileHeader import 附近)
+
+```js
+import { getUserInfo } from '../../utils/api.js'
+```
+
+> 若文件已 import getUserInfo 则跳过。同时在 `<style scoped>` 末尾追加卡片样式:
+
+```css
+.profile-card {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	justify-content: space-between;
+	margin: 20rpx 30rpx;
+	padding: 28rpx 32rpx;
+	background: #fff;
+	border-radius: 16rpx;
+}
+.profile-card-left {
+	display: flex;
+	flex-direction: column;
+}
+.profile-card-title {
+	font-size: 30rpx;
+	font-weight: bold;
+	color: #333;
+}
+.profile-card-desc {
+	font-size: 24rpx;
+	color: #999;
+	margin-top: 8rpx;
+}
+.profile-card-arrow {
+	font-size: 40rpx;
+	color: #ccc;
+}
+```
+
+**验证**: `npm run build:mp-weixin` 通过。
+**提交**: `feat: 注册引导-个人中心个人信息完善卡片`
+
+---
+
+## 任务 10:活动报名接入「就地补充 + 回写」(P1)
+
+修改 `cfc-frontend/pages/activity/activity-detail/activity-detail.vue`。
+
+### 10.1 import(script 顶部)
+
+```js
+import { backfillProfile } from '../../../utils/profile-backfill.js'
+```
+
+> 确认相对路径:activity-detail.vue 位于 `pages/activity/activity-detail/`,到 utils 为 `../../../utils/`。
+
+### 10.2 `loadFamilyMembers` 成员对象补充 targetType 标记(L579-597)
+
+parents 分支(L578-585)的 push 对象追加:
+
+```js
+			targetType: 'user',
+```
+
+children 分支(L590-597)的 push 对象追加:
+
+```js
+			targetType: 'member',
+```
+
+### 10.3 `toggleFamilyMember` 记录原始值(L616-621)
+
+push 对象追加 prevValues:
+
+```js
+		this.registrants.push({
+			memberId: member.id,
+			name: member.name,
+			phone: member.phone,
+			childId: member.childId,
+			targetType: member.targetType,
+			prevPhone: member.phone
+		})
+```
+
+### 10.4 `submitRegistration` 后触发回写(L671-707 校验之后、调用 doCreateOrder 之前)
+
+在校验循环(L678-689)之后、`if (self.hasFee...)`(L691)之前插入:
+
+```js
+		// 就地补充的手机号自动回写(仅家庭成员;外部报名人 memberId=null 不回写)
+		var backfillTasks = []
+		for (var b = 0; b < self.registrants.length; b++) {
+			var entryB = self.registrants[b]
+			if (!entryB.memberId) continue
+			var phoneB = (entryB.phone || '').trim()
+			if (phoneB === '') continue
+			backfillTasks.push(backfillProfile({
+				targetType: entryB.targetType,
+				targetId: entryB.memberId,
+				fields: { phone: phoneB },
+				scene: 'activity-signup',
+				prevValues: { phone: entryB.prevPhone }
+			}))
+		}
+		// 回写失败不阻塞报名主流程,仅静默记录
+		if (backfillTasks.length > 0) {
+			Promise.all(backfillTasks).catch(function(err) {
+				console.warn('[backfill] 活动报名信息回写失败', err)
+			})
+		}
+```
+
+> memberId 格式说明:`loadFamilyMembers` 中 parents 的 id 为 `'p_' + parents[i].id`(对应 users.id),children 的 id 为 `'c_' + children[j].id`(对应 family_members.id)。`backfillProfile` 会把 `memberId` 直接作为 `targetId` 传给后端——**需要前端转换**:`targetType='user'` 时 targetId 应为去掉 `p_` 前缀的纯数字。因此把 10.4 中的 `targetId` 改为:
+
+```js
+			var rawId = String(entryB.memberId)
+			var realId = rawId.indexOf('_') >= 0 ? rawId.substring(2) : rawId
+```
+
+并把 `targetId: entryB.memberId` 替换为 `targetId: realId`。
+
+**验证**: `npm run build:mp-weixin` 通过。
+**提交**: `feat: 注册引导-活动报名接入就地补充回写`
+
+---
+
+## 任务 11:无家庭引导组件 + 核心页接入(P2)
+
+### 11.1 新建 `cfc-frontend/components/family-guard.vue`
+
+```vue
+<template>
+	<view v-if="visible" class="family-guard-mask" @click="close">
+		<view class="family-guard-panel" @click.stop>
+			<text class="family-guard-title">创建家庭后即可使用</text>
+			<text class="family-guard-desc">家庭是任务、测评、成长档案等功能的基础,创建后邀请家人加入</text>
+			<view class="family-guard-input-wrap">
+				<input class="family-guard-input" v-model="familyName" placeholder="请输入家庭名称(默认:我的家庭)" maxlength="20" />
+			</view>
+			<view class="family-guard-actions">
+				<button class="family-guard-btn family-guard-btn-primary" @click="doCreate" :loading="loading">去创建家庭</button>
+				<button class="family-guard-btn family-guard-btn-plain" @click="close">暂不创建</button>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+import { createFamily } from '../utils/api.js'
+
+export default {
+	name: 'FamilyGuard',
+	data() {
+		return {
+			visible: false,
+			loading: false,
+			familyName: ''
+		}
+	},
+	methods: {
+		show() {
+			this.familyName = ''
+			this.visible = true
+		},
+		close() {
+			this.visible = false
+		},
+		doCreate() {
+			var self = this
+			var name = (this.familyName || '').trim() || '我的家庭'
+			this.loading = true
+			createFamily(name).then(function(res) {
+				self.loading = false
+				if (res && res.code === 200 && res.data) {
+					uni.setStorageSync('familyId', res.data)
+					uni.setStorageSync('currentFamilyId', res.data)
+					// 同步 userInfo 缓存
+					var userInfo = uni.getStorageSync('userInfo') || {}
+					userInfo.familyId = res.data
+					uni.setStorageSync('userInfo', userInfo)
+					self.visible = false
+					uni.showToast({ title: '家庭创建成功', icon: 'success' })
+					if (self.$listeners && self.$listeners.created) {
+						self.$emit('created', res.data)
+					}
+				} else {
+					uni.showToast({ title: (res && res.message) || '创建失败', icon: 'none' })
+				}
+			}).catch(function() {
+				self.loading = false
+				uni.showToast({ title: '网络异常', icon: 'none' })
+			})
+		}
+	}
+}
+</script>
+
+<style scoped>
+.family-guard-mask {
+	position: fixed;
+	top: 0;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	background: rgba(0, 0, 0, 0.5);
+	z-index: 999;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+}
+.family-guard-panel {
+	width: 600rpx;
+	background: #fff;
+	border-radius: 20rpx;
+	padding: 40rpx 36rpx;
+	display: flex;
+	flex-direction: column;
+}
+.family-guard-title {
+	font-size: 34rpx;
+	font-weight: bold;
+	color: #333;
+	text-align: center;
+}
+.family-guard-desc {
+	font-size: 26rpx;
+	color: #999;
+	margin-top: 16rpx;
+	line-height: 1.6;
+	text-align: center;
+}
+.family-guard-input-wrap {
+	margin-top: 30rpx;
+}
+.family-guard-input {
+	height: 80rpx;
+	background: #f5f7fa;
+	border-radius: 12rpx;
+	padding: 0 24rpx;
+	font-size: 28rpx;
+}
+.family-guard-actions {
+	margin-top: 36rpx;
+	display: flex;
+	flex-direction: column;
+}
+.family-guard-btn {
+	height: 84rpx;
+	border-radius: 42rpx;
+	font-size: 30rpx;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	margin-top: 16rpx;
+}
+.family-guard-btn-primary {
+	background: #F97316;
+	color: #fff;
+}
+.family-guard-btn-plain {
+	background: #f5f7fa;
+	color: #666;
+}
+</style>
+```
+
+### 11.2 接入 `pages/wealth-sub/insurance-planning.vue`
+
+- 模板根节点内(最末尾)加:
+
+```html
+	<family-guard ref="familyGuard" />
+```
+
+- script 加 import 与 components 注册:
+
+```js
+import FamilyGuard from '../../components/family-guard.vue'
+// components: { FamilyGuard }
+```
+
+- 找到现有「请先加入家庭」处理处(`if (!familyId)` / toast),替换为调用引导组件:
+
+```js
+if (!familyId) {
+	this.$refs.familyGuard.show()
+	return
+}
+```
+
+### 11.3 接入 `pages/growth/index.vue`
+
+- 同样加 `<family-guard ref="familyGuard" />`、import、components 注册
+- 将现有 `if (!familyId)` 静默返回处替换为 `this.$refs.familyGuard.show(); return`
+
+### 11.4 接入 `pages/profile/components/ProfileMenu.vue`
+
+- 同样加组件、import、注册
+- 将「请先加入家庭」toast 处替换为引导弹窗
+
+> 三个页面各自保留原业务逻辑(`familyId` 为空时不再静默,改为弹窗引导),创建成功后 `created` 事件回调里刷新页面数据(`onShow` 已有刷新逻辑则无需额外处理)。
+
+**验证**: `npm run build:mp-weixin` 通过。
+**提交**: `feat: 注册引导-无家庭引导组件接入核心页`
+
+---
+
+## 任务 12:验收与文档收尾
+
+1. 后端:`mvn clean compile` 退出码 0
+2. 前端:`npm run build:mp-weixin` 通过
+3. 检查路由重复:
+   ```bash
+   grep -rn '@Mapping' cfc-backend/src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u | grep backfill
+   ```
+   应仅出现 `/profile/backfill` 一处(无冲突)
+4. 检查 Bean 命名:`ProfileBackfillDTO` 为 DTO 无 Bean 冲突;`FamilyGuard` 前端组件名与现有组件无冲突(`grep -rn "FamilyGuard" cfc-frontend/components/`)
+5. 更新 `docs/superpowers/PROJECT-OVERVIEW.md`:本计划对应规格条目状态改为「已实施」、追加计划文档索引
+6. 提交:`docs: 注册引导实施计划完成`
+
+---
+
+## 自检记录
+
+- **规格覆盖度**:规格第 6.1(任务3)、6.2(任务1)、6.3(任务2)、6.4(任务4)、7.1(任务8)、7.2(任务9)、7.3(任务6)、7.4(任务7)、7.5(任务10)、7.6(任务11)、9 验收标准(任务12)——全部覆盖
+- **占位符扫描**:无 TODO/待定;每个任务含完整代码
+- **类型一致性**:`backfillProfile` 前后端签名一致(targetType/targetId/fields);`needsFamily` 在 8 个装配点一致;memberId 前缀转换(`p_`/`c_` → 纯数字 targetId)已在任务 10.4 定义并在 10.2-10.3 建立 targetType