# 邀请分享卡片 + 自动登录 实现计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 实现三种邀请分享卡片(邀请孩子/邀请家人/邀请规划师),接收方通过微信分享卡片进入小程序,可自动完成注册/登录/绑定。 **架构:** 后端新增 `invite_card` 表 + `InviteCardController`/`InviteCardService`,负责邀请码生成和接收验证;前端在三个入口页面添加分享按钮,登录页处理邀请参数自动登录。 **Tech Stack:** Spring Boot 2.7.18 + MyBatis-Plus, uni-app Vue 2, 微信小程序原生分享 --- ## 文件结构 | 文件 | 操作 | 职责 | |------|------|------| | `cfc-backend/.../entity/InviteCard.java` | 新建 | 邀请码实体 | | `cfc-backend/.../mapper/InviteCardMapper.java` | 新建 | MyBatis Mapper | | `cfc-backend/.../service/InviteCardService.java` | 新建 | 生成/验证/使用邀请码 | | `cfc-backend/.../controller/InviteCardController.java` | 新建 | 3个 REST 端点 | | `cfc-backend/.../config/DatabaseInitializer.java` | 修改 | 建 invite_card 表 | | `cfc-frontend/utils/api.js` | 修改 | 新增 3 个 API 函数 | | `cfc-frontend/pages/profile/children.vue` | 修改 | 每个孩子加「邀请」按钮 + share | | `cfc-frontend/pages/profile/profile.vue` | 修改 | 加「邀请家人」按钮 + share | | `cfc-frontend/pages/growth/index.vue` | 修改 | 加「邀请规划师」按钮 + share | | `cfc-frontend/pages/login/login.vue` | 修改 | 处理 invite_code 自动登录 | --- ### Task 1: 后端 — invitate_card 表 + 实体 + Mapper **前提:** 了解项目 MyBatis-Plus 实体/Mapper 写法:见 `entity/User.java`、`mapper/UserMapper.java`。 **Files:** - Create: `cfc-backend/src/main/java/com/etotem/cfc/entity/InviteCard.java` - Create: `cfc-backend/src/main/java/com/etotem/cfc/mapper/InviteCardMapper.java` - Modify: `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`(加建表语句) - Test: 启动后端验证建表成功 - [ ] **Step 1: 创建 InviteCard 实体** ```java package com.etotem.cfc.entity; import com.baomidou.mybatisplus.annotation.*; import java.time.LocalDateTime; @TableName("invite_card") public class InviteCard { @TableId(type = IdType.AUTO) private Long id; private String type; // child/family/guide private String code; // 唯一邀请码 private Long refId; // 关联ID (childId/familyId) private Long creatorId; // 创建人 private LocalDateTime expiresAt; private Integer used; // 0-未使用 1-已使用 private LocalDateTime createdAt; private LocalDateTime updatedAt; // getters/setters public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getType() { return type; } public void setType(String type) { this.type = type; } public String getCode() { return code; } public void setCode(String code) { this.code = code; } public Long getRefId() { return refId; } public void setRefId(Long refId) { this.refId = refId; } public Long getCreatorId() { return creatorId; } public void setCreatorId(Long creatorId) { this.creatorId = creatorId; } public LocalDateTime getExpiresAt() { return expiresAt; } public void setExpiresAt(LocalDateTime expiresAt) { this.expiresAt = expiresAt; } public Integer getUsed() { return used; } public void setUsed(Integer used) { this.used = used; } public LocalDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } public LocalDateTime getUpdatedAt() { return updatedAt; } public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } } ``` - [ ] **Step 2: 创建 InviteCardMapper** ```java package com.etotem.cfc.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.etotem.cfc.entity.InviteCard; public interface InviteCardMapper extends BaseMapper { } ``` - [ ] **Step 3: DatabaseInitializer 中添加建表语句** 找到 `DatabaseInitializer.java` 中的 SQL 执行部分,在最后加一条 CREATE TABLE IF NOT EXISTS: ```sql CREATE TABLE IF NOT EXISTS invite_card ( id BIGINT AUTO_INCREMENT PRIMARY KEY, type VARCHAR(20) NOT NULL COMMENT 'child/family/guide', code VARCHAR(64) NOT NULL UNIQUE COMMENT '邀请码', ref_id BIGINT NOT NULL COMMENT '关联ID(childId/familyId)', creator_id BIGINT NOT NULL COMMENT '创建人', expires_at DATETIME NOT NULL, used TINYINT DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_code (code), INDEX idx_ref_type (ref_id, type) ) COMMENT='邀请卡片'; ``` - [ ] **Step 4: 编译并启动验证** ```bash cd cfc-backend && mvn clean compile spring-boot:run ``` 确认启动日志无报错,表自动创建。 - [ ] **Step 5: Commit** ```bash git add cfc-backend/src/main/java/com/etotem/cfc/entity/InviteCard.java git add cfc-backend/src/main/java/com/etotem/cfc/mapper/InviteCardMapper.java git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java git commit -m "feat: add invite_card table and entity" ``` --- ### Task 2: 后端 — InviteCardService 生成 + 验证 + 使用 **Files:** - Create: `cfc-backend/src/main/java/com/etotem/cfc/service/InviteCardService.java` - Test: 编译验证 - [ ] **Step 1: 创建 InviteCardService** ```java package com.etotem.cfc.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.etotem.cfc.common.Result; import com.etotem.cfc.entity.*; import com.etotem.cfc.mapper.*; import com.etotem.cfc.service.UserService; import org.springframework.stereotype.Service; import javax.annotation.Resource; import java.time.LocalDateTime; import java.util.*; @Service public class InviteCardService { @Resource private InviteCardMapper inviteCardMapper; @Resource private UserMapper userMapper; @Resource private UserService userService; @Resource private ChildMapper childMapper; @Resource private FamilyMapper familyMapper; /** * 生成邀请码 * @param type child/family/guide * @param refId 关联ID * @param creatorId 创建人ID */ public InviteCard generate(String type, Long refId, Long creatorId) { // 使旧的未使用邀请码失效 invalidateOldCodes(type, refId); InviteCard card = new InviteCard(); card.setType(type); card.setCode(generateCode(type)); card.setRefId(refId); card.setCreatorId(creatorId); card.setExpiresAt(LocalDateTime.now().plusHours(24)); card.setUsed(0); card.setCreatedAt(LocalDateTime.now()); card.setUpdatedAt(LocalDateTime.now()); inviteCardMapper.insert(card); return card; } private void invalidateOldCodes(String type, Long refId) { List oldCards = inviteCardMapper.selectList( new LambdaQueryWrapper() .eq(InviteCard::getType, type) .eq(InviteCard::getRefId, refId) .eq(InviteCard::getUsed, 0) ); for (InviteCard card : oldCards) { card.setUsed(1); card.setUpdatedAt(LocalDateTime.now()); inviteCardMapper.updateById(card); } } private String generateCode(String type) { String prefix; switch (type) { case "child": prefix = "CHILD_"; break; case "family": prefix = "FAMILY_"; break; case "guide": prefix = "GUIDE_"; break; default: prefix = "INV_"; break; } String random = UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase(); return prefix + random; } /** * 验证邀请码并返回关联信息 */ public Result> verify(String code) { InviteCard card = inviteCardMapper.selectOne( new LambdaQueryWrapper().eq(InviteCard::getCode, code) ); if (card == null) { return Result.error("邀请码无效"); } if (card.getUsed() == 1) { return Result.error("邀请码已使用"); } if (card.getExpiresAt().isBefore(LocalDateTime.now())) { return Result.error("邀请码已过期"); } Map result = new HashMap<>(); result.put("type", card.getType()); result.put("refId", card.getRefId()); if ("child".equals(card.getType())) { Child child = childMapper.selectById(card.getRefId()); if (child != null) { result.put("nickname", child.getNickname()); Family family = familyMapper.selectById(child.getFamilyId()); if (family != null) { result.put("familyName", family.getName()); } } } else if ("family".equals(card.getType())) { Family family = familyMapper.selectById(card.getRefId()); if (family != null) { result.put("familyName", family.getName()); } } return Result.success(result); } /** * 接受邀请 - 核心逻辑 * @param code 邀请码 * @param phone 手机号 * @return 登录结果(token, userId, role, familyId) */ public Result> accept(String code, String phone) { InviteCard card = inviteCardMapper.selectOne( new LambdaQueryWrapper().eq(InviteCard::getCode, code) ); if (card == null) return Result.error("邀请码无效"); if (card.getUsed() == 1) return Result.error("邀请码已使用"); if (card.getExpiresAt().isBefore(LocalDateTime.now())) return Result.error("邀请码已过期"); if ("child".equals(card.getType())) { return acceptChild(card, phone); } else if ("family".equals(card.getType())) { return acceptFamily(card, phone); } else if ("guide".equals(card.getType())) { return acceptGuide(card, phone); } return Result.error("未知邀请类型"); } private Result> acceptChild(InviteCard card, String phone) { Child child = childMapper.selectById(card.getRefId()); if (child == null) return Result.error("孩子信息不存在"); if (child.getPhone() == null || !child.getPhone().equals(phone)) { return Result.error("手机号不匹配,请联系家长确认"); } // 检查孩子是否已有账号 User existingUser = userMapper.selectOne( new LambdaQueryWrapper().eq(User::getPhone, phone) ); User user; if (existingUser != null) { user = existingUser; } else { // 自动注册 user = new User(); user.setPhone(phone); user.setNickname(child.getNickname() != null ? child.getNickname() : "孩子"); user.setRole("child"); user.setFamilyId(child.getFamilyId()); user.setAvatar(child.getAvatar()); userMapper.insert(user); } // 标记邀请码已使用 card.setUsed(1); card.setUpdatedAt(LocalDateTime.now()); inviteCardMapper.updateById(card); Map result = new HashMap<>(); result.put("token", userService.generateToken(user)); result.put("userId", user.getId()); result.put("role", "child"); result.put("familyId", user.getFamilyId()); return Result.success(result); } private Result> acceptFamily(InviteCard card, String phone) { Family family = familyMapper.selectById(card.getRefId()); if (family == null) return Result.error("家庭不存在"); // 检查手机号是否已注册 User existingUser = userMapper.selectOne( new LambdaQueryWrapper().eq(User::getPhone, phone) ); User user; if (existingUser != null) { user = existingUser; // 更新家庭ID user.setFamilyId(card.getRefId()); userMapper.updateById(user); } else { user = new User(); user.setPhone(phone); user.setNickname("家人"); user.setRole("parent"); user.setFamilyId(card.getRefId()); userMapper.insert(user); } card.setUsed(1); card.setUpdatedAt(LocalDateTime.now()); inviteCardMapper.updateById(card); Map result = new HashMap<>(); result.put("token", userService.generateToken(user)); result.put("userId", user.getId()); result.put("role", user.getRole()); result.put("familyId", user.getFamilyId()); return Result.success(result); } private Result> acceptGuide(InviteCard card, String phone) { // 规划师邀请只返回家庭信息,让用户自己去规划师注册页走完整流程 Family family = familyMapper.selectById(card.getRefId()); if (family == null) return Result.error("家庭不存在"); card.setUsed(1); card.setUpdatedAt(LocalDateTime.now()); inviteCardMapper.updateById(card); Map result = new HashMap<>(); result.put("familyId", card.getRefId()); result.put("familyName", family.getName()); result.put("type", "guide"); return Result.success(result); } } ``` - [ ] **Step 2: 编译验证** ```bash cd cfc-backend && mvn clean compile ``` 确认无编译错误。 - [ ] **Step 3: Commit** ```bash git add cfc-backend/src/main/java/com/etotem/cfc/service/InviteCardService.java git commit -m "feat: add InviteCardService with generate/verify/accept logic" ``` --- ### Task 3: 后端 — InviteCardController 三个端点 **Files:** - Create: `cfc-backend/src/main/java/com/etotem/cfc/controller/guide/InviteCardController.java` - Test: 启动后端 + curl 测试 - [ ] **Step 1: 创建 InviteCardController** ```java package com.etotem.cfc.controller.guide; import com.etotem.cfc.common.Result; import com.etotem.cfc.entity.*; import com.etotem.cfc.mapper.*; import com.etotem.cfc.service.InviteCardService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.web.bind.annotation.*; import javax.annotation.Resource; import javax.servlet.http.HttpServletRequest; import java.util.HashMap; import java.util.Map; @Tag(name = "邀请卡片", description = "家庭成员/规划师邀请分享") @RestController @RequestMapping("/api/invite-card") public class InviteCardController { @Resource private InviteCardService inviteCardService; @Resource private ChildMapper childMapper; @Resource private FamilyMapper familyMapper; @Resource private UserMapper userMapper; @PostMapping("/generate") @Operation(summary = "生成邀请码") public Result> generate(@RequestBody Map params, HttpServletRequest request) { Long userId = (Long) request.getAttribute("userId"); String type = (String) params.get("type"); Object refIdObj = params.get("refId"); if (type == null || refIdObj == null) { return Result.error("缺少必要参数: type/refId"); } Long refId = Long.valueOf(refIdObj.toString()); // 验证用户对 refId 的归属权 if ("child".equals(type)) { Child child = childMapper.selectById(refId); if (child == null) return Result.error("孩子不存在"); User user = userMapper.selectById(userId); if (user == null || !user.getFamilyId().equals(child.getFamilyId())) { return Result.error("无权生成该孩子的邀请"); } } else if ("family".equals(type) || "guide".equals(type)) { Family family = familyMapper.selectById(refId); if (family == null) return Result.error("家庭不存在"); User user = userMapper.selectById(userId); if (user == null || !user.getFamilyId().equals(family.getId())) { return Result.error("无权生成该家庭的邀请"); } } InviteCard card = inviteCardService.generate(type, refId, userId); Map result = new HashMap<>(); result.put("code", card.getCode()); result.put("expiresAt", card.getExpiresAt().toString()); return Result.success(result); } @PostMapping("/verify") @Operation(summary = "验证邀请码") public Result> verify(@RequestBody Map params) { String code = (String) params.get("code"); if (code == null) return Result.error("缺少邀请码"); return inviteCardService.verify(code); } @PostMapping("/accept") @Operation(summary = "接受邀请(自动注册/登录)") public Result> accept(@RequestBody Map params) { String code = (String) params.get("code"); String phone = (String) params.get("phone"); if (code == null || phone == null) { return Result.error("缺少必要参数: code/phone"); } return inviteCardService.accept(code, phone); } } ``` - [ ] **Step 2: 检查 UserService.generateToken 方法是否存在** 搜索 `userService.generateToken` 或 `UserService` 中是否有生成 token 的方法。如果不存在,需要实现一个简单的 JWT token 生成(复用项目中已有的 JwtUtil): ```java // 在 InviteCardService 中注入 JwtUtil @Resource private com.etotem.cfc.config.JwtUtil jwtUtil; // 然后 generateToken 方法: private String generateToken(User user) { return jwtUtil.generateToken(user.getId(), user.getPhone(), user.getRole()); } ``` 如果项目中已有 `JwtUtil`,确认其 `generateToken` 的方法签名一致。 - [ ] **Step 3: 启动后端测试** ```bash cd cfc-backend && mvn spring-boot:run ``` 在另一个终端测试: ```bash # 生成邀请码(需要先获取 token) # 测试 child 类型 curl -X POST http://localhost:8080/api/invite-card/generate \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TOKEN" \ -d '{"type":"child","refId":1}' # 验证邀请码 curl -X POST http://localhost:8080/api/invite-card/verify \ -H "Content-Type: application/json" \ -d '{"code":"CHILD_XXXXXX"}' ``` 确认返回正常。 - [ ] **Step 4: Commit** ```bash git add cfc-backend/src/main/java/com/etotem/cfc/controller/guide/InviteCardController.java git commit -m "feat: add InviteCardController with generate/verify/accept endpoints" ``` --- ### Task 4: 前端 — api.js 新增三个 API 函数 **Files:** - Modify: `cfc-frontend/utils/api.js` - [ ] **Step 1: 新增 API 函数** 在 `utils/api.js` 末尾添加: ```javascript // 邀请卡片 export const generateInviteCard = (type, refId) => { return request('/api/invite-card/generate', 'POST', { type, refId }) } export const verifyInviteCard = (code) => { return request('/api/invite-card/verify', 'POST', { code }) } export const acceptInviteCard = (code, phone) => { return request('/api/invite-card/accept', 'POST', { code, phone }) } ``` - [ ] **Step 2: Commit** ```bash git add cfc-frontend/utils/api.js git commit -m "feat: add invite card API functions" ``` --- ### Task 5: 前端 — 孩子管理页添加「邀请」按钮 **Files:** - Modify: `cfc-frontend/pages/profile/children.vue` - [ ] **Step 1: 在模板每个孩子项加「邀请」按钮** 找到 ``,在编辑后加邀请按钮: ```html 邀请 编辑 ``` - [ ] **Step 2: 加分享配置** 在 `data()` 中添加: ```javascript data() { return { children: [], shareData: null // 用于 onShareAppMessage } } ``` 在 `onLoad` 中定义 `onShareAppMessage`: ```javascript onLoad() { // 必须在页面级别定义 onShareAppMessage,不能在 methods 里 }, onShareAppMessage() { if (this.shareData) { return { title: this.shareData.title, path: this.shareData.path, imageUrl: '/static/invite-card.png' } } return { title: '浠艾福', path: '/pages/login/login' } } ``` - [ ] **Step 3: 实现 inviteChild 方法** 在 `methods` 中添加: ```javascript async inviteChild(child) { try { const { generateInviteCard } = require('../../utils/api.js') const res = await generateInviteCard('child', child.id) const code = res.data.code // 保存分享数据 this.shareData = { title: `邀请 ${child.nickname || '孩子'} 加入家庭`, path: `/pages/login/login?invite_code=${code}` } // 触发微信原生分享 // 注意:需要 button open-type="share" 才能触发 uni.showToast({ title: '点击右上角转发给TA', icon: 'none' }) } catch (e) { uni.showToast({ title: e.message || '生成邀请失败', icon: 'none' }) } } ``` - [ ] **Step 4: 改「邀请」按钮为 button open-type="share"** 由于微信分享必须通过 `button open-type="share"` 触发(不能通过 @click 手动调),需要改模板: ```html 编辑 ``` 方法分离: ```javascript async prepareInvite(child) { try { const { generateInviteCard } = require('../../utils/api.js') const res = await generateInviteCard('child', child.id) const code = res.data.code this.shareData = { title: `邀请 ${child.nickname || '孩子'} 加入家庭`, path: `/pages/login/login?invite_code=${code}` } this._waitingShare = true } catch (e) { uni.showToast({ title: e.message || '生成邀请失败', icon: 'none' }) } } ``` - [ ] **Step 5: 添加邀请按钮样式** 在 `