1 引言
本用户管理中心项目,是基于SpringBoot后端和React前端的全栈项目,前后端分离,使用一些企业中流行的框架,实现用户注册、登录、查询等基础功能。
1.1 本项目的业务流程
管理员注册 => 管理员登录 => 管理员在主界面中浏览和删除用户
1.2 项目地址
前端:https://github.com/saikaisa/user-center-frontend
后端:https://github.com/saikaisa/user-center-backend
1.3 项目预览
2 需求分析
用户中心是一个后台系统,可用于各种项目系统,对用户实现统一的管理。
- 系统角色
- 用户(User):能够注册账号、登录系统。
- 管理员(Administrator):拥有用户管理权限,在用户的基础上,还有查询用户信息、删除用户的权限。(主要角色)
- 总体需求
- 用户的登录/注册功能
- 用户管理(查询及删除)功能(需要鉴权,仅管理员可以管理用户)
- 用户校验功能(仅授权用户可访问注册该用户中心后台)
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层和数据层
-
设计一个合适的数据库,用于存储用户信息
-
实现注册逻辑
-
实现登录逻辑
- 登录接口:用于返回脱敏后的用户信息
- 登录逻辑
- 登录态:登录成功后需要保存用户登录态(用session)
- 登录接口:用于返回脱敏后的用户信息
-
实现用户管理接口
- 管理前鉴权
- 根据用户名查询用户
- 删除用户
- 管理前鉴权
-
实现注销(退出登录)逻辑:删除登录态
-
补充用户注册校验逻辑:需使用邀请码注册
Controller层
- 配置各接口对应的url和method,并编写相应方法
- 封装注册和登录请求,创建请求实体类
3.2.2 前端功能开发
- 生成并修改登录/注册页面
- 实现前后端的交互
- 在前端使用正向代理解决跨域问题
- 使用Request请求后端(可视为封装过的ajax)
- 获取用户登录态,实现当前登录用户信息接口
- 实现用户管理后台的界面开发
- 实现注销(退出登录)功能
- 实现页面跳转以及重定向逻辑:登录/注册/注销以及权限不同等会导致页面跳转
- 补充用户注册校验功能:需使用邀请码注册
3.2.3 后端优化
- 创建通用返回对象(BaseResponse):给返回对象补充一些描述,告诉前端这个请求在业务层面上是成功还是失败
- 自定义错误码、描述信息
- 返回正常或错误
- 封装全局异常处理
- 自定义业务异常类(BusinessException)
- 相比Java自带异常类,能支持更多高级情况
- 自定义程度高,使用更灵活
- 编写全局异常处理器(使用Spring AOP)
- 捕获代码中所有的异常,只返回规定的异常,让前端得到更详细的业务报错
- 同时屏蔽项目本身异常,不暴露服务器内部状态,提高安全性
- 自定义业务异常类(BusinessException)
3.2.4 前端优化
-
为了能对接优化后的后端的返回值,需要定义一个相应的类,将返回的用户信息封装在其中的data属性中
-
全局响应处理:对接口的通用响应进行统一处理
- 统一从返回成功的response中取出data
- 根据错误码去集中处理错误(比如用户未登录、没权限之类的错误),并将其显示在前端页面
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 | 电话 |
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)
-
前端带上信息向后端请求需要的参数
- 用户在前端输入信息,或者前端根据需要请求的参数自行生成信息
- 前端带着信息请求后端的特定路径
- 后端控制器的各个处理程序接受对应路径的请求(如/login接受登录请求, /current接受查询当前登录用户的信息的请求)
-
注册
- 后端接收并校验用户的账号、密码、校验密码和邀请码,是否符合要求
- 各参数非空
- 账号长度不小于4位,密码不小于8位
- 账号不能包含特殊字符
- 密码与校验密码相同
- 账号不能与数据库中的重复(不能已被注册过)
- 邀请码是否正确且有效
- 将邀请码设置为已使用
- 对密码进行加密(md5加密/盐值)
- 向数据库中插入用户数据
- 后端接收并校验用户的账号、密码、校验密码和邀请码,是否符合要求
-
登录
- 后端接收并校验用户账号和密码是否合法
- 各参数非空
- 账号长度不能小于4位且密码长度不能小于8位
- 账号不包含特殊字符
- 校验密码是否输入正确,和数据库中的密文密码去对比
- 用户信息脱敏,隐藏敏感信息,防止数据库中的字段泄露
- 记录用户的登录状态(session),将其存到服务器上(用后端SpringBoot框架封装的服务器tomcat去记录)
- 返回脱敏后的用户信息
- 后端接收并校验用户账号和密码是否合法
-
注销
- 后端接收注销请求
- 从当前请求的session缓存里取出用户
- 如果取出的用户不为空,则移除当前session中的登录态
-
用户管理(查询和删除用户)
- 对所有管理相关的请求进行鉴权:查询当前登录用户是否为管理员
- 查询:查询所有用户
- 删除用户(原理与查询用户大同小异)
-
返回当前登录用户的信息
目的:前端每次刷新页面时,都会向后端发出一个查询当前用户状态的请求(是否登录、是哪个用户、是不是管理员),并为当前用户角色显示相应的页面
- 从当前请求的session缓存里取出用户
- 如果用户对象为空,抛出错误:用户未登录。告诉前端跳转至登录页面
- 如果用户对象不为空
- 校验用户是否还在数据库中(原因:该用户可能已被封禁或删除,但session缓存里还存有该用户信息,这种情况下对用户脱敏时会出现空指针错误)
- 返回脱敏后的用户信息
-
用户脱敏
目的:返回用户数据时隐藏敏感信息(密码等),提高安全性
4.1.3 通用请求/返回/异常处理(后端基于Spring,前端基于Umi)
后端:
- 通用返回对象:包含用户对象和成功/错误信息
- 自定义业务异常类:抛出自定义异常,描述请求失败的原因(如登录密码错误等)
- 全局异常处理器(使用Spring AOP):捕获所有异常集中处理,自定义返回给前端的报错信息
前端:
- 对接后端的通用返回类,将返回的用户数据和报错信息(如果有)封装起来
- 定义全局响应处理器(扩展Umi Request):统一从后端的各种返回信息中取出报错信息,集中处理并反馈给用户(如提示用户未登录、账号密码错误、无权限等),并在成功请求时返回用户数据,供各页面使用
4.1.4 一些小工具
后端
- Lombok:使用这个包,在实体类里添加@Data注释可以自动生成getter和setter方法
- MyBatisX(IDEA插件)自动生成domain, mapper, mapper.xml, service, serviceImpl五个文件并相互关联
- JUnit单元测试库以及IDEA的HttpClient:能够对指定的Java方法进行测试,和模拟客户端向后端发起Http请求
前端
- Ant Design Pro的ProComponents:一键生成管理页面
参考文章:Lombok使用讲解
参考文章:ProTable - 高级表格
4.2 关键模块的实现技术、数据结构等
4.2.1 数据库查询模块
实现技术:使用MyBatis和MyBatis Plus,还有MyBatisX 插件,实现对数据库的查询。
-
MyBatis
解释:MyBatis 是一个半ORM(对象关系映射)框架,它内部封装了JDBC,使用它后,我们只需要关注SQL语句本身,不需要花费精力去处理加载驱动、创建连接、创建statement等繁杂的过程,提高开发效率。
-
MyBatis-Plus
MyBatis-Plus是MyBatis的增强版框架,它对MyBatis进行了二次封装,只做增强不做改变。通过提供一系列的API方法和代码生成器,使重复的CRUD(创建、读取、更新、删除)操作更加简单,无需手动编写SQL语句,从而大幅提高开发效率。
-
使用方法:
-
使用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>
-
使用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> { }
-
在UserServiceImpl.java中使用:
-
引入UserMapper,并在Service的实现类中完成mapper跟User类的关联。
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService { @Resource private UserMapper userMapper; // ... }
-
调用来自框架的类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 登录/注册模块实现
-
前后端对接参考表
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类中的属性相对应 -
前后端交互过程(这里以登录请求,并请求成功为例)
-
前端: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方法,这是登录接口
-
前端: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)。
-
后端:UserController.java —— 分url接收来自前端的请求
-
/user/login接收到了来自前端的请求,映射到userLogin方法进行处理,从HTTP请求体中获取请求参数LoginParams,并将其转换为相应的Java对象UserLoginRequest。
注:Controller层绑定请求入口地址等操作
-
进行Controller层的校验,然后进入业务逻辑层,进行业务层面的校验和处理,并记录用户登录态。
-
业务逻辑层处理完成,返回用户信息。
-
控制器返回封装了User数据的请求成功信息。
-
-
前端:收到响应,进入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(即用户数据),返回给登录接口。
-
前端:回到api.ts —— 登录接口将返回值继续返回
-
前端:回到Login/index.tsx —— 继续执行handleSubmit方法(接i中代码)
- res.data即为返回参数的LoginResult,这个LoginResult限定的类型与返回的类型(User)不同!(但问题不大,我们不从这里获取用户数据,而且ts的类型限制只是语法上的限制,实际上它是允许类型不同的参数返回的,所以这里返回的还是实际的用户数据User)
- 判断返回的user不为空,提示登录成功,并且执行fetchUserInfo方法获取用户信息(不使用login返回的user,这只是用来判断用的,同样在其他方法中也经常使用fetchUserInfo方法获取当前登录的用户信息)。
- 页面跳转。如果用户之前是因为访问某页面被登录拦截,则返回到登录之前所在的页面,否则统一返回到后台管理页面。
-
4.2.3 用户管理模块(绝大部分操作与用户登录/注册大同小异)
-
管理前鉴权
// 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 页面跳转实现
-
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等页面用来获取当前用户信息,是一个通用的方法。
-
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 异常处理(后端)
**目的:**如果只向前端返回纯数据,前端接收到错误时,很难确定业务层面具体除了哪些问题,所以需要对各种请求返回(包括成功和失败)进行处理,将业务状态和请求数据封装起来。
-
通用返回类 (BaseResponse)
这个类与前端的BaseResponse相对应,它封装了code(业务状态码), message(错误信息), description(错误具体描述), data(请求的主要数据) 四个属性。
-
错误码枚举类 (ErrorCode)
通过自定义一个枚举类来集中定义错误码。
该枚举类提供code, message, description三个属性,并且提供一个默认的构造函数用于创建枚举。
已定义的错误码如下:
SUCCESS(0, "ok", ""), PARAMS_ERROR(40000, "请求参数错误", ""), PARAMS_NULL_ERROR(40001,"请求数据为空", ""), NOT_LOGIN_ERROR(40100, "未登录", ""), NO_PERMISSION(40101, "无权限访问", ""), SYSTEM_ERROR(50000, "系统内部异常", "");
-
返回结果包装工具 (ResultUtils)
这是对BaseResponse和ErrorCode的再次封装,在返回时直接调用,简化返回信息。
调用方式:
ResultUtils.success(用户数据);
—— 参数为用户对象,业务状态码默认为0(成功)
ResultUtils.error(错误信息);
—— 参数为错误信息(有多种构造方式),用户数据默认为null(请求失败)
-
业务异常类 (BusinessException)
用于抛出自定义业务异常,在Service和Controller中使用,所有的业务报错都抛出带有自定义描述的BusinessException,方便记录以及后面的全局异常处理器捕获。
-
全局异常处理器 (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(), ""); } }
全局异常处理器能够捕获应用程序中抛出的异常,并对这些异常进行统一的处理,从而避免因未处理的异常导致系统崩溃或无法正常工作,能够提高系统的稳定性。
在这里的用途:
- 错误信息统一处理:全局异常处理器可以将不同种类的异常转化为统一的错误信息格式,提供一致的错误响应给客户端,增强了用户体验。
- 异常信息隐藏: 通过全局异常处理器,可以隐藏敏感信息,以防止敏感信息泄露到客户端。
4.2.6 一些细节处理
-
多层校验(重复校验的必要性)
- 前端校验:前端校验主要是为了提供即时反馈和防止无效请求。通过在前端进行校验,可以减轻服务器端的负担,避免将无效请求发送到服务器。前端校验可以在用户输入数据时就即时检测和提示错误,避免不必要的网络请求和服务器端的资源消耗。
- Controller校验:Controller层的校验主要是对请求参数的合法性进行验证。它确保请求参数的格式、类型和范围符合预期,以防止无效或非法数据进入后续的业务逻辑处理。Controller校验是对输入数据的基本检查,保证请求的合法性和安全性。
- Service校验:Service层的校验主要是对业务逻辑的验证。它涉及更深层次的数据验证、关联数据的一致性检查以及其他业务规则的校验。Service校验是为了保证业务操作的正确性和完整性,确保数据满足特定的业务需求和规则。
-
登录态:往session储存特定键值来记录登录态(贴个图),并通过登录态查询用户是否登录及用户状态
-
在登录验证成功后,相关用户信息存储到session中。
-
在服务器端的session中,使用特定的键值(例如"USER_LOGIN_STATE")来表示用户的登录状态,相当于一个属性,如果该session中不存在这个属性,则说明该用户未登录。
-
在需要取到当前用户信息的地方,可根据登录态键取出与其相关的用户Object缓存,再转换为用户对象进行调用。
4.3 特殊问题及解决办法
4.3.1 项目瘦身:
问题一: 前端框架多余文件太多怎么删?
解决方法: 看官方文档,自行测试,删一个测一个,没问题再继续删。
问题二: 如果删文件或者代码时删错了,或者后悔了,怎么办?
解决方法: 使用git备份和还原(未commit时使用reverse可快速还原)
删除流程:
- 在package.json中执行"i18n-remove"命令后,删除国际化文件文件 /src/locales
- 删除集成测试 /src/e2e
- 删除API框架 /services/swagger
- 删除 /config/oneapi(注意:还要删除 config.ts 中的 openAPI 关联内容)
- 删除测试 /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自动生成以及一些数据库相关文件关联出错的问题
容易出错的细节:
- 该插件自动生成之后,mapper.xml里的mapper namespace是它默认的路径,需要将它改为项目实际的mapper路径。
- 数据库的字段如果进行了修改,需要在mapper.xml以及对应的实体类中修改相应字段,不然映射会出问题!
4.3.3 接口请求问题
什么情况下用Get请求,什么情况下用Post请求?
- 请求的结果如果对数据库有副作用(添加修改删除),则用POST
- 如果只是查询则用GET
4.3.4 异常问题
问题: 前端请求出现错误时,我该如何得知这个错误来自于哪?F12控制台里只有503,404等代码,错误太宽泛,我是否能自定义错误码,甚至是自定义异常类,来告诉前端是哪里出错了?
解决方法: 自定义错误信息和全局异常处理器。
4.3.5 前端头像问题
a. 管理页面右上角头像不显示,一直转圈
解决方法: 经过一番排查,发现这应该是由框架自带的avatar字段命名与我们自定义的名称不同导致的,对框架的代码进行查找后,发现在src/components/RightContent这个目录,推测这是右上角显示头像的一个组件。随后,在该组件中找到了AvatarDropdown.tsx文件,从中发现了HeaderDropdown标签,推测是页面header元素的一个组件。如图:
我们猜测这里的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、看源码。
具体的方法如下:
-
自行Debug:结合官方文档,在关键的语句下输出必要信息,这可以解决代码跑不通、逻辑错误或者个人疏忽产生的bug。
-
查阅官方文档:官方文档是非常有用且重要的信息来源,就Ant Design, MyBatis Plus官方文档来说,大部分功能我都能从文档中找到用法。这是最主要的问题解决方式,毕竟框架有什么功能、有什么问题,官方相对来说是最清楚的。
-
百度/google/csdn:网络上的帖子质量参差不齐,我一般会按照csdn > google > 百度的优先级去查找。在搜索问题的时候需要详细描述,报错信息选取关键部分粘贴,并且在必要时限定时间范围!(一些流行框架的更新频率不低,不同版本的特性可能差异非常大,过时的信息可能会对排错进程造成更大阻碍)
-
问ChatGPT:对于一些基础问题(比如对语言和框架的解释,像前端框架用的是TypeScript语言,我根本没学过,gpt给了我很大帮助)有很大的帮助,毕竟gpt对基础概念的准确度不会低于网上的帖子,但是对于业务逻辑和其他一些自由程度很高的,没有标准答案的内容,gpt不能作为首选。
并且使用gpt时我一般都结合网页搜索,对两个来源的信息进行交互和筛选,这样既能减轻因为网页搜索过于宽泛导致的信息处理压力大,也能减少因为gpt回答缺陷带来的更多错误和排错压力,极大提升了效率。
-
看源码:这也是重要的方式之一。当我不知道这个方法的用途时,我会一步步追溯,点进这个方法内部,或者它的封装类/封装参数,去获得更详细的信息。必要时还可以将看起来封装的很严密的,没法看懂实现逻辑的方法名,复制到官方文档或者网页上搜索,很大概率能搜到详细的解释。
不过由于个人能力还很薄弱,更深层的源码看不懂了,所以基本很少用这种方式。
这次课程设计中,我学到了很多对自身有帮助的知识和项目经验,虽然项目最终实现的只是基础的功能,但它系统、规范,为往后的学习打下了坚实的基础。这个项目也算是我编程路上的一个新起点吧。