Spring Boot + Vue3前后端分离实战wiki知识库系统<十二>--用户管理&单点登录开发一
目标:
在上一次https://www.cnblogs.com/webor2006/p/17533745.html我们已经完成了文档管理的功能模块开发,接下来则开启新模块的学习---用户登录,这块还是有不少知识点值得学习的,先来看一下整体的效果,关于效果官网有一个体验地址:wiki.courseimooc.com,如下:
其效果也是人人熟知的,下面直接开撸。
用户表设计与持久层代码生成:
用户表设计:
一个模块的开始通常就是从表的设计开始,这里先来将用户表的sql贴出来,当然实际对于表设计肯定不会直接给出个sql,表的设计其实也是非常有学问的,这里重点是操练功能,所以直接贴sql了:
-- 用户表 drop table if exists `user`; create table `user` ( `id` bigint not null comment 'ID', `login_name` varchar(50) not null comment '登陆名', `name` varchar(50) comment '昵称', `password` char(32) not null comment '密码', primary key (`id`), unique key `login_name_unique` (`login_name`) ) engine = innodb default charset = utf8mb4 comment ='用户';
然后执行一下sql:
此时查看一下数据库中用户表有木有生成?
妥妥的,然后我们默认生插入一个用户数据:
看一下表数据:
持久层代码生成:
接下来则来生成持久层的代码,这块在之前已经用得很熟练了,就不过多说明:
然后执行此命令开始生成:
此时看一下本地生成的文件是否正常生成?
在这里我其实一直有一个反思:对于一个初学者来说,这样逃避手写sql层的代码是不是不利于自己的学习呀,其实我看了下公司Java后端的代码是没有使用这种自动生成的方式的,但是,多学一种“高效率”的方式有利无害呀,毕竟对于实际做项目来说效率是非常重要的,哪怕公司里没用到,到时写自己的项目是有可能用到的呀,总之,拥抱变化,任何学到的新知识,在未来总会发挥它的余热的~~
完成用户表基本增删改查功能:
如之前实现电子书和文档的增删改查功能一样,使用非常厉害的CV大法就可以了,这边不厌其烦地再来走一遍流程,下面开始。
后端代码:
1、UserController:
这里从EbookController拷贝过来:
然后再替换一下里面的内容,两个步骤,还记得么?
此时代码中一堆红,不要理,之后随着全局替换完都会自动消失了。
2、UserService:
同样拷贝至EbookService:
然后里面的内容也是那两步进行全局替换,这里就不说明了,替换完成之后,有一个点这里需要修改一下:
3、UserQueryReq:
这里查询只需根据用户名查,这里直接贴内容:
package com.cexo.wiki.req; public class UserQueryReq extends PageReq { private String loginName; public String getLoginName() { return loginName; } public void setLoginName(String loginName) { this.loginName = loginName; } @Override public String toString() { return "UserQueryReq{" + "loginName='" + loginName + '\'' + "} " + super.toString(); } }
4、UserQueryResp:
这个直接从domain中的User类中拷贝既可:
5、UserSaveReq:
它也可以拷至EbookSaveReq,不过表单校验规则需要改一下,这里直接将内容贴出:
package com.cexo.wiki.req; import javax.validation.constraints.NotNull; import javax.validation.constraints.Pattern; public class UserSaveReq { private Long id; @NotNull(message = "【用户名】不能为空") private String loginName; @NotNull(message = "【昵称】不能为空") private String name; @NotNull(message = "【密码】不能为空") // @Length(min = 6, max = 20, message = "【密码】6~20位") @Pattern(regexp = "^(?![0-9]+$)(?![a-zA-Z]+$)[0-9A-Za-z]{6,32}$", message = "【密码】至少包含 数字和英文,长度6-32") private String password; public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getLoginName() { return loginName; } public void setLoginName(String loginName) { this.loginName = loginName; } public String getName() { return name; } public void setName(String name) { this.name = name; } public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } @Override public String toString() { StringBuilder sb = new StringBuilder(); sb.append(getClass().getSimpleName()); sb.append(" ["); sb.append("Hash = ").append(hashCode()); sb.append(", id=").append(id); sb.append(", loginName=").append(loginName); sb.append(", name=").append(name); sb.append(", password=").append(password); sb.append("]"); return sb.toString(); } }
其中有个小细节需要说明一下:
最后需要解决一个报错:
对于用户来说只需要根据用户名称来查询,所以需要改一下条件,如下:
前端代码:
1、 admin-user.vue:
它的内容同样可以拷贝至电子书的,里面的内容需要做一些减法,因为它没有像电子书中的树形分类数据,这里直接把内容贴一下:
<template> <a-layout> <a-layout-content :style="{ background: '#fff', padding: '24px', margin: 0, minHeight: '280px' }" > <p> <a-form layout="inline" :model="param"> <a-form-item> <a-input v-model:value="param.loginName" placeholder="登陆名"> </a-input> </a-form-item> <a-form-item> <a-button type="primary" @click="handleQuery({page: 1, size: pagination.pageSize})"> 查询 </a-button> </a-form-item> <a-form-item> <a-button type="primary" @click="add()"> 新增 </a-button> </a-form-item> </a-form> </p> <a-table :columns="columns" :row-key="record => record.id" :data-source="users" :pagination="pagination" :loading="loading" @change="handleTableChange" > <template v-slot:action="{ text, record }"> <a-space size="small"> <a-button type="primary" @click="edit(record)"> 编辑 </a-button> <a-popconfirm title="删除后不可恢复,确认删除?" ok-text="是" cancel-text="否" @confirm="handleDelete(record.id)" > <a-button type="danger"> 删除 </a-button> </a-popconfirm> </a-space> </template> </a-table> </a-layout-content> </a-layout> <a-modal title="用户表单" v-model:visible="modalVisible" :confirm-loading="modalLoading" @ok="handleModalOk" > <a-form :model="user" :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }"> <a-form-item label="登陆名"> <a-input v-model:value="user.loginName" :disabled="!!user.id"/> </a-form-item> <a-form-item label="昵称"> <a-input v-model:value="user.name"/> </a-form-item> <a-form-item label="密码" v-show="!user.id"> <a-input v-model:value="user.password"/> </a-form-item> </a-form> </a-modal> </template> <script lang="ts"> import {defineComponent, onMounted, ref} from 'vue'; import axios from 'axios'; import {message} from 'ant-design-vue'; import {Tool} from "@/util/tool"; export default defineComponent({ name: 'AdminUser', setup() { const param = ref(); param.value = {}; const users = ref(); const pagination = ref({ current: 1, pageSize: 10, total: 0 }); const loading = ref(false); const columns = [ { title: '登陆名', dataIndex: 'loginName' }, { title: '名称', dataIndex: 'name' }, { title: '密码', dataIndex: 'password' }, { title: 'Action', key: 'action', slots: {customRender: 'action'} } ]; /** * 数据查询 **/ const handleQuery = (params: any) => { loading.value = true; // 如果不清空现有数据,则编辑保存重新加载数据后,再点编辑,则列表显示的还是编辑前的数据 users.value = []; axios.get("/user/list", { params: { page: params.page, size: params.size, loginName: param.value.loginName } }).then((response) => { loading.value = false; const data = response.data; if (data.success) { users.value = data.content.list; // 重置分页按钮 pagination.value.current = params.page; pagination.value.total = data.content.total; } else { message.error(data.message); } }); }; /** * 表格点击页码时触发 */ const handleTableChange = (pagination: any) => { console.log("看看自带的分页参数都有啥:" + pagination); handleQuery({ page: pagination.current, size: pagination.pageSize }); }; // -------- 表单 --------- const user = ref(); const modalVisible = ref(false); const modalLoading = ref(false); const handleModalOk = () => { modalLoading.value = true; axios.post("/user/save", user.value).then((response) => { modalLoading.value = false; const data = response.data; // data = commonResp if (data.success) { modalVisible.value = false; // 重新加载列表 handleQuery({ page: pagination.value.current, size: pagination.value.pageSize, }); } else { message.error(data.message); } }); }; /** * 编辑 */ const edit = (record: any) => { modalVisible.value = true; user.value = Tool.copy(record); }; /** * 新增 */ const add = () => { modalVisible.value = true; user.value = {}; }; const handleDelete = (id: number) => { axios.delete("/user/delete/" + id).then((response) => { const data = response.data; // data = commonResp if (data.success) { // 重新加载列表 handleQuery({ page: pagination.value.current, size: pagination.value.pageSize, }); } else { message.error(data.message); } }); }; onMounted(() => { handleQuery({ page: 1, size: pagination.value.pageSize, }); }); return { param, users, pagination, columns, loading, handleTableChange, handleQuery, edit, add, user, modalVisible, modalLoading, handleModalOk, handleDelete, } } }); </script> <style scoped> img { width: 50px; height: 50px; } </style>
里面的内容都是之前学过了,没有任何难点,所以不过多解释。
2、index.ts添加路由信息:
3、头部增加用户管理菜单入口:
4、整体运行:
用户名重复校验与自定义异常:
概述:
对于用户名,在后端我们表设计时是设置了它的唯一性,不能重复的:
而目前我们并没有做重复名称的校验,看一下:
界面一直转圈,此时后端报异常了:
所以接下来咱们做一下重复用户名的校验逻辑。
实现:
1、后端插入进行用户名的校验:
目前在插入逻辑中并没有根据用户名参数到数据库中查询是否存在:
咱们先提供一个根据用户名查询的方法:
然后插入逻辑就可以修改为:
2、新建业务异常:
接下来这个用户名已存在的业务异常,则采用自定义异常的方式来处理,先来新建一个自定义的异常类:
package com.cexo.wiki.exception; public enum BusinessExceptionCode { USER_LOGIN_NAME_EXIST("登录名已存在"), ; private String desc; BusinessExceptionCode(String desc) { this.desc = desc; } public String getDesc() { return desc; } public void setDesc(String desc) { this.desc = desc; } }
异常code是通过枚举来定义的,接下来这个异常类的内容为:
package com.cexo.wiki.exception; public class BusinessException extends RuntimeException{ private BusinessExceptionCode code; public BusinessException (BusinessExceptionCode code) { super(code.getDesc()); this.code = code; } public BusinessExceptionCode getCode() { return code; } public void setCode(BusinessExceptionCode code) { this.code = code; } /** * 不写入堆栈信息,提高性能 */ @Override public Throwable fillInStackTrace() { return this; } }
其中:
它的意思是不抛出一堆的异常堆栈信息了,因为这个属于业务异常,并非系统异常,通过业务异常的code其实就知道此异常的问题,它是纯业务逻辑,并非是因为程序的缺陷,所以这里就重写一下fillInStackTrace()。
3、抛出异常:
接下来咱们就可以在用户名重复的处理处这样来抛出业务异常了:
4、统一异常处理:
还记得在之前https://www.cnblogs.com/webor2006/p/17238571.html使用过SpingBoot的全局异常处理么?对于这个自定义的异常处理也类似,处理如下:
5、运行:
接下来咱们运行看一下效果:
6、编辑用户名问题:
还有一个细节需要再说明一下,就是编辑用户时,是不允许编辑用户名的,目前控制它的地方在这:
也就是当用户的id不为空,说明是编辑操作,此时登陆名是只读的不允许编辑,而当用户的id为空,则说明是新增操作,当然登录名是可写入的,另外目前密码不可以编辑,可以把这个条件暂且先去掉,因为接下来就要对密码进行进一步处理,目前密码名文存在库中肯定是不合理的:
此时编辑的时候就可以编辑密码了:
这个不是说的重点,重点是它:
为啥要加“!!”两个叹号呢?那将它去掉看会有什么影响就知道了:
此时就可以使用“!!”来绕过语法检查,这个算是一个小技巧。
另外目前貌似前端用户名已经禁用输入来防止更改用户名已经完美了,但是!!!对于前端的东东用户都可以绕过去的,最典型的是通过浏览器的调试来绕过,如下:
所以有必要在后端针对这种从前端绕过去来修改用户名的情况进行一下处理,那如何处理呢?有一种简单的改法,就是在后端忽略用户名的更新既可,也就是不管前端针对用户名做何等操作,都忽略,具体可以这样来做:
其实它有另一个方法可以满足咱们目前的场景:
而它的功能其实跟进去看一下它的sql定义就知道了:
也就是只有有值的情况下才会更新,为空则就不会更新了,那么思路来了,我们在调它之前,主动将loginName给置空不就行了,如下:
好,此时再运行看一下效果:
完美解决。
关于密码的两层加密处理:
概述:
接下来咱们来处理密码加密的问题了,如上面也提到过,目前咱们的用户名的密码都是明文存储的:
这是一个非常危险的事情,假如数据被泄漏了,所有用户的密码也就知道了,所以必须得加密存储,下面来处理下。
密码加密存储:
修改也比较简单,在后端保存的时候,使用springframework的md5进行一下加密既可,如下:
此时运行看一下:
此时看一下库里的密码是否已经加密了:
但是!!!现在加密之后在前端编辑时就会有一些问题了,每次编辑,如果不想改密码只改昵称,貌似密码也每次都会变:
关于这个问题之后再来解决,目前先解决保存时加密的问题。
密码加密传输:
看似目前编辑加密没问题,其实在前端这块传输还是有问题的,这里看一下:
发现问题了么?前端的密码其实还是明文的,那当然也得加密喽,其加密方式也是用MD5,下面来实现一下。
1、拷贝一个md5加密的js:
而此md5的文件地址为:https://blog-static.cnblogs.com/files/webor2006/md5.js?t=1691852396&download=true,其中我们需要调用的函数就是:
2、引入到页面中:
接下来咱们就可以将这个js引入到用户管理的页面中了,其引入方法如下:
然后在用户管理页面在保存时对用户的密码进行一下加密处理:
加入这么一句:
但是!!!报错了呀,其中hexMd5就是调用我们新引入的md5.js中的函数,因为它是全局的,哪个页面都可以调用,而KEY也是定义在md5.js中的:
这个叫“盐值”,俗称的“加盐”,为啥要加一个盐值呢?关于这块可以网上搜一下,比如密码“123”,如果不加盐值,它的md5是固定的,根据md5可能很容易知道它是"123",但是如果这个密码加了一串特殊字符再进行md5,此时用户就很难逆推出来原密了。好,还是回到解决这个报错问题上来,其实是因为我们页面上使用的typescript,它默认是无法直接识别javascript的这个方法和变量,需要这样声明一下就可以了:
此时咱们再来运行看一下:
这就是密码的两层加密,一层是前端的md5,另一层是后端的md5。
增加重置密码功能:
修改用户时,不能修改密码:
对于加了密的密码,在编辑时是不应该再让用户能进行修改的,毕竟加了密的密文再编辑是没有意义的,所以这里还是在编辑时将密码表单栏隐藏,但是在新增用户时是需要显示的,如下:
与v-show功能类似的还有一个v-if,关于这俩的区别其实在之前的学习中也提到过了,可以参考它:https://www.cnblogs.com/webor2006/p/17510360.html,运行看一下:
同样的,对于后端也需要做一下处理,避免绕过前端能对密码进行修改,如下:
单独开发重置密码表单和接口:
概述:
对于用户的密码有可能会有忘记的情况,此时就应该有一个重置用户密码的功能。
1、准备接口:
这里先来准备重置的接口,如下:
其中UserResetPasswordReq新建了一个,因为它里面的入参只需要一个密码既可,跟用户保存的入参是不一样的:
package com.cexo.wiki.req; import javax.validation.constraints.NotNull; import javax.validation.constraints.Pattern; public class UserResetPasswordReq { private Long id; @NotNull(message = "【密码】不能为空") // @Length(min = 6, max = 20, message = "【密码】6~20位") @Pattern(regexp = "^(?![0-9]+$)(?![a-zA-Z]+$)[0-9A-Za-z]{6,32}$", message = "【密码】至少包含 数字和英文,长度6-32") private String password; public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } @Override public String toString() { StringBuilder sb = new StringBuilder(); sb.append(getClass().getSimpleName()); sb.append(" ["); sb.append("Hash = ").append(hashCode()); sb.append(", id=").append(id); sb.append(", password=").append(password); sb.append("]"); return sb.toString(); } }
接下来再来实现service层的代码:
2、准备重置入口:
先在列表操作按钮上新增一个重置:
此时运行看一下:
其中点击事件定义如下:
3、实现重置:
接下来则来实现重置的功能。
1、准备重置的模态框:
这个模态框可以copy至编辑时的模态框,只是表单内容不一样,比较简单,这里细节就略过了,直接贴相关的代码:
代码:
<a-modal title="重置密码" v-model:visible="resetModalVisible" :confirm-loading="resetModalLoading" @ok="handleResetModalOk" > <a-form :model="user" :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }"> <a-form-item label="新密码"> <a-input v-model:value="user.password"/> </a-form-item> </a-form> </a-modal>
然后定义相关的变量及函数实现,这块也没有任何新的技术点,也直接贴出来了:
代码:
// -------- 重置密码 --------- const resetModalVisible = ref(false); const resetModalLoading = ref(false); const handleResetModalOk = () => { resetModalLoading.value = true; user.value.password = hexMd5(user.value.password + KEY); axios.post("/user/reset-password", user.value).then((response) => { resetModalLoading.value = false; const data = response.data; if (data.success) { resetModalVisible.value = false; // 重新加载列表 handleQuery({ page: pagination.value.current, size: pagination.value.pageSize, }); } else { message.error(data.message); } }); };
2、重置密码点击事件处理:
接下来则来实现密码重置点击事件的逻辑,也就是将咱们准备的重置模态框给展示出来:
4、运行:
接下来咱们运行看一下效果:
可以看到,我新建了一个账号,将密码设置成123之后,跟test2这个用户密码也是123最后生成的md5是同一个值:
证明密码重置之后的密码是好使的,当然最终得要实现了登录功能再来进行这块密码修改功能是否一切正常,用户登录模块后续做到时再来验证。
单点登录token与JWT介绍:
目前为止,咱们已经将用户的管理功能实现了,那接下来就可以来进行用户登录功能的开发了,这里在开发之前,先理论化了解登录的一些概念,刚好也篇末了,埋个伏笔。
登录流程:
这里先来了解一下通常登录的整个流程,总体分为两大块:登录和校验。
登录:
1、前端输入用户名和密码。
2、校验用户名和密码。(包含基本的用户名和密码的格式校验、用户名和密码是否匹配校验)。
3、生成token,也称令牌、登录标识,其实也就是一串“唯一”的字符串(既使是同一个用户,登录多次,每次的token也是不一样的)。
4、后端保存token(最终会保存到redis中)。
5、前端保存token。
校验:
1、前端请求时,带上token,但并非所有的接口都需要校验,一般就是管理类的接口(增删改)是需要校验token的。(通常是token是放在header请求头里)
2、登录拦截器,校验token。(到redis获取token)
3、校验成功则继续后面的业务。
4、校验失败则跳回到登录界面。
单点登录系统:【了解】
在上面的登录流程中可以看到其流程还是很多的, 如果有很多系统都需要来自己实现一遍,那成本比较高,也不好维护,所以可以将它做成一个登录系统,以后所有产品需要登录功能都跳到这个系统里,当然做法有两种:一种是该系统已经带登录界面及接口,第二种是各个产品自己维护登录界面,这套系统只维护登录相关的接口。而通常这套系统包含如下功能:用户管理、登录、登录校验、退出登录,简单了解一下,不同公司对于它的定义也不一样。
token与JWT:【了解】
再来了解一下概念,对于token+redis,其实这个token,只要保证它唯一,可以用md5字符串、时间戳等,它的特点就是其值是无意义的,因为它不能代表任何业务意义;但是对于JWT就不一样了,说到不一样当然它的token是有意义的喽,是的,因为它是将用户的业务信息通过加密手段而生成的token,所以通过token就可以解出来用户的信息,说了这么多,先度娘一下JWT,在这篇https://blog.csdn.net/weixin_45410366/article/details/125031959文章里是这么说明的:
也一知半解,它其实有一个官网https://jwt.io/,打开了解一下:
哦,原来JWT的全称是JSON Web Tokens,官网这句描述其实也不知道它是干嘛的,往下翻,官网直接给出了一个在线的效果:
其中加密的算法有很多种,可以根据实际情况来选择:
也就是这个token信息是有意义的,通过这个token是可以解密的,而要使用它,则需要添加如下依赖:
而核心使用就是这两个:
这里仅当做个知识了解,待未来真正使用到它时再进一步了解,下篇就正式进入用户登录功能的开发,这篇先这样了。