# 会员管理模块技术方案 --- | 文档信息 | 内容 | |---------|------| | **文档版本** | V1.0 | | **项目名称** | 盘古用户平台(Pangu User Platform) | | **模块名称** | 会员管理模块 | | **编写团队** | pangu | | **创建日期** | 2026-01-31 | | **评审状态** | 待评审 | --- ## 修订记录 | 版本 | 日期 | 修订人 | 修订内容 | |-----|------|-------|---------| | V1.0 | 2026-01-31 | pangu | 初稿 | --- ## 目录 1. [概述](#1-概述) 2. [需求分析](#2-需求分析) 3. [前端技术方案](#3-前端技术方案) 4. [后端技术方案](#4-后端技术方案) 5. [数据库设计](#5-数据库设计) 6. [接口设计](#6-接口设计) 7. [开发阶段计划](#7-开发阶段计划) 8. [测试方案](#8-测试方案) 9. [部署方案](#9-部署方案) 10. [风险评估](#10-风险评估) --- ## 1. 概述 ### 1.1 模块简介 会员管理模块是盘古用户平台的核心业务模块之一,主要负责管理通过小程序/H5端注册的前端用户(家长/教师),支持用户信息维护、登录认证、学生绑定等功能。 ### 1.2 模块边界 ``` ┌─────────────────────────────────────────────────────────────┐ │ 会员管理模块 │ ├─────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ 会员信息管理 │ │ 登录认证 │ │ 学生绑定 │ │ │ │ - 新增会员 │ │ - 验证码登录 │ │ - 绑定学生 │ │ │ │ - 编辑会员 │ │ - 密码登录 │ │ - 解绑学生 │ │ │ │ - 删除会员 │ │ - 微信登录 │ │ - 绑定规则 │ │ │ │ - 列表查询 │ │ - Token管理 │ │ │ │ │ │ - 重置密码 │ │ │ │ │ │ │ │ - 状态控制 │ │ │ │ │ │ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │ │ │ 依赖模块:学校管理、区域管理、年级班级管理、学生管理 │ └─────────────────────────────────────────────────────────────┘ ``` ### 1.3 用户角色 | 角色 | 权限范围 | 说明 | |-----|---------|------| | 超级管理员 | 全部会员数据 | 可管理所有会员 | | 分公司用户 | 所属区域会员 | 只能管理所属区域的会员 | | 学校用户 | 本校教师 | 只能查看本校教师会员 | ### 1.4 术语定义 | 术语 | 定义 | |-----|------| | 会员 | 通过小程序/H5端注册的前端用户,包括家长和教师 | | 家长会员 | 身份类型为"家长"的会员,可绑定任意学校的学生 | | 教师会员 | 身份类型为"教师"的会员,需绑定学校信息,只能绑定本校学生 | | 学生绑定 | 会员与学生之间的关联关系 | --- ## 2. 需求分析 ### 2.1 功能需求清单 | 功能编号 | 功能名称 | 功能描述 | 优先级 | |---------|---------|---------|:-----:| | MEM-001 | 会员列表查询 | 按手机号、昵称、状态、注册时间、身份类型筛选 | P0 | | MEM-002 | 新增会员 | 后台手动创建会员账号 | P0 | | MEM-003 | 编辑会员 | 修改会员基本信息和学生绑定关系 | P0 | | MEM-004 | 删除会员 | 软删除会员(需检查学生绑定) | P1 | | MEM-005 | 重置密码 | 重置会员登录密码并显示新密码 | P0 | | MEM-006 | 禁用/启用会员 | 控制会员登录权限 | P0 | | MEM-007 | 绑定学生 | 为会员绑定学生信息 | P0 | | MEM-008 | 解绑学生 | 移除会员与学生的绑定关系 | P0 | ### 2.2 业务规则 | 规则编号 | 规则描述 | 校验时机 | |---------|---------|---------| | MEM-R01 | 会员编号由系统自动生成,格式:JS + 时间戳 | 新增时 | | MEM-R02 | 昵称未填写时,系统自动生成默认昵称 | 新增时 | | MEM-R03 | 手机号为必填项,需验证格式有效性和唯一性 | 新增/编辑时 | | MEM-R04 | 出生日期和性别为选填项 | - | | MEM-R05 | 身份类型为"教师"时,必须选择所属区域/学校/年级/班级 | 新增/编辑时 | | MEM-R06 | 身份类型为"教师"时,只能绑定同校学生 | 绑定学生时 | | MEM-R07 | 身份类型为"家长"时,不显示区域信息,可绑定任意学生 | 绑定学生时 | | MEM-R08 | 删除会员前需检查是否绑定学生,有则不允许删除 | 删除时 | | MEM-R09 | 重置密码后,需弹窗显示新密码并提供复制功能 | 重置密码后 | | MEM-R10 | 禁用会员后,该用户无法登录任何端 | 禁用时 | | MEM-R11 | 使用RuoYi鉴权体系,区分后台用户和会员信息 | 登录时 | ### 2.3 数据权限 ``` ┌────────────────────────────────────────────────────────────┐ │ 会员数据权限控制 │ ├────────────────────────────────────────────────────────────┤ │ │ │ 超级管理员 ────────────────────────────────► 全部会员数据 │ │ │ │ 分公司用户 ─────► 所属区域 ────► 区域下学校 ──► 学校相关会员 │ │ │ │ 学校用户 ────────────────────────────────► 本校教师会员 │ │ │ └────────────────────────────────────────────────────────────┘ ``` --- ## 3. 前端技术方案 ### 3.1 技术栈 | 技术 | 版本 | 说明 | |-----|------|------| | Vue | 3.5.x | 前端框架 | | Element Plus | 2.13.x | UI组件库 | | Pinia | 3.0.x | 状态管理 | | Axios | 1.13.x | HTTP客户端 | | Vue Router | 4.6.x | 路由管理 | ### 3.2 目录结构 ``` pangu-ui/src/ ├── api/ │ └── member.js # 会员管理API接口 ├── views/ │ └── member/ │ ├── index.vue # 会员列表页 │ ├── form.vue # 会员新增/编辑页 │ └── components/ │ ├── MemberSearch.vue # 搜索条件组件 │ ├── MemberTable.vue # 列表表格组件 │ ├── MemberForm.vue # 表单组件 │ ├── StudentBind.vue # 学生绑定组件 │ └── PasswordDialog.vue # 密码弹窗组件 ├── mock/ │ └── member.js # Mock数据 └── utils/ └── member.js # 会员相关工具函数 ``` ### 3.3 页面设计 #### 3.3.1 会员列表页(index.vue) **页面布局** ``` ┌─────────────────────────────────────────────────────────────────┐ │ 搜索区域 │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ 手机号 │ │ 昵称 │ │ 身份类型 │ │ 状态 │ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ ┌──────────────────────┐ ┌────────┐ ┌────────┐ │ │ │ 注册时间区间 │ │ 搜索 │ │ 重置 │ │ │ └──────────────────────┘ └────────┘ └────────┘ │ ├─────────────────────────────────────────────────────────────────┤ │ 操作按钮 │ │ ┌────────┐ │ │ │ 新增 │ │ │ └────────┘ │ ├─────────────────────────────────────────────────────────────────┤ │ 列表区域 │ │ ┌──────┬─────────┬──────┬──────┬──────┬──────┬──────┬────────┐ │ │ │会员编号│ 手机号 │ 昵称 │ 性别 │身份类型│注册时间│ 状态 │ 操作 │ │ │ ├──────┼─────────┼──────┼──────┼──────┼──────┼──────┼────────┤ │ │ │ ... │ ... │ ... │ ... │ ... │ ... │ ... │编辑/重置│ │ │ └──────┴─────────┴──────┴──────┴──────┴──────┴──────┴────────┘ │ ├─────────────────────────────────────────────────────────────────┤ │ 分页区域 │ │ ┌──────────────────────┐ │ │ │ < 1 2 3 4 5 ... > │ │ │ └──────────────────────┘ │ └─────────────────────────────────────────────────────────────────┘ ``` **核心代码结构** ```vue 搜索 重置 新增 {{ genderFormat(scope.row.gender) }} {{ scope.row.identityType === '1' ? '家长' : '教师' }} 编辑 重置密码 删除 新密码: {{ newPassword }} 复制 ``` #### 3.3.2 会员编辑页(form.vue) **页面布局** ``` ┌─────────────────────────────────────────────────────────────────┐ │ 页面标题:新增会员 / 编辑会员 │ ├─────────────────────────────────────────────────────────────────┤ │ 基本信息 │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ 手机号 * [_______________] │ │ │ │ 昵称 [_______________] │ │ │ │ 性别 ○ 未知 ○ 男 ○ 女 │ │ │ │ 出生日期 [_______________] │ │ │ │ 身份类型 * [___家长▼_______] │ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ │ 教师信息(身份类型为教师时显示) │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ 所属区域 * [___区域选择___] │ │ │ │ 所属学校 * [___学校选择___] │ │ │ │ 所属年级 * [___年级选择___] │ │ │ │ 所属班级 * [___班级选择___] │ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ │ 绑定学生 │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ ┌────────┐ │ │ │ │ │ + 绑定 │ │ │ │ │ └────────┘ │ │ │ │ ┌──────┬────────┬────────┬────────┬────────┬────────┐ │ │ │ │ │ 姓名 │ 学号 │ 学校 │ 年级 │ 班级 │ 操作 │ │ │ │ │ ├──────┼────────┼────────┼────────┼────────┼────────┤ │ │ │ │ │ ... │ ... │ ... │ ... │ ... │ 解绑 │ │ │ │ │ └──────┴────────┴────────┴────────┴────────┴────────┘ │ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ │ ┌────────┐ ┌────────┐ │ │ │ 保存 │ │ 取消 │ │ │ └────────┘ └────────┘ │ └─────────────────────────────────────────────────────────────────┘ ``` **核心代码结构** ```vue 基本信息 未知 男 女 教师信息 绑定学生 + 绑定学生 解绑 保存 取消 ``` ### 3.4 组件设计 #### 3.4.1 学生选择弹窗(StudentSelectDialog.vue) | 属性 | 类型 | 说明 | |-----|------|------| | visible | Boolean | 弹窗显示状态 | | identityType | String | 身份类型,用于控制可选范围 | | schoolId | Number | 学校ID,教师身份时限制只能选本校学生 | | excludeIds | Array | 已绑定学生ID列表,排除这些学生 | | 事件 | 参数 | 说明 | |-----|------|------| | confirm | studentList | 选中的学生列表 | | update:visible | Boolean | 更新显示状态 | #### 3.4.2 密码显示弹窗(PasswordDialog.vue) | 属性 | 类型 | 说明 | |-----|------|------| | visible | Boolean | 弹窗显示状态 | | password | String | 新密码 | | 事件 | 参数 | 说明 | |-----|------|------| | copy | - | 复制密码 | | update:visible | Boolean | 更新显示状态 | ### 3.5 状态管理 ```javascript // store/member.js import { defineStore } from 'pinia' export const useMemberStore = defineStore('member', { state: () => ({ // 区域树缓存 regionTree: [], // 当前编辑的会员 currentMember: null }), actions: { // 获取区域树(带缓存) async fetchRegionTree() { if (this.regionTree.length > 0) { return this.regionTree } const res = await getRegionTree() if (res.code === 200) { this.regionTree = res.data } return this.regionTree }, // 设置当前会员 setCurrentMember(member) { this.currentMember = member }, // 清空当前会员 clearCurrentMember() { this.currentMember = null } } }) ``` ### 3.6 表单校验规则 ```javascript const rules = reactive({ phone: [ { required: true, message: '请输入手机号', trigger: 'blur' }, { pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' } ], identityType: [ { required: true, message: '请选择身份类型', trigger: 'change' } ], regionId: [ { required: true, message: '请选择所属区域', trigger: 'change' } ], schoolId: [ { required: true, message: '请选择所属学校', trigger: 'change' } ], schoolGradeId: [ { required: true, message: '请选择所属年级', trigger: 'change' } ], schoolClassId: [ { required: true, message: '请选择所属班级', trigger: 'change' } ] }) ``` ### 3.7 工具函数 ```javascript // utils/member.js /** * 生成默认昵称 */ export function generateNickname(phone) { return `用户${phone.slice(-4)}` } /** * 手机号脱敏 */ export function maskPhone(phone) { if (!phone || phone.length !== 11) return phone return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') } /** * 性别格式化 */ export function formatGender(gender) { const map = { '0': '未知', '1': '男', '2': '女' } return map[gender] || '未知' } /** * 身份类型格式化 */ export function formatIdentityType(type) { return type === '1' ? '家长' : '教师' } /** * 复制文本到剪贴板 */ export async function copyToClipboard(text) { try { await navigator.clipboard.writeText(text) ElMessage.success('复制成功') } catch (err) { ElMessage.error('复制失败') } } ``` --- ## 4. 后端技术方案 ### 4.1 技术栈 | 技术 | 版本 | 说明 | |-----|------|------| | Spring Boot | 3.3.x | 应用框架 | | Spring Security | 6.x | 安全框架 | | MyBatis Plus | 3.5.x | ORM框架 | | JWT | 0.12.x | Token认证 | | Hutool | 5.x | 工具库 | | JDK | 17+ | 运行环境 | ### 4.2 模块结构 ``` pangu-admin/ └── src/main/java/com/pangu/ ├── member/ │ ├── controller/ │ │ └── MemberController.java # 会员管理控制器 │ ├── service/ │ │ ├── IMemberService.java # 会员服务接口 │ │ └── impl/ │ │ └── MemberServiceImpl.java # 会员服务实现 │ ├── mapper/ │ │ └── MemberMapper.java # 会员数据访问 │ ├── domain/ │ │ ├── Member.java # 会员实体 │ │ ├── MemberVO.java # 会员视图对象 │ │ └── MemberDTO.java # 会员传输对象 │ └── enums/ │ ├── IdentityTypeEnum.java # 身份类型枚举 │ └── RegisterSourceEnum.java # 注册来源枚举 └── common/ └── exception/ └── MemberException.java # 会员模块异常 ``` ### 4.3 实体设计 #### 4.3.1 会员实体(Member.java) ```java package com.pangu.member.domain; import com.baomidou.mybatisplus.annotation.*; import lombok.Data; import java.time.LocalDate; import java.time.LocalDateTime; /** * 会员实体 * @author pangu */ @Data @TableName("pg_member") public class Member { /** 会员ID */ @TableId(type = IdType.AUTO) private Long memberId; /** 会员编号 */ private String memberCode; /** 手机号 */ private String phone; /** 密码 */ private String password; /** 昵称 */ private String nickname; /** 头像URL */ private String avatar; /** 性别(0未知 1男 2女) */ private String gender; /** 出生日期 */ private LocalDate birthday; /** 身份类型(1家长 2教师) */ private String identityType; /** 微信OpenID */ private String openId; /** 微信UnionID */ private String unionId; /** 所属区域ID(教师必填) */ private Long regionId; /** 所属学校ID(教师必填) */ private Long schoolId; /** 所属学校年级ID(教师必填) */ private Long schoolGradeId; /** 所属学校班级ID(教师必填) */ private Long schoolClassId; /** 注册来源(1小程序 2H5 3后台 4导入) */ private String registerSource; /** 注册时间 */ private LocalDateTime registerTime; /** 最后登录时间 */ private LocalDateTime lastLoginTime; /** 最后登录IP */ private String lastLoginIp; /** 登录次数 */ private Integer loginCount; /** 状态(0正常 1停用) */ private String status; /** 创建者 */ @TableField(fill = FieldFill.INSERT) private String createBy; /** 创建时间 */ @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; /** 更新者 */ @TableField(fill = FieldFill.INSERT_UPDATE) private String updateBy; /** 更新时间 */ @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; /** 删除标志(0存在 1删除) */ @TableLogic private String delFlag; /** 备注 */ private String remark; } ``` #### 4.3.2 会员VO(MemberVO.java) ```java package com.pangu.member.domain; import lombok.Data; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; /** * 会员视图对象 * @author pangu */ @Data public class MemberVO { private Long memberId; private String memberCode; private String phone; // 脱敏显示 private String phoneFull; // 完整手机号(编辑时使用) private String nickname; private String avatar; private String gender; private String genderName; private LocalDate birthday; private String identityType; private String identityTypeName; private String openId; private Long regionId; private String regionPath; // 区域路径 private Long schoolId; private String schoolName; private Long schoolGradeId; private String gradeName; private Long schoolClassId; private String className; private String registerSource; private String registerSourceName; private LocalDateTime registerTime; private String status; /** 绑定的学生列表 */ private List students; @Data public static class StudentVO { private Long studentId; private String studentName; private String studentNo; private String schoolName; private String gradeName; private String className; } } ``` ### 4.4 服务层设计 #### 4.4.1 会员服务接口(IMemberService.java) ```java package com.pangu.member.service; import com.baomidou.mybatisplus.extension.service.IService; import com.pangu.member.domain.Member; import com.pangu.member.domain.MemberDTO; import com.pangu.member.domain.MemberVO; import com.pangu.common.core.page.TableDataInfo; import java.util.List; /** * 会员服务接口 * @author pangu */ public interface IMemberService extends IService { /** * 查询会员列表 * @param memberDTO 查询条件 * @return 会员列表 */ TableDataInfo selectMemberList(MemberDTO memberDTO); /** * 根据ID获取会员详情 * @param memberId 会员ID * @return 会员详情 */ MemberVO getMemberById(Long memberId); /** * 新增会员 * @param memberDTO 会员信息 * @return 结果 */ int insertMember(MemberDTO memberDTO); /** * 修改会员 * @param memberDTO 会员信息 * @return 结果 */ int updateMember(MemberDTO memberDTO); /** * 删除会员 * @param memberId 会员ID * @return 结果 */ int deleteMember(Long memberId); /** * 重置密码 * @param memberId 会员ID * @return 新密码 */ String resetPassword(Long memberId); /** * 修改会员状态 * @param memberId 会员ID * @param status 状态 * @return 结果 */ int changeStatus(Long memberId, String status); /** * 绑定学生 * @param memberId 会员ID * @param studentId 学生ID * @return 结果 */ int bindStudent(Long memberId, Long studentId); /** * 解绑学生 * @param memberId 会员ID * @param studentId 学生ID * @return 结果 */ int unbindStudent(Long memberId, Long studentId); /** * 根据手机号查询会员 * @param phone 手机号 * @return 会员信息 */ Member getMemberByPhone(String phone); /** * 检查手机号是否唯一 * @param phone 手机号 * @param memberId 会员ID(编辑时排除自己) * @return 是否唯一 */ boolean checkPhoneUnique(String phone, Long memberId); /** * 校验会员是否可删除 * @param memberId 会员ID * @return 校验结果 */ boolean checkCanDelete(Long memberId); } ``` #### 4.4.2 会员服务实现(MemberServiceImpl.java) ```java package com.pangu.member.service.impl; import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.RandomUtil; import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.pangu.common.core.page.TableDataInfo; import com.pangu.common.exception.ServiceException; import com.pangu.member.domain.Member; import com.pangu.member.domain.MemberDTO; import com.pangu.member.domain.MemberVO; import com.pangu.member.mapper.MemberMapper; import com.pangu.member.service.IMemberService; import com.pangu.student.service.IStudentService; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.List; /** * 会员服务实现 * @author pangu */ @Service @RequiredArgsConstructor public class MemberServiceImpl extends ServiceImpl implements IMemberService { private final MemberMapper memberMapper; private final IStudentService studentService; private final BCryptPasswordEncoder passwordEncoder; @Override public TableDataInfo selectMemberList(MemberDTO memberDTO) { Page page = new Page<>(memberDTO.getPageNum(), memberDTO.getPageSize()); List list = memberMapper.selectMemberVOList(page, memberDTO); return TableDataInfo.build(list, page.getTotal()); } @Override public MemberVO getMemberById(Long memberId) { MemberVO memberVO = memberMapper.selectMemberVOById(memberId); if (memberVO == null) { throw new ServiceException("会员不存在"); } // 查询绑定的学生 memberVO.setStudents(studentService.selectStudentsByMemberId(memberId)); return memberVO; } @Override @Transactional(rollbackFor = Exception.class) public int insertMember(MemberDTO memberDTO) { // 校验手机号唯一性 if (!checkPhoneUnique(memberDTO.getPhone(), null)) { throw new ServiceException("手机号已存在"); } Member member = new Member(); // 生成会员编号 member.setMemberCode("JS" + System.currentTimeMillis()); member.setPhone(memberDTO.getPhone()); // 默认密码 member.setPassword(passwordEncoder.encode("123456")); // 昵称自动生成 member.setNickname(StrUtil.isBlank(memberDTO.getNickname()) ? "用户" + memberDTO.getPhone().substring(7) : memberDTO.getNickname()); member.setGender(memberDTO.getGender()); member.setBirthday(memberDTO.getBirthday()); member.setIdentityType(memberDTO.getIdentityType()); // 教师身份必须填写学校信息 if ("2".equals(memberDTO.getIdentityType())) { validateTeacherInfo(memberDTO); member.setRegionId(memberDTO.getRegionId()); member.setSchoolId(memberDTO.getSchoolId()); member.setSchoolGradeId(memberDTO.getSchoolGradeId()); member.setSchoolClassId(memberDTO.getSchoolClassId()); } member.setRegisterSource("3"); // 后台新增 member.setRegisterTime(LocalDateTime.now()); member.setStatus("0"); return memberMapper.insert(member); } @Override @Transactional(rollbackFor = Exception.class) public int updateMember(MemberDTO memberDTO) { // 校验手机号唯一性 if (!checkPhoneUnique(memberDTO.getPhone(), memberDTO.getMemberId())) { throw new ServiceException("手机号已存在"); } Member member = memberMapper.selectById(memberDTO.getMemberId()); if (member == null) { throw new ServiceException("会员不存在"); } member.setPhone(memberDTO.getPhone()); member.setNickname(memberDTO.getNickname()); member.setGender(memberDTO.getGender()); member.setBirthday(memberDTO.getBirthday()); member.setIdentityType(memberDTO.getIdentityType()); // 教师身份必须填写学校信息 if ("2".equals(memberDTO.getIdentityType())) { validateTeacherInfo(memberDTO); member.setRegionId(memberDTO.getRegionId()); member.setSchoolId(memberDTO.getSchoolId()); member.setSchoolGradeId(memberDTO.getSchoolGradeId()); member.setSchoolClassId(memberDTO.getSchoolClassId()); } else { // 家长不需要学校信息 member.setRegionId(null); member.setSchoolId(null); member.setSchoolGradeId(null); member.setSchoolClassId(null); } return memberMapper.updateById(member); } @Override @Transactional(rollbackFor = Exception.class) public int deleteMember(Long memberId) { // 检查是否可删除 if (!checkCanDelete(memberId)) { throw new ServiceException("该会员已绑定学生,请先解绑学生后再删除"); } return memberMapper.deleteById(memberId); } @Override public String resetPassword(Long memberId) { Member member = memberMapper.selectById(memberId); if (member == null) { throw new ServiceException("会员不存在"); } // 生成8位随机密码 String newPassword = RandomUtil.randomString(8); member.setPassword(passwordEncoder.encode(newPassword)); memberMapper.updateById(member); return newPassword; } @Override public int changeStatus(Long memberId, String status) { Member member = new Member(); member.setMemberId(memberId); member.setStatus(status); return memberMapper.updateById(member); } @Override @Transactional(rollbackFor = Exception.class) public int bindStudent(Long memberId, Long studentId) { Member member = memberMapper.selectById(memberId); if (member == null) { throw new ServiceException("会员不存在"); } // 教师只能绑定本校学生 if ("2".equals(member.getIdentityType())) { if (!studentService.isStudentInSchool(studentId, member.getSchoolId())) { throw new ServiceException("教师只能绑定本校学生"); } } return studentService.updateStudentMember(studentId, memberId); } @Override @Transactional(rollbackFor = Exception.class) public int unbindStudent(Long memberId, Long studentId) { // 解绑时将学生的memberId置空或设置为默认值 return studentService.unbindStudent(studentId, memberId); } @Override public Member getMemberByPhone(String phone) { return memberMapper.selectOne( new LambdaQueryWrapper().eq(Member::getPhone, phone) ); } @Override public boolean checkPhoneUnique(String phone, Long memberId) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Member::getPhone, phone); if (memberId != null) { wrapper.ne(Member::getMemberId, memberId); } return memberMapper.selectCount(wrapper) == 0; } @Override public boolean checkCanDelete(Long memberId) { // 检查是否有绑定的学生 return studentService.countByMemberId(memberId) == 0; } /** * 校验教师信息完整性 */ private void validateTeacherInfo(MemberDTO memberDTO) { if (memberDTO.getRegionId() == null) { throw new ServiceException("请选择所属区域"); } if (memberDTO.getSchoolId() == null) { throw new ServiceException("请选择所属学校"); } if (memberDTO.getSchoolGradeId() == null) { throw new ServiceException("请选择所属年级"); } if (memberDTO.getSchoolClassId() == null) { throw new ServiceException("请选择所属班级"); } } } ``` ### 4.5 控制器设计 ```java package com.pangu.member.controller; import com.pangu.common.annotation.Log; import com.pangu.common.core.controller.BaseController; import com.pangu.common.core.domain.AjaxResult; import com.pangu.common.core.page.TableDataInfo; import com.pangu.common.enums.BusinessType; import com.pangu.member.domain.MemberDTO; import com.pangu.member.domain.MemberVO; import com.pangu.member.service.IMemberService; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; /** * 会员管理控制器 * @author pangu */ @RestController @RequestMapping("/member") @RequiredArgsConstructor public class MemberController extends BaseController { private final IMemberService memberService; /** * 查询会员列表 */ @PreAuthorize("@ss.hasPermi('user:member:list')") @GetMapping("/list") public TableDataInfo list(MemberDTO memberDTO) { return memberService.selectMemberList(memberDTO); } /** * 获取会员详情 */ @PreAuthorize("@ss.hasPermi('user:member:query')") @GetMapping("/{memberId}") public AjaxResult getInfo(@PathVariable Long memberId) { return success(memberService.getMemberById(memberId)); } /** * 新增会员 */ @PreAuthorize("@ss.hasPermi('user:member:add')") @Log(title = "会员管理", businessType = BusinessType.INSERT) @PostMapping public AjaxResult add(@Validated @RequestBody MemberDTO memberDTO) { return toAjax(memberService.insertMember(memberDTO)); } /** * 修改会员 */ @PreAuthorize("@ss.hasPermi('user:member:edit')") @Log(title = "会员管理", businessType = BusinessType.UPDATE) @PutMapping public AjaxResult edit(@Validated @RequestBody MemberDTO memberDTO) { return toAjax(memberService.updateMember(memberDTO)); } /** * 删除会员 */ @PreAuthorize("@ss.hasPermi('user:member:remove')") @Log(title = "会员管理", businessType = BusinessType.DELETE) @DeleteMapping("/{memberId}") public AjaxResult remove(@PathVariable Long memberId) { return toAjax(memberService.deleteMember(memberId)); } /** * 重置密码 */ @PreAuthorize("@ss.hasPermi('user:member:resetPwd')") @Log(title = "会员管理", businessType = BusinessType.UPDATE) @PutMapping("/resetPwd/{memberId}") public AjaxResult resetPwd(@PathVariable Long memberId) { String newPassword = memberService.resetPassword(memberId); return AjaxResult.success("密码重置成功").put("password", newPassword); } /** * 修改状态 */ @PreAuthorize("@ss.hasPermi('user:member:edit')") @Log(title = "会员管理", businessType = BusinessType.UPDATE) @PutMapping("/changeStatus") public AjaxResult changeStatus(@RequestBody MemberDTO memberDTO) { return toAjax(memberService.changeStatus(memberDTO.getMemberId(), memberDTO.getStatus())); } /** * 绑定学生 */ @PreAuthorize("@ss.hasPermi('user:member:edit')") @Log(title = "会员管理", businessType = BusinessType.UPDATE) @PostMapping("/bindStudent") public AjaxResult bindStudent(@RequestBody MemberDTO memberDTO) { return toAjax(memberService.bindStudent(memberDTO.getMemberId(), memberDTO.getStudentId())); } /** * 解绑学生 */ @PreAuthorize("@ss.hasPermi('user:member:edit')") @Log(title = "会员管理", businessType = BusinessType.UPDATE) @DeleteMapping("/unbindStudent/{memberId}/{studentId}") public AjaxResult unbindStudent(@PathVariable Long memberId, @PathVariable Long studentId) { return toAjax(memberService.unbindStudent(memberId, studentId)); } } ``` ### 4.6 数据访问层 ```java package com.pangu.member.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.pangu.member.domain.Member; import com.pangu.member.domain.MemberDTO; import com.pangu.member.domain.MemberVO; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import java.util.List; /** * 会员数据访问 * @author pangu */ @Mapper public interface MemberMapper extends BaseMapper { /** * 查询会员列表(带关联信息) */ List selectMemberVOList(Page page, @Param("dto") MemberDTO dto); /** * 根据ID查询会员详情(带关联信息) */ MemberVO selectMemberVOById(@Param("memberId") Long memberId); } ``` **Mapper XML** ```xml m.member_id, m.member_code, m.phone, m.nickname, m.gender, m.birthday, m.identity_type, m.open_id, m.region_id, m.school_id, m.school_grade_id, m.school_class_id, m.register_source, m.register_time, m.status, s.school_name, s.region_path, g.grade_name, c.class_name SELECT FROM pg_member m LEFT JOIN pg_school s ON m.school_id = s.school_id LEFT JOIN pg_school_grade sg ON m.school_grade_id = sg.id LEFT JOIN pg_grade g ON sg.grade_id = g.grade_id LEFT JOIN pg_school_class sc ON m.school_class_id = sc.id LEFT JOIN pg_class c ON sc.class_id = c.class_id WHERE m.del_flag = '0' AND m.phone LIKE CONCAT('%', #{dto.phone}, '%') AND m.nickname LIKE CONCAT('%', #{dto.nickname}, '%') AND m.identity_type = #{dto.identityType} AND m.status = #{dto.status} AND m.register_time >= #{dto.beginTime} AND m.register_time <= #{dto.endTime} ${dto.params.dataScope} ORDER BY m.register_time DESC SELECT FROM pg_member m LEFT JOIN pg_school s ON m.school_id = s.school_id LEFT JOIN pg_school_grade sg ON m.school_grade_id = sg.id LEFT JOIN pg_grade g ON sg.grade_id = g.grade_id LEFT JOIN pg_school_class sc ON m.school_class_id = sc.id LEFT JOIN pg_class c ON sc.class_id = c.class_id WHERE m.member_id = #{memberId} AND m.del_flag = '0' ``` --- ## 5. 数据库设计 ### 5.1 表结构 详见《数据库设计文档_v1.0.md》第3.8节"会员表(pg_member)"。 ### 5.2 索引设计 | 索引名 | 索引类型 | 索引字段 | 说明 | |-------|---------|---------|------| | uk_member_code | UNIQUE | member_code | 会员编号唯一 | | uk_phone | UNIQUE | phone | 手机号唯一 | | idx_open_id | INDEX | open_id | 微信登录查询 | | idx_school_id | INDEX | school_id | 按学校查询 | | idx_identity_type | INDEX | identity_type | 按身份类型查询 | | idx_register_time | INDEX | register_time | 按注册时间排序 | ### 5.3 示例数据 ```sql INSERT INTO pg_member (member_id, member_code, phone, password, nickname, gender, identity_type, region_id, school_id, register_source, register_time, status) VALUES (1, 'JS123123123', '13207166213', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '张三家长', '1', '1', NULL, NULL, '1', NOW(), '0'), (2, 'JS123123124', '13807166214', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '李老师', '2', '2', 111, 1, '1', NOW(), '0'); ``` --- ## 6. 接口设计 详见《接口设计文档_v1.0.md》第4章"会员管理接口"。 ### 6.1 接口清单 | 接口路径 | 方法 | 说明 | 权限 | |---------|------|------|------| | GET /api/member/list | GET | 查询会员列表 | user:member:list | | GET /api/member/{id} | GET | 获取会员详情 | user:member:query | | POST /api/member | POST | 新增会员 | user:member:add | | PUT /api/member | PUT | 修改会员 | user:member:edit | | DELETE /api/member/{id} | DELETE | 删除会员 | user:member:remove | | PUT /api/member/resetPwd/{id} | PUT | 重置密码 | user:member:resetPwd | | PUT /api/member/changeStatus | PUT | 修改状态 | user:member:edit | | POST /api/member/bindStudent | POST | 绑定学生 | user:member:edit | | DELETE /api/member/unbindStudent/{memberId}/{studentId} | DELETE | 解绑学生 | user:member:edit | --- ## 7. 开发阶段计划 ### 7.1 阶段划分 | 阶段 | 任务 | 交付物 | |-----|------|-------| | **阶段一:后端开发** | 数据库表创建、实体类、Mapper、Service、Controller | 接口可调用 | | **阶段二:前端开发** | 页面组件开发、API对接、功能联调 | 页面可操作 | | **阶段三:功能测试** | 单元测试、接口测试、功能测试 | 测试报告 | | **阶段四:集成部署** | 代码合并、环境部署、验收测试 | 上线部署 | ### 7.2 详细任务分解 #### 阶段一:后端开发 | 序号 | 任务 | 负责人 | 备注 | |:---:|-----|-------|------| | 1.1 | 创建pg_member表 | - | 包含索引 | | 1.2 | 创建Member实体类 | - | 含VO/DTO | | 1.3 | 开发MemberMapper | - | 含XML映射 | | 1.4 | 开发IMemberService | - | 接口定义 | | 1.5 | 开发MemberServiceImpl | - | 业务逻辑 | | 1.6 | 开发MemberController | - | REST接口 | | 1.7 | 配置权限菜单 | - | 菜单和按钮 | | 1.8 | 接口测试 | - | Postman测试 | #### 阶段二:前端开发 | 序号 | 任务 | 负责人 | 备注 | |:---:|-----|-------|------| | 2.1 | 开发member.js API | - | 接口封装 | | 2.2 | 开发会员列表页 | - | index.vue | | 2.3 | 开发会员编辑页 | - | form.vue | | 2.4 | 开发学生选择弹窗 | - | 组件 | | 2.5 | 开发密码显示弹窗 | - | 组件 | | 2.6 | 配置路由 | - | router配置 | | 2.7 | 前后端联调 | - | 功能验证 | #### 阶段三:功能测试 | 序号 | 任务 | 负责人 | 备注 | |:---:|-----|-------|------| | 3.1 | 编写单元测试 | - | JUnit | | 3.2 | 接口测试 | - | API测试 | | 3.3 | 功能测试 | - | 页面操作 | | 3.4 | 业务规则验证 | - | 规则校验 | | 3.5 | 权限测试 | - | 角色权限 | | 3.6 | 问题修复 | - | Bug修复 | #### 阶段四:集成部署 | 序号 | 任务 | 负责人 | 备注 | |:---:|-----|-------|------| | 4.1 | 代码评审 | - | Code Review | | 4.2 | 合并代码 | - | Git操作 | | 4.3 | 测试环境部署 | - | 环境配置 | | 4.4 | UAT验收测试 | - | 用户验收 | | 4.5 | 生产环境部署 | - | 上线 | --- ## 8. 测试方案 ### 8.1 单元测试 ```java @SpringBootTest class MemberServiceTest { @Autowired private IMemberService memberService; @Test void testInsertMember() { MemberDTO dto = new MemberDTO(); dto.setPhone("13812345678"); dto.setIdentityType("1"); int result = memberService.insertMember(dto); assertEquals(1, result); } @Test void testCheckPhoneUnique() { boolean unique = memberService.checkPhoneUnique("13812345678", null); // 根据数据库状态断言 } @Test void testResetPassword() { String newPwd = memberService.resetPassword(1L); assertNotNull(newPwd); assertEquals(8, newPwd.length()); } } ``` ### 8.2 功能测试用例 | 用例编号 | 用例名称 | 前置条件 | 测试步骤 | 预期结果 | |---------|---------|---------|---------|---------| | TC-001 | 会员列表查询 | 已登录 | 1.进入会员管理页面 2.输入搜索条件 3.点击搜索 | 列表显示符合条件的数据 | | TC-002 | 新增家长会员 | 已登录 | 1.点击新增 2.填写手机号 3.选择家长身份 4.点击保存 | 新增成功,列表显示新数据 | | TC-003 | 新增教师会员 | 已登录 | 1.点击新增 2.填写手机号 3.选择教师身份 4.选择学校信息 5.保存 | 新增成功 | | TC-004 | 手机号重复校验 | 已存在会员 | 1.新增会员 2.输入已存在的手机号 3.保存 | 提示"手机号已存在" | | TC-005 | 教师信息必填校验 | 已登录 | 1.新增会员 2.选择教师身份 3.不填学校信息 4.保存 | 提示相关必填项 | | TC-006 | 重置密码 | 已存在会员 | 1.点击重置密码 2.确认操作 | 弹窗显示新密码,可复制 | | TC-007 | 删除会员(无绑定) | 会员无绑定学生 | 1.点击删除 2.确认 | 删除成功 | | TC-008 | 删除会员(有绑定) | 会员已绑定学生 | 1.点击删除 2.确认 | 提示"请先解绑学生" | | TC-009 | 绑定学生-家长 | 家长会员 | 1.编辑会员 2.点击绑定学生 3.选择任意学生 | 绑定成功 | | TC-010 | 绑定学生-教师 | 教师会员 | 1.编辑会员 2.点击绑定学生 3.选择非本校学生 | 提示"只能绑定本校学生" | | TC-011 | 禁用会员 | 已存在会员 | 1.切换状态开关为禁用 | 状态变更成功 | | TC-012 | 数据权限-分公司 | 分公司用户登录 | 1.进入会员列表 | 只显示所属区域会员 | ### 8.3 性能测试 | 测试项 | 测试场景 | 性能指标 | |-------|---------|---------| | 列表查询 | 10万条数据分页查询 | 响应时间 ≤ 500ms | | 新增会员 | 并发100用户新增 | 成功率 ≥ 99% | | 重置密码 | 并发50用户操作 | 响应时间 ≤ 300ms | --- ## 9. 部署方案 ### 9.1 环境配置 | 环境 | 用途 | 配置 | |-----|------|------| | 开发环境 | 开发调试 | 本地MySQL、Redis | | 测试环境 | 功能测试 | 测试服务器 | | 生产环境 | 正式运行 | 生产服务器集群 | ### 9.2 配置项 ```yaml # application-prod.yml pangu: member: # 默认密码(批量导入时使用) default-password: 123456 # 密码重置长度 reset-password-length: 8 # 会员编号前缀 code-prefix: JS ``` ### 9.3 菜单配置 ```sql -- 会员管理菜单 INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, menu_type, perms, icon) VALUES (2000, '用户管理', 0, 2, 'user', NULL, 'M', NULL, 'user'), (2010, '会员管理', 2000, 1, 'member', 'user/member/index', 'C', 'user:member:list', 'peoples'), (2011, '会员查询', 2010, 1, '#', '', 'F', 'user:member:query', '#'), (2012, '会员新增', 2010, 2, '#', '', 'F', 'user:member:add', '#'), (2013, '会员修改', 2010, 3, '#', '', 'F', 'user:member:edit', '#'), (2014, '会员删除', 2010, 4, '#', '', 'F', 'user:member:remove', '#'), (2015, '重置密码', 2010, 5, '#', '', 'F', 'user:member:resetPwd', '#'); ``` --- ## 10. 风险评估 ### 10.1 技术风险 | 风险项 | 风险等级 | 应对措施 | |-------|:-------:|---------| | 手机号唯一性并发问题 | 中 | 使用数据库唯一索引 + 业务层校验 | | 密码安全性 | 高 | BCrypt加密,密码复杂度校验 | | 数据权限泄露 | 高 | 严格的数据权限控制 | | 接口性能问题 | 中 | 合理的索引设计,分页查询 | ### 10.2 业务风险 | 风险项 | 风险等级 | 应对措施 | |-------|:-------:|---------| | 教师绑定错误学生 | 中 | 严格校验本校学生 | | 误删除会员 | 低 | 软删除机制,删除前校验 | | 密码泄露 | 高 | 重置密码后一次性显示,建议用户修改 | --- ## 审核签字 | 角色 | 姓名 | 日期 | 签字 | |-----|------|------|------| | 技术负责人 | | | | | 前端负责人 | | | | | 后端负责人 | | | | | 测试负责人 | | | | --- *文档结束*