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

61 KiB
Raw Blame History

会员管理模块技术方案


文档信息 内容
文档版本 V1.0
项目名称 盘古用户平台Pangu User Platform
模块名称 会员管理模块
编写团队 pangu
创建日期 2026-01-31
评审状态 待评审

修订记录

版本 日期 修订人 修订内容
V1.0 2026-01-31 pangu 初稿

目录

  1. 概述
  2. 需求分析
  3. 前端技术方案
  4. 后端技术方案
  5. 数据库设计
  6. 接口设计
  7. 开发阶段计划
  8. 测试方案
  9. 部署方案
  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 ... >   │   │
│                                      └──────────────────────┘   │
└─────────────────────────────────────────────────────────────────┘

核心代码结构

<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

页面布局

┌─────────────────────────────────────────────────────────────────┐
│  页面标题:新增会员 / 编辑会员                                    │
├─────────────────────────────────────────────────────────────────┤
│  基本信息                                                        │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │ 手机号 *   [_______________]                              │  │
│  │ 昵称       [_______________]                              │  │
│  │ 性别       ○ 未知  ○ 男  ○ 女                             │  │
│  │ 出生日期   [_______________]                              │  │
│  │ 身份类型 * [___家长▼_______]                              │  │
│  └──────────────────────────────────────────────────────────┘  │
│                                                                  │
│  教师信息(身份类型为教师时显示)                                  │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │ 所属区域 * [___区域选择___]                               │  │
│  │ 所属学校 * [___学校选择___]                               │  │
│  │ 所属年级 * [___年级选择___]                               │  │
│  │ 所属班级 * [___班级选择___]                               │  │
│  └──────────────────────────────────────────────────────────┘  │
│                                                                  │
│  绑定学生                                                        │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │ ┌────────┐                                               │  │
│  │ │ + 绑定  │                                               │  │
│  │ └────────┘                                               │  │
│  │ ┌──────┬────────┬────────┬────────┬────────┬────────┐    │  │
│  │ │ 姓名 │ 学号   │ 学校   │ 年级   │ 班级   │ 操作   │    │  │
│  │ ├──────┼────────┼────────┼────────┼────────┼────────┤    │  │
│  │ │ ...  │ ...    │ ...    │ ...    │ ...    │ 解绑   │    │  │
│  │ └──────┴────────┴────────┴────────┴────────┴────────┘    │  │
│  └──────────────────────────────────────────────────────────┘  │
│                                                                  │
│                              ┌────────┐ ┌────────┐              │
│                              │  保存  │ │  取消  │              │
│                              └────────┘ └────────┘              │
└─────────────────────────────────────────────────────────────────┘

核心代码结构

<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 状态管理

// 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 表单校验规则

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 工具函数

// 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

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

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

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

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 控制器设计

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 数据访问层

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 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 示例数据

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 单元测试

@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 配置项

# application-prod.yml
pangu:
  member:
    # 默认密码(批量导入时使用)
    default-password: 123456
    # 密码重置长度
    reset-password-length: 8
    # 会员编号前缀
    code-prefix: JS

9.3 菜单配置

-- 会员管理菜单
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 业务风险

风险项 风险等级 应对措施
教师绑定错误学生 严格校验本校学生
误删除会员 软删除机制,删除前校验
密码泄露 重置密码后一次性显示,建议用户修改

审核签字

角色 姓名 日期 签字
技术负责人
前端负责人
后端负责人
测试负责人

文档结束