1 引言

本用户管理中心项目,是基于SpringBoot后端和React前端的全栈项目,前后端分离,使用一些企业中流行的框架,实现用户注册、登录、查询等基础功能。

1.1 本项目的业务流程

管理员注册 => 管理员登录 => 管理员在主界面中浏览和删除用户

1.2 项目地址

前端:https://github.com/saikaisa/user-center-frontend

后端:https://github.com/saikaisa/user-center-backend

1.3 项目预览

8045c5dc636514247a53cc96f81a9b08.png

567d540daaf313dc5e899d967cd611b7.png

1a20c3cb03b79a74bbe2303d3baa7d98.png

c3a5d932357249e22cbba0a66bbc85ca.png

2 需求分析

用户中心是一个后台系统,可用于各种项目系统,对用户实现统一的管理。

  1. 系统角色
    1. 用户(User):能够注册账号、登录系统。
    2. 管理员(Administrator):拥有用户管理权限,在用户的基础上,还有查询用户信息、删除用户的权限。(主要角色)
  2. 总体需求
    1. 用户的登录/注册功能
    2. 用户管理(查询及删除)功能(需要鉴权,仅管理员可以管理用户)
    3. 用户校验功能(仅授权用户可访问注册该用户中心后台)

41d2b566870bea09f6269bda061d20b8.png

3 项目设计

3.1 技术选型

项目使用了多层框架,极大程度地精简了效率低下、技术含量低的重复代码的编写过程,将重心放在业务逻辑的处理和项目整体结构的研究上。

3.1.1 前端

主要使用阿里的Ant Design生态:

  • HTML + CSS + JavaScript三件套(基础,不直接使用)
  • React开发框架(已被Ant Design封装,不直接使用)
  • Umi开发框架:实现项目路由、构建和部署等功能的工具,并与Ant Design一同作为该项目的基础
  • Ant Design组件库:项目基础,与Umi协同管理项目
  • Ant Design Pro项目模板:用于快捷生成前端页面,并且可自定义,迅速实现需要的功能
  • Umi Request请求库:实现前后端交互的核心
  • TypeScript(JavaScript的超集,在js的基础上增加了类型限制):该项目前端主要使用的语言

3.1.2 后端

以Spring为核心:

  • Java:主要使用的语言
  • Maven:项目管理工具
  • Spring全家桶 (Spring + SpringMVC + SpringBoot):实现依赖注入,管理Java对象,提供接口访问,自动配置和快速集成项目等功能
  • MyBatis + MyBatis Plus数据访问框架
    • MyBatis:Java 操作数据库的框架,持久层框架,对 jdbc 的封装
    • MyBatis Plus:对MyBatis的进一步封装,提供了许多更方便的操作数据库的方法,极大提高开发效率
    • MyBatisX(IDEA插件):自动根据数据库生成 domain 实体对象、mapper(操作数据库的对象)、mapper.xml(定义了 mapper对象和数据库的关联)、service(实现业务逻辑)、serviceImpl(具体实现 service)
  • MySQL数据库:存储和管理数据
  • JUnit单元测试库:对Java项目进行单元测试,能更高效的对特定模块进行检查和排错

3.1.3 部署

部署在Linux云服务器上。

前端部署

  • 使用Umi自带功能,打包时自动区分生产环境和开发环境
  • 部署在Nginx服务器上

后端部署

  • 通过SpringBoot的配置文件和maven打包编译时传参,区分生产环境和开发环境

  • 使用Maven打包

  • Java启动SpringBoot项目

    在下一个板块——项目开发中,我们将一一提到这些组件的使用流程和详细功能。

3.2 设计的主要内容

3.2.1 后端功能开发

Service层和数据层

  1. 设计一个合适的数据库,用于存储用户信息

  2. 实现注册逻辑

  3. 实现登录逻辑

    • 登录接口:用于返回脱敏后的用户信息
      • 登录逻辑
      • 登录态:登录成功后需要保存用户登录态(用session)
  4. 实现用户管理接口

    • 管理前鉴权
      • 根据用户名查询用户
      • 删除用户
  5. 实现注销(退出登录)逻辑:删除登录态

  6. 补充用户注册校验逻辑:需使用邀请码注册

Controller层

  1. 配置各接口对应的url和method,并编写相应方法
  2. 封装注册和登录请求,创建请求实体类

3.2.2 前端功能开发

  1. 生成并修改登录/注册页面
  2. 实现前后端的交互
  3. 在前端使用正向代理解决跨域问题
  4. 使用Request请求后端(可视为封装过的ajax)
  5. 获取用户登录态,实现当前登录用户信息接口
  6. 实现用户管理后台的界面开发
  7. 实现注销(退出登录)功能
  8. 实现页面跳转以及重定向逻辑:登录/注册/注销以及权限不同等会导致页面跳转
  9. 补充用户注册校验功能:需使用邀请码注册

3.2.3 后端优化

  1. 创建通用返回对象(BaseResponse):给返回对象补充一些描述,告诉前端这个请求在业务层面上是成功还是失败
    1. 自定义错误码、描述信息
    2. 返回正常或错误
  2. 封装全局异常处理
    1. 自定义业务异常类(BusinessException)
      1. 相比Java自带异常类,能支持更多高级情况
      2. 自定义程度高,使用更灵活
    2. 编写全局异常处理器(使用Spring AOP)
      1. 捕获代码中所有的异常,只返回规定的异常,让前端得到更详细的业务报错
      2. 同时屏蔽项目本身异常,不暴露服务器内部状态,提高安全性

3.2.4 前端优化

  1. 为了能对接优化后的后端的返回值,需要定义一个相应的类,将返回的用户信息封装在其中的data属性中

  2. 全局响应处理:对接口的通用响应进行统一处理

    1. 统一从返回成功的response中取出data
    2. 根据错误码去集中处理错误(比如用户未登录、没权限之类的错误),并将其显示在前端页面

3.3 项目目录结构

3.3.1 前端

├── config 						# umi 配置,包含路由,构建等配置
├── public
│   └── favicon.png 			# Favicon
├── src
│   ├── components 				# 业务通用组件
│   ├── constants 				# 常量
│   ├── pages 					# 业务页面入口和常用模板
│   │   ├── Admin 				# 用户管理页面
│   │   ├── User
│   │   │   ├── Login 			# 用户登录页面
│   │   │   └── Register 		# 用户注册页面
│   │   └── Welcome.tsx 		# 欢迎页
│   ├── plugins
│   │   └── globalRequest.ts 	# 全局响应处理器
│   ├── services 				# 后台接口服务
│   ├── app.tsx 				# 项目全局配置
│   ├── global.less 			# 全局样式
│   └── global.ts 				# 全局 JS
├── README.md
└── package.json 				# 启动脚本和依赖说明

3.3.2 后端

├── src
│   ├── main
│   │   ├── java
│   │   ├── top.saikaisa.usercenter
│   │   │   ├── common 				# 通用工具类
│   │   │   ├── constant 			# 常量
│   │   │   ├── controller 			# 控制层
│   │   │   ├── exception 			# 自定义异常处理
│   │   │   ├── mapper 				# 数据库映射类
│   │   │   ├── model.domain 		# 实体类
│   │   │   ├── service 			# 业务逻辑层
│   │   │   └── UserCenterApplication.java 	# 项目全局入口
│   │   ├── resources
│   │   ├── mapper 					#数据库映射定义
│   │   └── application.yml 		# 全局配置文件
│   └── test 						# 测试工具
├── README.md
└── pom.xml 	# 项目对象模型

4项目开发

4.1 技术方案

4.1.1 数据库设计(基于MyBatis框架)

user表 (存储用户相关数据)
属性 数据类型 用途
id BIGINT 用户唯一标识符(主键,以自增形式由数据库自动分配)
username VARCHAR 用户名/昵称
userAccount VARCHAR 用户登录账号,不能重复 (业务去重)
userPassword VARCHAR 用户密码 (以加密形式存储在数据库中)
avatarUrl VARCHAR 用户头像Url
gender TINYINT 性别
phone VARCHAR 电话
email VARCHAR 邮箱
userStatus INT 用户状态 0--正常 1--封禁
userRole TINYINT 用户角色 0--普通用户 1--管理员
invitationCode VARCHAR 用户注册时使用的邀请码
createTime DATETIME 用户创建时间,即数据插入时间
updateTime DATETIME 数据更新时间
isDelete TINYINT 数据是否删除

注:

invitationCode:用户注册时使用的邀请码,便于查询用户是通过哪个邀请码注册的,可以追溯注册来源(比如不同的邀请码段对应不同的注册来源),方便管理。

isDelete:逻辑删除,通过其值为0还是1,表示数据是否已经无效,避免数据被直接删 除,即使数据被删除,也可用来找回。

createTime, updateTime, isDelete与业务需求无关,是每个数据表都应该要有的三个属性,是数据库规范。

invitationlib表 (邀请码库,存储邀请码及其使用情况)
属性 数据类型 用途
invitationCode VARCHAR 邀请码 (存储用户注册可使用的邀请码)
isUsed INT 表示邀请码是否已被使用 0--未使用 1--已使用

这是一个为防止服务器被攻击,为了限制注册而创建的临时数据表,故没有必要的三属性。若要正式上线使用,会进行优化。

4.1.2 业务逻辑设计(基于Spring和Ant Design Pro)

  1. 前端带上信息向后端请求需要的参数

    1. 用户在前端输入信息,或者前端根据需要请求的参数自行生成信息
    2. 前端带着信息请求后端的特定路径
    3. 后端控制器的各个处理程序接受对应路径的请求(如/login接受登录请求, /current接受查询当前登录用户的信息的请求)
  2. 注册

    1. 后端接收并校验用户的账号、密码、校验密码和邀请码,是否符合要求
      1. 各参数非空
      2. 账号长度不小于4位,密码不小于8位
      3. 账号不能包含特殊字符
      4. 密码与校验密码相同
      5. 账号不能与数据库中的重复(不能已被注册过)
      6. 邀请码是否正确且有效
    2. 将邀请码设置为已使用
    3. 对密码进行加密(md5加密/盐值)
    4. 向数据库中插入用户数据
  3. 登录

    1. 后端接收并校验用户账号和密码是否合法
      1. 各参数非空
      2. 账号长度不能小于4位且密码长度不能小于8位
      3. 账号不包含特殊字符
    2. 校验密码是否输入正确,和数据库中的密文密码去对比
    3. 用户信息脱敏,隐藏敏感信息,防止数据库中的字段泄露
    4. 记录用户的登录状态(session),将其存到服务器上(用后端SpringBoot框架封装的服务器tomcat去记录)
    5. 返回脱敏后的用户信息
  4. 注销

    1. 后端接收注销请求
    2. 从当前请求的session缓存里取出用户
    3. 如果取出的用户不为空,则移除当前session中的登录态
  5. 用户管理(查询和删除用户)

    1. 对所有管理相关的请求进行鉴权:查询当前登录用户是否为管理员
    2. 查询:查询所有用户
    3. 删除用户(原理与查询用户大同小异)
  6. 返回当前登录用户的信息

    目的:前端每次刷新页面时,都会向后端发出一个查询当前用户状态的请求(是否登录、是哪个用户、是不是管理员),并为当前用户角色显示相应的页面

    1. 从当前请求的session缓存里取出用户
    2. 如果用户对象为空,抛出错误:用户未登录。告诉前端跳转至登录页面
    3. 如果用户对象不为空
      1. 校验用户是否还在数据库中(原因:该用户可能已被封禁或删除,但session缓存里还存有该用户信息,这种情况下对用户脱敏时会出现空指针错误)
      2. 返回脱敏后的用户信息
  7. 用户脱敏

    目的:返回用户数据时隐藏敏感信息(密码等),提高安全性

4.1.3 通用请求/返回/异常处理(后端基于Spring,前端基于Umi)

后端:

  1. 通用返回对象:包含用户对象和成功/错误信息
  2. 自定义业务异常类:抛出自定义异常,描述请求失败的原因(如登录密码错误等)
  3. 全局异常处理器(使用Spring AOP):捕获所有异常集中处理,自定义返回给前端的报错信息

前端:

  1. 对接后端的通用返回类,将返回的用户数据和报错信息(如果有)封装起来
  2. 定义全局响应处理器(扩展Umi Request):统一从后端的各种返回信息中取出报错信息,集中处理并反馈给用户(如提示用户未登录、账号密码错误、无权限等),并在成功请求时返回用户数据,供各页面使用

4.1.4 一些小工具

后端

  1. Lombok:使用这个包,在实体类里添加@Data注释可以自动生成getter和setter方法
  2. MyBatisX(IDEA插件)自动生成domain, mapper, mapper.xml, service, serviceImpl五个文件并相互关联
  3. JUnit单元测试库以及IDEA的HttpClient:能够对指定的Java方法进行测试,和模拟客户端向后端发起Http请求

前端

  1. Ant Design Pro的ProComponents:一键生成管理页面

参考文章:Lombok使用讲解

参考文章:ProTable - 高级表格

4.2 关键模块的实现技术、数据结构等

4.2.1 数据库查询模块

实现技术:使用MyBatis和MyBatis Plus,还有MyBatisX 插件,实现对数据库的查询。

  • MyBatis

    解释:MyBatis 是一个半ORM(对象关系映射)框架,它内部封装了JDBC,使用它后,我们只需要关注SQL语句本身,不需要花费精力去处理加载驱动、创建连接、创建statement等繁杂的过程,提高开发效率。

    参考:MyBatis - 基于Java的持久层框架 - 百度百科

  • MyBatis-Plus

    MyBatis-Plus是MyBatis的增强版框架,它对MyBatis进行了二次封装,只做增强不做改变。通过提供一系列的API方法和代码生成器,使重复的CRUD(创建、读取、更新、删除)操作更加简单,无需手动编写SQL语句,从而大幅提高开发效率。

  • 使用方法:

    1. 使用MyBatis Plus前,需要自己编写SQL Mapper映射文件和语句:

      <? "UserMapper.xml" ?>
      <mapper namespace="top.saikaisa.usercenter.mapper.UserMapper">   
          <resultMap id="BaseResultMap" type="top.saikaisa.usercenter.model.domain.User">  
              <id property="id" column="id" jdbcType="BIGINT"/>  
              <result property="username" column="username" jdbcType="VARCHAR"/> 
              <result property="userAccount" column="userAccount" jdbcType="VARCHAR"/>  
              <result property="avatarUrl" column="avatarUrl" jdbcType="VARCHAR"/>  
              <result property="gender" column="gender" jdbcType="TINYINT"/>  
              <result property="password" column="password" jdbcType="VARCHAR"/> 
              <result property="phone" column="phone" jdbcType="VARCHAR"/>  
              <result property="email" column="email" jdbcType="VARCHAR"/>  
              <result property="userStatus" column="userStatus" jdbcType="INTEGER"/>  
              <result property="createTime" column="createTime" jdbcType="TIMESTAMP"/>  
              <result property="updateTime" column="updateTime" jdbcType="TIMESTAMP"/>  
              <result property="isDelete" column="isDelete" jdbcType="TINYINT"/>  
              <result property="userRole" column="userRole" jdbcType="INTEGER"/>  
              <result property="invitationCode" column="invitationCode" jdbcType="VARCHAR"/>  
          </resultMap>  
      
          <sql id="Base_Column_List">  
              id,username,userAccount,  
              avatarUrl,gender,password,  
              phone,email,userStatus,  
              createTime,updateTime,isDelete,  
              userRole,invitationCode  
          </sql>  
      </mapper>
      
    2. 使用MyBatis Plus后,定义接口继承BaseMapper接口,然后在其他类中调用其中提供的现成方法即可,以下是一个实例:

      /** 
      * @filename UserMapper.java
      * @author Saikai 
      * @description 针对表【user】的数据库操作Mapper 
      * @createDate 2023-09-16 14:24:48 
      * @Entity top.saikaisa.usercenter.model.domain.User 
      */  
      package top.saikaisa.usercenter.mapper;  
      import top.saikaisa.usercenter.model.domain.User;  
      import com.baomidou.mybatisplus.core.mapper.BaseMapper;  
      
      public interface UserMapper extends BaseMapper<User> {  
      }  
      
    3. UserServiceImpl.java中使用:

      1. 引入UserMapper,并在Service的实现类中完成mapper跟User类的关联。

        public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {  
        	@Resource
        	private UserMapper userMapper; 
        	// ...
        }
        
      2. 调用来自框架的类BaseMapper的方法(UserMapper继承了BaseMapper)

        // 1.4 账户不能重复  
        QueryWrapper<User> queryWrapper = new QueryWrapper<>();  
        queryWrapper.eq("userAccount", userAccount);  
        long count = userMapper.selectCount(queryWrapper);  
        if (count > 0) {  
        	throw new BusinessException(ErrorCode.PARAMS_ERROR, "账号已存在");  
        }  
        

      这样就成功地从数据库中查询到了所需数据。

      代码中还有许多利用到了框架特性的地方,比如将User数据插入到数据库中,这使用了框架的IService接口中的Save方法。详情请见代码。

4.2.2 登录/注册模块实现

  1. 前后端对接参考表

    a. 前端的api类如下

    API 类 CurrentUser id,username,gender userAccount,avatarUrl phone,email,userRole userStatus,invitationCode, createTime 当前用户的数据结构
    BaseResponse<T> code,data<T>,message description 接受一个泛型参数 T,用作请求函数的返回类型,用于表示接口的基本响应结构。
    LoginParams userAccount,autoLogin userPassword(,type) 用于传递登录接口的请求参数,包括账号和密码等信息,
    LoginResult (status,type, currentAuthority) 返回登录结果及登录用户的角色
    RegisterParams userAccount,userPassword checkPassword, invitationCode(,type) 用于传递注册接口的请求参数,包括账号、密码和邀请码等信息
    RegisterResult (一个number类型) 返回注册结果 (返回为1表示注册成功)

    实际上,RegisterResult 和 LoginResult 都不包含用户信息,只有 CurrentUser 才包含完整用户信息。这个后续会解释。

    b. 后端实体类如下

    SafetyUser id,username, userAccount,avatarUrl,gender, phone,email,userRole userStatus,createTime,updateTime 这实质上是一个方法,但返回给前端的所有用户信息都应该是脱敏后的SafetyUser,纯User数据只能存在于后端。 (除了左边表中的属性,其他的User属性都被设置为null)
    UserRegisterRequest userAccount, userPassword, checkPassword, invitationCode 将前端的请求封装到这个请求类中,与前端的RegisterParams类中的属性相对应
    UserLoginRequest userAccount, userPassword 将前端的请求封装到这个请求类中,与前端的LoginParams类中的属性相对应
  2. 前后端交互过程(这里以登录请求,并请求成功为例)

    1. 前端:Login/index.tsx —— 登录表单和请求提交

      用户输入完信息,点击提交后,用户填写的信息封装成LoginParams参数,进入handleSubmit方法

      const handleSubmit = async (values: API.LoginParams) => {  
        try {  
      	// 登录  
      	const user = await login({  
      	  ...values,  
      	  type,  
      	});  
      	if (user) {  
      	  const defaultLoginSuccessMessage = '登录成功!';  
      	  message.success(defaultLoginSuccessMessage);  	  
      	  await fetchUserInfo();  
      	  /** 此方法会跳转到 redirect 参数所在的位置 */  
      	  if (!history) return;  
      	  const { query } = history.location;  
      	  const { redirect } = query as {  
      	    redirect: string;  
      	  };  
      	  history.push(redirect || '/');  
      	  return;  
      	}  
      	// 如果失败去设置用户错误信息  
      	// setUserLoginState(user);  
        } catch (error) {  
      	const defaultLoginFailureMessage = '登录失败,请重试!';  
      	message.error(defaultLoginFailureMessage);  
        }  
      };  
      

      随后LoginParams传入login方法,这是登录接口

    2. 前端:api.ts —— 登录接口,发起POST请求

      /** 登录接口 POST /api/user/login */  
      export async function login(body: API.LoginParams, options?: { [key: string]: any }) {  
        return request<API.BaseResponse<API.LoginResult>>('/api/user/login', {  
          method: 'POST',  
          headers: {  
              'Content-Type': 'application/json',  
          },  
          data: body,  
          ...(options || {}),  
        });  
      }  
      

      该接口向后端的/user/login发起request,将LoginParams发送给后端,期待返回类型为BaseResponse(封装了LoginResult)。

    3. 后端:UserController.java —— 分url接收来自前端的请求

      1. /user/login接收到了来自前端的请求,映射到userLogin方法进行处理,从HTTP请求体中获取请求参数LoginParams,并将其转换为相应的Java对象UserLoginRequest。

        注:Controller层绑定请求入口地址等操作

        参考文章:SpringBoot的Controller层常用注解

      2. 进行Controller层的校验,然后进入业务逻辑层,进行业务层面的校验和处理,并记录用户登录态。

      3. 业务逻辑层处理完成,返回用户信息。

      4. 控制器返回封装了User数据的请求成功信息。

    4. 前端:收到响应,进入globalRequest.ts —— 全局响应处理器

      request.interceptors.response.use(async (response, options): Promise<any> => { 
        const res = await response.clone().json();  
        // res.code 是和后端约定的状态码,表示响应状态  
        // 0 表示成功  
        if (res.code === 0) {  
      	return res.data;  
        }  
        // 40100 表示未登录  
        if (res.code === 40100) {  
      	message.error('请先登录');  
      	history.replace({  
      	  pathname: '/user/login',  
      	  search: stringify({  
      	    redirect: location.pathname,  
      	  }),  
      	});  
        } else {  
      	message.error(res.description);  
        }  
      	return res.data;  
      }); 
      

      该处理器拦截所有响应并进行处理,res是后端的返回数据,类型为BaseResponse,与后端类型对应。

      先取出状态码进行验证,状态码为0,登录成功。然后从响应信息中取出data(即用户数据),返回给登录接口。

    5. 前端:回到api.ts —— 登录接口将返回值继续返回

    6. 前端:回到Login/index.tsx —— 继续执行handleSubmit方法(接i中代码)

      1. res.data即为返回参数的LoginResult,这个LoginResult限定的类型与返回的类型(User)不同!(但问题不大,我们不从这里获取用户数据,而且ts的类型限制只是语法上的限制,实际上它是允许类型不同的参数返回的,所以这里返回的还是实际的用户数据User)
      2. 判断返回的user不为空,提示登录成功,并且执行fetchUserInfo方法获取用户信息(不使用login返回的user,这只是用来判断用的,同样在其他方法中也经常使用fetchUserInfo方法获取当前登录的用户信息)
      3. 页面跳转。如果用户之前是因为访问某页面被登录拦截,则返回到登录之前所在的页面,否则统一返回到后台管理页面。

4.2.3 用户管理模块(绝大部分操作与用户登录/注册大同小异)

  1. 管理前鉴权

    // UserController.java
    private boolean isAdmin(HttpServletRequest request) {  
    	// 仅管理员可查询  
    	Object userObj = request.getSession().getAttribute(USER_LOGIN_STATE);  
    	User user = (User) userObj;  
    	return user != null && user.getUserRole() == ADMIN_RULE;  
    }
    

    在用户执行查询/删除等敏感操作前,需要对用户身份进行判断。从用户请求的session中取出用户对象,判断用户的userRole属性。

4.2.4 页面跳转实现

  1. fetchUserInfo 方法

    export async function getInitialState(): Promise<{  
      settings?: Partial<LayoutSettings>;  
      currentUser?: API.CurrentUser;  
      loading?: boolean;  
      fetchUserInfo?: () => Promise<API.CurrentUser | undefined>;  
    }> {  
      // 获取用户信息  
      const fetchUserInfo = async () => {  
    	try {  
    	  return await queryCurrentUser();  
    	} catch (error) {  
    	  // 如果没有获取到用户信息,则重定向至 login 页面  
    	  history.push(loginPath);  
    	}  
    	return undefined;  
      };  
      // 如果是无需登录的页面,则无需执行 fetchUserInfo()  
      if (NO_NEED_LOGIN_WHITELIST.includes(history.location.pathname)) {  
    	return {  
    	  fetchUserInfo,  
    	  settings: defaultSettings,  
    	};  
      }  
      // 否则执行 fetchUserInfo()  
      const currentUser = await fetchUserInfo();  
      return {  
    	fetchUserInfo,  
        currentUser,  
        settings: defaultSettings,  
      };  
    } 
    

    每次刷新页面时都会检测当前是否在免登录页面(如登录页、注册页),如果不在,则执行一次fetchUserInfo,通过queryCurrentUser方法对接一个查询当前登录用户信息的接口,返回当前用户信息。如果没有获取到用户信息,说明未登录,则重定向至登录页面。

    同时fetchUserInfo方法也被login, register等页面用来获取当前用户信息,是一个通用的方法。

  2. routes.ts的配置:

    在浏览器中访问某个url,实际上是访问项目文件中的某个组件(Components),其中path和components的对应关系都写在routes.ts里

    export default [  
     {  
    	path: '/user',  
    	layout: false,  
    	/** 这个是React Router组件,component即为src/pages文件夹里的组件,与path关联,path即为用户访问的地址 
    	 *  访问path就会访问到其关联的组件component */  
    	routes: [  
    	  { name: '登录', path: '/user/login', component: './user/Login' },  
    	  { name: '注册', path: '/user/register', component: './user/Register' },  
    	  { component: './404' },  
    	],  
      },  
      { path: '/welcome', name: '欢迎', icon: 'smile', component: './Welcome' },  
      {  
    	path: '/admin',  
    	name: '管理页',  
    	icon: 'crown',  
    	access: 'canAdmin',  
    	/** 下面是嵌套,表示 Admin 组件里可以再嵌套 routes 中的组件, 
    	 * 在 Admin.tsx 中引入 children 可以将 routes 中的组件当作子页面显示 */  
    	component: './Admin',  
    	routes: [  
    	  {  
    	    path: '/admin/user-manage',  
    	    name: '用户管理',  
    	    icon: 'smile',  
    	    component: './Admin/UserManage',  
    	  },  
    	  { component: './404' },  
    	],  
      },  
      { path: '/', redirect: '/welcome' },  
      { component: './404' },  
    ];  
    

    组件(Components)对应src/pages/目录下的页面。

4.2.5 异常处理(后端)

**目的:**如果只向前端返回纯数据,前端接收到错误时,很难确定业务层面具体除了哪些问题,所以需要对各种请求返回(包括成功和失败)进行处理,将业务状态和请求数据封装起来。

  1. 通用返回类 (BaseResponse)

    这个类与前端的BaseResponse相对应,它封装了code(业务状态码), message(错误信息), description(错误具体描述), data(请求的主要数据) 四个属性。

  2. 错误码枚举类 (ErrorCode)

    通过自定义一个枚举类来集中定义错误码。

    该枚举类提供code, message, description三个属性,并且提供一个默认的构造函数用于创建枚举。

    已定义的错误码如下:

    SUCCESS(0, "ok", ""),
    PARAMS_ERROR(40000, "请求参数错误", ""),
    PARAMS_NULL_ERROR(40001,"请求数据为空", ""),
    NOT_LOGIN_ERROR(40100, "未登录", ""),
    NO_PERMISSION(40101, "无权限访问", ""),
    SYSTEM_ERROR(50000, "系统内部异常", "");
    
  3. 返回结果包装工具 (ResultUtils)

    这是对BaseResponse和ErrorCode的再次封装,在返回时直接调用,简化返回信息。

    调用方式:

    ResultUtils.success(用户数据);

    —— 参数为用户对象,业务状态码默认为0(成功)

    ResultUtils.error(错误信息);

    —— 参数为错误信息(有多种构造方式),用户数据默认为null(请求失败)

  4. 业务异常类 (BusinessException)

    用于抛出自定义业务异常,在Service和Controller中使用,所有的业务报错都抛出带有自定义描述的BusinessException,方便记录以及后面的全局异常处理器捕获。

  5. 全局异常处理器 (GlobalExceptionHandler)

    @RestControllerAdvice
    @Slf4j
    public class GlobalExceptionHandler {
    
    	// @ExceptionHandler(BusinessException.class) 说明这个方法只用来处理BusinessException 类型的异常
        @ExceptionHandler(BusinessException.class)
        public BaseResponse bussinessExceptionHandler(BusinessException e){
        	log.error("businessException: " + e.getMessage(), e);
        	return ResultUtils.error(e.getCode(), e.getMessage(), e.getDescription());
        }
    
    	@ExceptionHandler(RuntimeException.class)
    	public BaseResponse runtimeExceptionHandler(RuntimeException e){
    		log.error("runtimeException", e);
    		return ResultUtils.error(ErrorCode.SYSTEM_ERROR, e.getMessage(), "");
    	}  
    } 
    

    全局异常处理器能够捕获应用程序中抛出的异常,并对这些异常进行统一的处理,从而避免因未处理的异常导致系统崩溃或无法正常工作,能够提高系统的稳定性。

    在这里的用途:

    1. 错误信息统一处理:全局异常处理器可以将不同种类的异常转化为统一的错误信息格式,提供一致的错误响应给客户端,增强了用户体验。
    2. 异常信息隐藏: 通过全局异常处理器,可以隐藏敏感信息,以防止敏感信息泄露到客户端。

4.2.6 一些细节处理

  1. 多层校验(重复校验的必要性)

    1. 前端校验:前端校验主要是为了提供即时反馈和防止无效请求。通过在前端进行校验,可以减轻服务器端的负担,避免将无效请求发送到服务器。前端校验可以在用户输入数据时就即时检测和提示错误,避免不必要的网络请求和服务器端的资源消耗。
    2. Controller校验:Controller层的校验主要是对请求参数的合法性进行验证。它确保请求参数的格式、类型和范围符合预期,以防止无效或非法数据进入后续的业务逻辑处理。Controller校验是对输入数据的基本检查,保证请求的合法性和安全性。
    3. Service校验:Service层的校验主要是对业务逻辑的验证。它涉及更深层次的数据验证、关联数据的一致性检查以及其他业务规则的校验。Service校验是为了保证业务操作的正确性和完整性,确保数据满足特定的业务需求和规则。
  2. 登录态:往session储存特定键值来记录登录态(贴个图),并通过登录态查询用户是否登录及用户状态

  3. 在登录验证成功后,相关用户信息存储到session中。

  4. 在服务器端的session中,使用特定的键值(例如"USER_LOGIN_STATE")来表示用户的登录状态,相当于一个属性,如果该session中不存在这个属性,则说明该用户未登录。

  5. 在需要取到当前用户信息的地方,可根据登录态键取出与其相关的用户Object缓存,再转换为用户对象进行调用。

4.3 特殊问题及解决办法

4.3.1 项目瘦身:

问题一: 前端框架多余文件太多怎么删?

解决方法: 看官方文档,自行测试,删一个测一个,没问题再继续删。

问题二: 如果删文件或者代码时删错了,或者后悔了,怎么办?

解决方法: 使用git备份和还原(未commit时使用reverse可快速还原)

删除流程:

  1. 在package.json中执行"i18n-remove"命令后,删除国际化文件文件 /src/locales
  2. 删除集成测试 /src/e2e
  3. 删除API框架 /services/swagger
  4. 删除 /config/oneapi(注意:还要删除 config.ts 中的 openAPI 关联内容)
  5. 删除测试 /tests、测试工具/jest.config.js 、/playwright.conf.ts

4.3.2 数据库方面

问题:

a. 既然我用了框架,那怎么快捷配置逻辑删除的字段呢?

解决方法:MyBatis Plus框架提供了配置逻辑删除参数的功能,只需要在后端的全局配置文件application.yml下添加如下字段即可:

mybatis-plus:
  global-config:
    db-config:
      logic-delete-field: isDelete  # 表示逻辑删除的实体字段名
      logic-delete-value: 1         # 逻辑已删除值(默认为 1)
      logic-not-delete-value: 0     # 逻辑未删除值(默认为 0)

如果isDelete = 1,那么在后续的数据库查询操作中,框架会自动忽略该条数据。

b. Mybatis Plus自动改驼峰导致bug,怎么关?

解决方法: 框架默认自动将Java实体类的属性名(规范为驼峰式命名),在进行数据库查询时转换为相应的下划线命名(数据库命名规范),但是本项目的数据库条目也是驼峰式命名,所以转换反而报错,经过查询文档,我们找到了关闭该功能的选项。但从这个错误中,我们也学会了数据库的规范命名方式。

application.yml 配置如下:

mybatis-plus:  
  configuration:  
    map-underscore-to-camel-case: false 

c. MyBatisX自动生成以及一些数据库相关文件关联出错的问题

容易出错的细节:

  1. 该插件自动生成之后,mapper.xml里的mapper namespace是它默认的路径,需要将它改为项目实际的mapper路径。
  2. 数据库的字段如果进行了修改,需要在mapper.xml以及对应的实体类中修改相应字段,不然映射会出问题!

4.3.3 接口请求问题

什么情况下用Get请求,什么情况下用Post请求?

  1. 请求的结果如果对数据库有副作用(添加修改删除),则用POST
  2. 如果只是查询则用GET

参考文章:关于什么时候用get请求和什么时候用post请求

4.3.4 异常问题

问题: 前端请求出现错误时,我该如何得知这个错误来自于哪?F12控制台里只有503,404等代码,错误太宽泛,我是否能自定义错误码,甚至是自定义异常类,来告诉前端是哪里出错了?

解决方法: 自定义错误信息和全局异常处理器。

4.3.5 前端头像问题

a. 管理页面右上角头像不显示,一直转圈

解决方法: 经过一番排查,发现这应该是由框架自带的avatar字段命名与我们自定义的名称不同导致的,对框架的代码进行查找后,发现在src/components/RightContent这个目录,推测这是右上角显示头像的一个组件。随后,在该组件中找到了AvatarDropdown.tsx文件,从中发现了HeaderDropdown标签,推测是页面header元素的一个组件。如图:

31a2c79f093edbcad2053a87bd3d2cab.png

我们猜测这里的src就是avatar参数,这里原本是currentUser.avatar,与我们预定义的参数名avatarUrl不同,故更改。

更改完后刷新页面,头像成功加载!

b. 管理页面显示所有用户的头像是链接形式,而不是图片

解决方法: 经过对官方文档的一番查找,我们终于发现了将链接显示为图片的解决方案。

UserManage/index.tsx

{  
  title: '头像',  
  dataIndex: 'avatarUrl',  
  render: (_, record) => (
    <div>
      <Image src={record.avatarUrl} height={50} />
    </div>
  ),  
},

解释: record 表示当前行的数据对象,_ 参数表示当前列的值,但由于该列在渲染时并不需要使用到该值,因此被命名为 _ 表示占位符。这段代码的作用是在数据表格中的某一列中显示用户头像图片。通过访问record对象中的avatarUrl字段,获取图片的地址,并使用Ant Design的 <Image> 组件将图片渲染到表格中。

参考文档:

React Render用法:ChatGPT

Image组件:Image 图片 - Ant Design 官方文档

4.3.6 生产环境的区分与配置

前端

用NodeJS的环境变量NODE_ENV判断生产环境,切换请求后端的地址。

解释:process.env.NODE_ENV 是一个Node.js中的环境变量,用于表示当前运行环境的模式。它通常被用于在开发环境和生产环境中执行不同的操作。而Umi框架可以在项目打包时自动给环境变量传参,极大简化了环境变量的配置。因此,我们只需要在全局响应拦截器里面对响应的请求头分情况即可。如图:

/**
* 配置request请求时的默认参数
*/
const request = extend({  
  credentials: 'include', // 默认请求是否带上cookie  
  prefix: process.env.NODE_ENV === 'production' ? 'http://8.130.101.248' : undefined,
  // requestType: 'form',
});

当环境变量为production时,请求前缀为我们服务器的IP地址,否则为localhost.

后端

项目启动时传参声明生产环境,切换请求数据库的地址。

解释: SpringBoot 项目,通过 application.yml 添加不同的后缀来区分配置文件。

在启动项目时传入环境变量:

java -jar ./user-center-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod

传入参数为prod,则会加载application-prod.yml文件。

application-prod.yml

spring:
  # Datasource Config
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://8.130.101.248:3306/user_center
    username: saikaisa
    password: ********

这里配置了云服务器的数据源,覆盖了原配置文件的相应配置,其他配置不变且正常加载。项目部署到服务器后,会将数据库地址从localhost自动改成云服务器IP地址。

4.3.7 跨域问题

问题: 前端请求后端时出现跨域错误,什么情况?

原因: 浏览器为了用户的安全,仅允许向 同域名、同端口 的服务器发送请求。把域名、端口改成相同的即可。但是在这里我们不打算改成相同的,因为这是前后端分离的项目,所以我们针对本地和线上有两套解决方案。

解决方案:

本地:Umi自带代理配置proxy.ts,但该配置只对本地环境有效,代码如下
   // ...
   dev: {
  // localhost:8000/api/** -> http://localhost:8080/api/**
  // 解释:如果访问的路径带上了 /api ,它就会把请求代理到地址 target:'http://localhost:8080'
  '/api/': {
	// 要代理的地址
	target: 'http://localhost:8080',
	changeOrigin: true,
  },
},
线上:可以通过修改Nginx配置文件,允许跨域来解决
# 跨域配置
location ^~ /api/ {
    proxy_pass http://127.0.0.1:8080/api/;
    add_header 'Access-Control-Allow-Origin' $http_origin;
    add_header 'Access-Control-Allow-Credentials' 'true';
    add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS';
    add_header Access-Control-Allow-Headers '*';
    if ($request_method = 'OPTIONS') {
        add_header 'Access-Control-Allow-Credentials' 'true';
        add_header 'Access-Control-Allow-Origin' $http_origin;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
        add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
        add_header 'Access-Control-Max-Age' 1728000;
        add_header 'Content-Type' 'text/plain; charset=utf-8';
        add_header 'Content-Length' 0;
        return 204;
    }
}

参考文章:什么是代理?

5 个人体会

这是我做的第一个比较完整的项目,虽然功能不多,只有登录注册查询和删除,但是涉及的技术栈很多,之所以选择这个项目,是因为从这个项目中能学到很多东西,项目的前端、后端,项目的部署、上线等,很多知识点和小细节,还有碰到问题的解决方法和经验,都能从这个项目中学到。

本项目是用Spring和Ant Design框架做的一个全栈项目,Ant Design这个框架是面向企业的一个流行框架,很适合用来做后台管理系统,用户中心就是其中一种,也是最基本,最重要的一个管理系统。

从与该项目有关的各种教程中,我学习到了做项目的流程:

我意识到,在做项目时,还是要写一下项目流程和计划的,这是一个好习惯。

在做项目的过程中,理所当然会遇到许多问题,也会去想办法解决——如自己Debug、查阅官方文档、百度/google/csdn、问gpt、看源码。

具体的方法如下:

  1. 自行Debug:结合官方文档,在关键的语句下输出必要信息,这可以解决代码跑不通、逻辑错误或者个人疏忽产生的bug。

  2. 查阅官方文档:官方文档是非常有用且重要的信息来源,就Ant Design, MyBatis Plus官方文档来说,大部分功能我都能从文档中找到用法。这是最主要的问题解决方式,毕竟框架有什么功能、有什么问题,官方相对来说是最清楚的。

  3. 百度/google/csdn:网络上的帖子质量参差不齐,我一般会按照csdn > google > 百度的优先级去查找。在搜索问题的时候需要详细描述,报错信息选取关键部分粘贴,并且在必要时限定时间范围!(一些流行框架的更新频率不低,不同版本的特性可能差异非常大,过时的信息可能会对排错进程造成更大阻碍)

  4. 问ChatGPT:对于一些基础问题(比如对语言和框架的解释,像前端框架用的是TypeScript语言,我根本没学过,gpt给了我很大帮助)有很大的帮助,毕竟gpt对基础概念的准确度不会低于网上的帖子,但是对于业务逻辑和其他一些自由程度很高的,没有标准答案的内容,gpt不能作为首选。

    并且使用gpt时我一般都结合网页搜索,对两个来源的信息进行交互和筛选,这样既能减轻因为网页搜索过于宽泛导致的信息处理压力大,也能减少因为gpt回答缺陷带来的更多错误和排错压力,极大提升了效率。

  5. 看源码:这也是重要的方式之一。当我不知道这个方法的用途时,我会一步步追溯,点进这个方法内部,或者它的封装类/封装参数,去获得更详细的信息。必要时还可以将看起来封装的很严密的,没法看懂实现逻辑的方法名,复制到官方文档或者网页上搜索,很大概率能搜到详细的解释。

    不过由于个人能力还很薄弱,更深层的源码看不懂了,所以基本很少用这种方式。

这次课程设计中,我学到了很多对自身有帮助的知识和项目经验,虽然项目最终实现的只是基础的功能,但它系统、规范,为往后的学习打下了坚实的基础。这个项目也算是我编程路上的一个新起点吧。