pangu-user-platform/docs/05-模块技术方案/会员管理/会员管理模块技术方案_v1.0.md

1687 lines
61 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 会员管理模块技术方案
---
| 文档信息 | 内容 |
|---------|------|
| **文档版本** | 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 目录结构
```
frontend/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
<template>
<div class="member-container">
<!-- 搜索区域 -->
<el-form :model="queryParams" ref="queryRef" :inline="true">
<el-form-item label="手机号" prop="phone">
<el-input v-model="queryParams.phone" placeholder="请输入手机号" clearable />
</el-form-item>
<el-form-item label="昵称" prop="nickname">
<el-input v-model="queryParams.nickname" placeholder="请输入昵称" clearable />
</el-form-item>
<el-form-item label="身份类型" prop="identityType">
<el-select v-model="queryParams.identityType" placeholder="请选择" clearable>
<el-option label="家长" value="1" />
<el-option label="教师" value="2" />
</el-select>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="请选择" clearable>
<el-option label="正常" value="0" />
<el-option label="停用" value="1" />
</el-select>
</el-form-item>
<el-form-item label="注册时间">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="-"
start-placeholder="开始日期"
end-placeholder="结束日期"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery">搜索</el-button>
<el-button @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<!-- 操作按钮 -->
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button type="primary" @click="handleAdd">新增</el-button>
</el-col>
</el-row>
<!-- 列表表格 -->
<el-table v-loading="loading" :data="memberList">
<el-table-column label="会员编号" prop="memberCode" width="150" />
<el-table-column label="手机号" prop="phone" width="120" />
<el-table-column label="昵称" prop="nickname" />
<el-table-column label="性别" prop="gender" width="80">
<template #default="scope">
{{ genderFormat(scope.row.gender) }}
</template>
</el-table-column>
<el-table-column label="身份类型" prop="identityType" width="100">
<template #default="scope">
<el-tag :type="scope.row.identityType === '1' ? '' : 'success'">
{{ scope.row.identityType === '1' ? '家长' : '教师' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="注册时间" prop="registerTime" width="160" />
<el-table-column label="状态" prop="status" width="80">
<template #default="scope">
<el-switch
v-model="scope.row.status"
active-value="0"
inactive-value="1"
@change="handleStatusChange(scope.row)"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="scope">
<el-button link type="primary" @click="handleEdit(scope.row)">编辑</el-button>
<el-button link type="primary" @click="handleResetPwd(scope.row)">重置密码</el-button>
<el-popconfirm
title="确认删除该会员?"
@confirm="handleDelete(scope.row)"
>
<template #reference>
<el-button link type="danger">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="queryParams.pageNum"
v-model:page-size="queryParams.pageSize"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
<!-- 密码重置弹窗 -->
<el-dialog v-model="passwordDialogVisible" title="密码重置成功" width="400px">
<div class="password-display">
<span>新密码:</span>
<span class="password-text">{{ newPassword }}</span>
<el-button type="primary" link @click="copyPassword">复制</el-button>
</div>
</el-dialog>
</div>
</template>
```
#### 3.3.2 会员编辑页form.vue
**页面布局**
```
┌─────────────────────────────────────────────────────────────────┐
│ 页面标题:新增会员 / 编辑会员 │
├─────────────────────────────────────────────────────────────────┤
│ 基本信息 │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 手机号 * [_______________] │ │
│ │ 昵称 [_______________] │ │
│ │ 性别 ○ 未知 ○ 男 ○ 女 │ │
│ │ 出生日期 [_______________] │ │
│ │ 身份类型 * [___家长▼_______] │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ 教师信息(身份类型为教师时显示) │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 所属区域 * [___区域选择___] │ │
│ │ 所属学校 * [___学校选择___] │ │
│ │ 所属年级 * [___年级选择___] │ │
│ │ 所属班级 * [___班级选择___] │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ 绑定学生 │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ ┌────────┐ │ │
│ │ │ + 绑定 │ │ │
│ │ └────────┘ │ │
│ │ ┌──────┬────────┬────────┬────────┬────────┬────────┐ │ │
│ │ │ 姓名 │ 学号 │ 学校 │ 年级 │ 班级 │ 操作 │ │ │
│ │ ├──────┼────────┼────────┼────────┼────────┼────────┤ │ │
│ │ │ ... │ ... │ ... │ ... │ ... │ 解绑 │ │ │
│ │ └──────┴────────┴────────┴────────┴────────┴────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────┐ ┌────────┐ │
│ │ 保存 │ │ 取消 │ │
│ └────────┘ └────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
**核心代码结构**
```vue
<template>
<div class="member-form-container">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<!-- 基本信息 -->
<el-divider content-position="left">基本信息</el-divider>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="手机号" prop="phone">
<el-input v-model="form.phone" placeholder="请输入手机号" maxlength="11" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="昵称" prop="nickname">
<el-input v-model="form.nickname" placeholder="请输入昵称(不填则自动生成)" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="性别" prop="gender">
<el-radio-group v-model="form.gender">
<el-radio value="0">未知</el-radio>
<el-radio value="1">男</el-radio>
<el-radio value="2">女</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="出生日期" prop="birthday">
<el-date-picker v-model="form.birthday" type="date" placeholder="请选择出生日期" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="身份类型" prop="identityType">
<el-select v-model="form.identityType" placeholder="请选择身份类型" @change="handleIdentityChange">
<el-option label="家长" value="1" />
<el-option label="教师" value="2" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态" prop="status">
<el-switch v-model="form.status" active-value="0" inactive-value="1" />
</el-form-item>
</el-col>
</el-row>
<!-- 教师信息(身份类型为教师时显示) -->
<template v-if="form.identityType === '2'">
<el-divider content-position="left">教师信息</el-divider>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="所属区域" prop="regionId">
<el-cascader
v-model="form.regionIds"
:options="regionTree"
:props="{ value: 'id', label: 'label', children: 'children' }"
placeholder="请选择区域"
@change="handleRegionChange"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="所属学校" prop="schoolId">
<el-select v-model="form.schoolId" placeholder="请选择学校" @change="handleSchoolChange">
<el-option
v-for="item in schoolList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="所属年级" prop="schoolGradeId">
<el-select v-model="form.schoolGradeId" placeholder="请选择年级" @change="handleGradeChange">
<el-option
v-for="item in gradeList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="所属班级" prop="schoolClassId">
<el-select v-model="form.schoolClassId" placeholder="请选择班级">
<el-option
v-for="item in classList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
</template>
<!-- 绑定学生 -->
<el-divider content-position="left">绑定学生</el-divider>
<el-button type="primary" @click="openStudentDialog">+ 绑定学生</el-button>
<el-table :data="form.students" style="margin-top: 16px">
<el-table-column label="姓名" prop="studentName" />
<el-table-column label="学号" prop="studentNo" />
<el-table-column label="学校" prop="schoolName" />
<el-table-column label="年级" prop="gradeName" />
<el-table-column label="班级" prop="className" />
<el-table-column label="操作" width="100">
<template #default="scope">
<el-popconfirm title="确认解绑该学生?" @confirm="handleUnbind(scope.row)">
<template #reference>
<el-button link type="danger">解绑</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<!-- 按钮 -->
<el-form-item style="margin-top: 24px">
<el-button type="primary" @click="submitForm">保存</el-button>
<el-button @click="cancel">取消</el-button>
</el-form-item>
</el-form>
<!-- 学生选择弹窗 -->
<student-select-dialog
v-model:visible="studentDialogVisible"
:identity-type="form.identityType"
:school-id="form.schoolId"
:exclude-ids="form.students.map(s => s.studentId)"
@confirm="handleStudentSelected"
/>
</div>
</template>
```
### 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 会员VOMemberVO.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<StudentVO> 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<Member> {
/**
* 查询会员列表
* @param memberDTO 查询条件
* @return 会员列表
*/
TableDataInfo<MemberVO> 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<MemberMapper, Member> implements IMemberService {
private final MemberMapper memberMapper;
private final IStudentService studentService;
private final BCryptPasswordEncoder passwordEncoder;
@Override
public TableDataInfo<MemberVO> selectMemberList(MemberDTO memberDTO) {
Page<MemberVO> page = new Page<>(memberDTO.getPageNum(), memberDTO.getPageSize());
List<MemberVO> 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<Member>().eq(Member::getPhone, phone)
);
}
@Override
public boolean checkPhoneUnique(String phone, Long memberId) {
LambdaQueryWrapper<Member> 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<MemberVO> 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<Member> {
/**
* 查询会员列表(带关联信息)
*/
List<MemberVO> selectMemberVOList(Page<MemberVO> page, @Param("dto") MemberDTO dto);
/**
* 根据ID查询会员详情带关联信息
*/
MemberVO selectMemberVOById(@Param("memberId") Long memberId);
}
```
**Mapper XML**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.pangu.member.mapper.MemberMapper">
<resultMap id="MemberVOResult" type="com.pangu.member.domain.MemberVO">
<id property="memberId" column="member_id"/>
<result property="memberCode" column="member_code"/>
<result property="phone" column="phone"/>
<result property="nickname" column="nickname"/>
<result property="gender" column="gender"/>
<result property="birthday" column="birthday"/>
<result property="identityType" column="identity_type"/>
<result property="openId" column="open_id"/>
<result property="regionId" column="region_id"/>
<result property="regionPath" column="region_path"/>
<result property="schoolId" column="school_id"/>
<result property="schoolName" column="school_name"/>
<result property="schoolGradeId" column="school_grade_id"/>
<result property="gradeName" column="grade_name"/>
<result property="schoolClassId" column="school_class_id"/>
<result property="className" column="class_name"/>
<result property="registerSource" column="register_source"/>
<result property="registerTime" column="register_time"/>
<result property="status" column="status"/>
</resultMap>
<sql id="selectMemberVOColumns">
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
</sql>
<select id="selectMemberVOList" resultMap="MemberVOResult">
SELECT <include refid="selectMemberVOColumns"/>
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'
<if test="dto.phone != null and dto.phone != ''">
AND m.phone LIKE CONCAT('%', #{dto.phone}, '%')
</if>
<if test="dto.nickname != null and dto.nickname != ''">
AND m.nickname LIKE CONCAT('%', #{dto.nickname}, '%')
</if>
<if test="dto.identityType != null and dto.identityType != ''">
AND m.identity_type = #{dto.identityType}
</if>
<if test="dto.status != null and dto.status != ''">
AND m.status = #{dto.status}
</if>
<if test="dto.beginTime != null">
AND m.register_time &gt;= #{dto.beginTime}
</if>
<if test="dto.endTime != null">
AND m.register_time &lt;= #{dto.endTime}
</if>
<!-- 数据权限 -->
${dto.params.dataScope}
ORDER BY m.register_time DESC
</select>
<select id="selectMemberVOById" resultMap="MemberVOResult">
SELECT <include refid="selectMemberVOColumns"/>
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'
</select>
</mapper>
```
---
## 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 业务风险
| 风险项 | 风险等级 | 应对措施 |
|-------|:-------:|---------|
| 教师绑定错误学生 | 中 | 严格校验本校学生 |
| 误删除会员 | 低 | 软删除机制,删除前校验 |
| 密码泄露 | 高 | 重置密码后一次性显示,建议用户修改 |
---
## 审核签字
| 角色 | 姓名 | 日期 | 签字 |
|-----|------|------|------|
| 技术负责人 | | | |
| 前端负责人 | | | |
| 后端负责人 | | | |
| 测试负责人 | | | |
---
*文档结束*