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

1687 lines
61 KiB
Markdown
Raw Normal View History

# 会员管理模块技术方案
---
| 文档信息 | 内容 |
|---------|------|
| **文档版本** | 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
<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 业务风险
| 风险项 | 风险等级 | 应对措施 |
|-------|:-------:|---------|
| 教师绑定错误学生 | 中 | 严格校验本校学生 |
| 误删除会员 | 低 | 软删除机制,删除前校验 |
| 密码泄露 | 高 | 重置密码后一次性显示,建议用户修改 |
---
## 审核签字
| 角色 | 姓名 | 日期 | 签字 |
|-----|------|------|------|
| 技术负责人 | | | |
| 前端负责人 | | | |
| 后端负责人 | | | |
| 测试负责人 | | | |
---
*文档结束*