2026-08-04-registration-profile-backfill.md 37 KB

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

  • 日期: 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.vueloadFamilyMembers 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.vuecfc-frontend/pages/profile/components/ProfileMenu.vuecfc-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)之后、方法闭合 } 之前追加:

        // 迁移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):

family_id BIGINT NOT NULL,

改为:

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 后)追加:

    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(...) 之后)追加:

        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)

删除新用户分支中的建家庭代码块:

                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

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 之后):

    /** 回写字段白名单(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 之后)新增:

    @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.ProfileBackfillDTOjava.util.Map(文件已使用 Map)。

验证: mvn clean compile 退出码 0。 提交: feat: 注册引导-新增统一backfill回写接口


任务 5:前端 api.js 新增 backfillProfile

修改 cfc-frontend/utils/api.js,在 updateUserInfo(L256-258)之后追加:

// 就地补充信息回写(活动报名等场景按需补充后自动回写个人资料)
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

新增文件(完整内容):

/**
 * 个人信息使用关联表
 * 记录个人信息的各个字段在哪些功能场景中被使用。
 * 用途:
 *  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

新增文件(完整内容):

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> 之后插入:

			<!-- 新用户流程:仅展示头像+昵称,其余字段按需在功能页补充 -->
			<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,含邀请码输入 + 创建/加入家庭按钮)替换为:

		<!-- 新用户流程:保存资料 / 跳过(不再创建或加入家庭) -->
		<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)

替换方法体为:

		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 后)

		<!-- 个人信息完善卡片(全部填满后隐藏) -->
		<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() 内)

			profileProgress: 0,

9.3 onShow 中加载(L110 this.loadHealthScoreData() 之后)

		this.loadProfileProgress()

9.4 methods 新增

		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 附近)

import { getUserInfo } from '../../utils/api.js'

若文件已 import getUserInfo 则跳过。同时在 <style scoped> 末尾追加卡片样式:

.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 顶部)

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 对象追加:

			targetType: 'user',

children 分支(L590-597)的 push 对象追加:

			targetType: 'member',

10.3 toggleFamilyMember 记录原始值(L616-621)

push 对象追加 prevValues:

		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)之前插入:

		// 就地补充的手机号自动回写(仅家庭成员;外部报名人 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 改为:

			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

<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

  • 模板根节点内(最末尾)加:

    	<family-guard ref="familyGuard" />
    
  • script 加 import 与 components 注册:

    import FamilyGuard from '../../components/family-guard.vue'
    // components: { FamilyGuard }
    
  • 找到现有「请先加入家庭」处理处(if (!familyId) / toast),替换为调用引导组件:

    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. 检查路由重复:

    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