一、数据表设计
1、需求分析
(1)目的:
由于此项目作为一个后台管理系统模板,不同用户登录后应该有不同的操作权限,所以此处实现一个简单的菜单权限控制。即不同用户登录系统后,会展示不同的菜单,并对菜单具有操作(增删改查)的权限。
(2)数据表设计(自己瞎捣鼓的,有不对的地方还望 DBA 大神不吝赐教(=_=)):
需求:
一个用户登录系统后,根据其所代表的的角色,去查询其对应的菜单权限,并返回相应的菜单数据。
整个设计核心可以分为:用户、用户角色(下面简称角色)、菜单权限(下面简称菜单)。
思考一:
一个用户只拥有一个角色,一个角色可以被多个用户拥有。
一个角色可以有多个菜单,一个菜单可以被多个角色拥有。
即 角色 与 用户间为 1 对 多关系,角色 与 菜单 间为 多对多关系。
所以可以在用户表中定义一个字段作为外键 关联到 角色表。
而角色表 与 菜单表 采用 中间表去维护。
思考二:
一个用户可以有多个角色,一个角色可以被多个用户拥有。
一个角色可以有多个菜单,一个菜单可以被多个角色拥有。
即 菜单 与 角色 间属于 多对多关系,用户 与 角色间 也属于 多对多关系。
所以 用户表 与 角色表间、角色表 与 菜单表间均可以采用 中间表维护。
为了避免使用外键,此处我均采用中间表对三张表进行数据关联。
最终设计(三个主表,两个中间表):
用户表 sys_user
用户角色表 sys_user_role
角色表 sys_role
角色菜单表 sys_role_menu
菜单表 sys_menu
2、用户表(sys_user)设计
(1)必须字段:
用户 ID、用户名、用户手机号、用户密码。
其中:
用户手机号 作为用户注册、登录的依据(用户名也可以登录)。
用户名为 用户登录后显示的 昵称。
用户密码 需要密文存储(此项目中 前端、后端均对密码进行 MD5 加密处理)。
(2)数据表结构如下:
-- DROP DATABASE IF EXISTS admin_template;
--
-- CREATE DATABASE admin_template;
-- --------------------------sys_user 用户表---------------------------------------
USE admin_template;
DROP TABLE IF EXISTS sys_user;
-- 用户表
CREATE TABLE sys_user (
id bigint NOT NULL COMMENT '用户 ID',
name varchar(20) NOT NULL COMMENT '用户名',
mobile varchar(20) NOT NULL COMMENT '用户手机号',
password varchar(64) NOT NULL COMMENT '用户密码',
sex tinyint DEFAULT NULL COMMENT '性别, 0 表示女, 1 表示男',
age tinyint DEFAULT NULL COMMENT '年龄',
avatar varchar(255) DEFAULT NULL COMMENT '头像',
email varchar(100) DEFAULT NULL COMMENT '邮箱',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_time datetime DEFAULT NULL COMMENT '修改时间',
delete_flag tinyint DEFAULT NULL COMMENT '逻辑删除标志,0 表示未删除, 1 表示删除',
disabled_flag tinyint DEFAULT NULL COMMENT '禁用标志, 0 表示未禁用, 1 表示禁用',
wx_id varchar(128) DEFAULT NULL COMMENT '微信 openid(拓展字段、用于第三方微信登录)',
qq_id varchar(128) DEFAULT NULL COMMENT 'QQ openid(拓展字段、用于第三方 QQ 登录)',
PRIMARY KEY(id),
UNIQUE INDEX(name),
UNIQUE INDEX(mobile)
) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COMMENT='系统用户表';
-- 插入数据
INSERT INTO `sys_user`(`id`, `name`, `mobile`, `password`, `sex`, `age`, `avatar`, `email`, `create_time`, `update_time`, `delete_flag`, `disabled_flag`, `wx_id`, `qq_id`)
VALUES (1278601251755454466, 'superAdmin', '17730125031', 'e10adc3949ba59abbe56e057f20f883e', 1, 23, NULL, "m_17730125031@163.com", '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0, 0, NULL, NULL),
(1278601251755451232, 'admin', '17730125032', 'e10adc3949ba59abbe56e057f20f883e', 1, 23, NULL, "m_17730125031@163.com", '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0, 0, NULL, NULL),
(1278601251755456778, 'jack', '17730125033', 'e10adc3949ba59abbe56e057f20f883e', 1, 23, NULL, "m_17730125031@163.com", '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0, 0, NULL, NULL);
-- --------------------------sys_user 用户表---------------------------------------
3、角色表(sys_role)设计
(1)必须字段:
角色 ID,角色名称。
其中:
角色名称用于定位用户角色。
(2)数据表结构如下:
-- DROP DATABASE IF EXISTS admin_template;
--
-- CREATE DATABASE admin_template;
-- --------------------------sys_role 角色表---------------------------------------
USE admin_template;
DROP TABLE IF EXISTS sys_role;
-- 系统用户角色表
CREATE TABLE sys_role (
id bigint NOT NULL COMMENT '角色 ID',
role_name varchar(20) NOT NULL COMMENT '角色名称',
role_code varchar(20) DEFAULT NULL COMMENT '角色码',
remark varchar(255) DEFAULT NULL COMMENT '角色备注',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_time datetime DEFAULT NULL COMMENT '修改时间',
delete_flag tinyint DEFAULT NULL COMMENT '逻辑删除标志,0 表示未删除, 1 表示删除',
PRIMARY KEY(id)
) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COMMENT='系统用户角色表';
-- 插入数据
INSERT INTO `sys_role`(`id`, `role_name`, `role_code`, `remark`, `create_time`, `update_time`, `delete_flag`)
VALUES (1278601251755451245, 'superAdmin', '1001', '超级管理员','2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755452551, 'admin', '2001', '普通管理员','2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755458779, 'user', '3001', '普通用户','2020-07-02 16:07:48', '2020-07-02 16:07:48', 0);
-- --------------------------sys_role 角色表---------------------------------------
4、菜单权限表(sys_menu)设计
(1)必须字段:
当前菜单 ID,父菜单 ID,菜单名,菜单类型,菜单路径
其中:
当前菜单 ID 与 父菜单 ID 用于确定菜单的层级顺序。
菜单类型 用于确定是否显示在菜单目录中(按钮不显示在菜单目录中)。
菜单路径 用于确定最终指向的 组件路径(使用 vue-route 进行路由跳转)。
注:
最外层 父菜单 ID 此处设置为 0,但不创建 ID 为 0 的数据。
(2)数据表结构如下:
-- DROP DATABASE IF EXISTS admin_template;
--
-- CREATE DATABASE admin_template;
-- --------------------------sys_menu 菜单权限表---------------------------------------
USE admin_template;
DROP TABLE IF EXISTS sys_menu;
-- 系统菜单权限表
CREATE TABLE sys_menu (
menu_id bigint NOT NULL COMMENT '当前菜单 ID',
parent_id bigint NOT NULL COMMENT '当前菜单父菜单 ID',
name_zh varchar(20) NOT NULL COMMENT '中文菜单名称',
name_en varchar(40) NOT NULL COMMENT '英文菜单名称',
type tinyint NOT NULL COMMENT '菜单类型,0 表示目录,1 表示菜单项,2 表示按钮',
url varchar(100) NOT NULL COMMENT '访问路径',
icon varchar(100) DEFAULT NULL COMMENT '菜单图标',
order_num int DEFAULT NULL COMMENT '菜单项顺序',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_time datetime DEFAULT NULL COMMENT '修改时间',
delete_flag tinyint DEFAULT NULL COMMENT '逻辑删除标志,0 表示未删除, 1 表示删除',
PRIMARY KEY(menu_id)
) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COMMENT='系统菜单权限表';
-- 插入数据
INSERT INTO `sys_menu`(`menu_id`, `parent_id`, `name_zh`, `name_en`, `type`, `url`, `icon`, `order_num`, `create_time`, `update_time`, `delete_flag`)
VALUES (127860125171111, 0, '系统管理', 'System Control', 0, '', 'el-icon-setting', 0,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(127860125172211, 127860125171111, '用户管理', 'User Control', 1, 'sys/UserList', 'el-icon-user', 1,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(127860125173311, 127860125171111, '角色管理', 'Role Control', 1, 'sys/RoleControl', 'el-icon-price-tag', 2,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(127860125174411, 127860125171111, '菜单管理', 'Menu Control', 1, 'sys/MenuControl', 'el-icon-menu', 3,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(127860125172221, 127860125172211, '添加', 'Add', 2, '', '', 1,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(127860125172231, 127860125172211, '删除', 'Delete', 2, '', '', 2,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(127860125172241, 127860125172211, '修改', 'Update', 2, '', '', 3,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(127860125172251, 127860125172211, '查看', 'List', 2, '', '', 4,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(127860125173321, 127860125173311, '添加', 'Add', 2, '', '', 1,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(127860125173331, 127860125173311, '删除', 'Delete', 2, '', '', 2,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(127860125173341, 127860125173311, '修改', 'Update', 2, '', '', 3,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(127860125173351, 127860125173311, '查看', 'List', 2, '', '', 4,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(127860125174421, 127860125174411, '添加', 'Add', 2, '', '', 1,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(127860125174431, 127860125174411, '删除', 'Delete', 2, '', '', 2,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(127860125174441, 127860125174411, '修改', 'Update', 2, '', '', 3,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(127860125174451, 127860125174411, '查看', 'List', 2, '', '', 4,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(127860125175511, 0, '帮助', 'help', 0, '', 'el-icon-info', 1,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(127860125175521, 127860125175511, '百度', 'Baidu', 1, 'https://www.baidu.com/', 'el-icon-menu', 1,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(127860125175531, 127860125175511, '博客', 'Blog', 1, '', 'el-icon-menu', 2,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0);
-- --------------------------sys_menu 菜单权限表---------------------------------------
5、中间表设计(sys_user_role、sys_role_menu)
(1)设计原则:
中间表存储的是相关联两表的主键。
(2)用户角色表如下:
-- DROP DATABASE IF EXISTS admin_template;
--
-- CREATE DATABASE admin_template;
-- --------------------------sys_user_role 用户角色表---------------------------------------
USE admin_template;
DROP TABLE IF EXISTS sys_user_role;
-- 系统用户角色表
CREATE TABLE sys_user_role (
id bigint NOT NULL COMMENT '用户角色表 ID',
role_id bigint NOT NULL COMMENT '角色 ID',
user_id bigint NOT NULL COMMENT '用户 ID',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_time datetime DEFAULT NULL COMMENT '修改时间',
delete_flag tinyint DEFAULT NULL COMMENT '逻辑删除标志,0 表示未删除, 1 表示删除',
PRIMARY KEY(id)
) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COMMENT='系统用户角色表';
-- 插入数据
INSERT INTO `sys_user_role`(`id`, `role_id`, `user_id`, `create_time`, `update_time`, `delete_flag`)
VALUES (1278601251755452234, '1278601251755451245', '1278601251755454466', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755453544, '1278601251755452551', '1278601251755451232', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755454664, '1278601251755458779', '1278601251755456778', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0);
-- --------------------------sys_user_role 用户角色表---------------------------------------
(3)角色菜单表如下:
-- DROP DATABASE IF EXISTS admin_template;
--
-- CREATE DATABASE admin_template;
-- --------------------------sys_role_menu 系统角色菜单表---------------------------------------
USE admin_template;
DROP TABLE IF EXISTS sys_role_menu;
-- 系统角色菜单表
CREATE TABLE sys_role_menu (
id bigint NOT NULL COMMENT '角色菜单表 ID',
role_id bigint NOT NULL COMMENT '角色 ID',
menu_id varchar(20) NOT NULL COMMENT '菜单 ID',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_time datetime DEFAULT NULL COMMENT '修改时间',
delete_flag tinyint DEFAULT NULL COMMENT '逻辑删除标志,0 表示未删除, 1 表示删除',
PRIMARY KEY(id)
) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COMMENT='系统角色菜单表';
-- 插入数据
INSERT INTO `sys_role_menu`(`id`, `role_id`, `menu_id`, `create_time`, `update_time`, `delete_flag`)
VALUES (1278601251755461111, '1278601251755451245', '1278601251755451111', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755461112, '1278601251755451245', '1278601251755452211', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755461113, '1278601251755451245', '1278601251755453311', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755461114, '1278601251755451245', '1278601251755454411', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755461115, '1278601251755451245', '1278601251755452221', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755461116, '1278601251755451245', '1278601251755452231', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755461117, '1278601251755451245', '1278601251755452241', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755461118, '1278601251755451245', '1278601251755452251', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755461119, '1278601251755451245', '1278601251755453321', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755461120, '1278601251755451245', '1278601251755453331', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755461121, '1278601251755451245', '1278601251755453341', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755461122, '1278601251755451245', '1278601251755453351', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755461123, '1278601251755451245', '1278601251755454421', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755461124, '1278601251755451245', '1278601251755454431', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755461125, '1278601251755451245', '1278601251755454441', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755461126, '1278601251755451245', '1278601251755454451', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755461127, '1278601251755451245', '1278601251755455511', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755461128, '1278601251755451245', '1278601251755455521', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755461129, '1278601251755451245', '1278601251755455531', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755462111, '1278601251755452551', '1278601251755451111', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755462112, '1278601251755452551', '1278601251755452211', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755462113, '1278601251755452551', '1278601251755453311', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755462114, '1278601251755452551', '1278601251755454411', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755462115, '1278601251755452551', '1278601251755452251', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755462116, '1278601251755452551', '1278601251755453351', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755462117, '1278601251755452551', '1278601251755454451', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755462118, '1278601251755452551', '1278601251755455511', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755462119, '1278601251755452551', '1278601251755455521', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755462120, '1278601251755452551', '1278601251755455531', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755463111, '1278601251755458779', '1278601251755455511', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755463112, '1278601251755458779', '1278601251755455521', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755463113, '1278601251755458779', '1278601251755455531', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0);
-- --------------------------sys_role_menu 系统角色菜单表---------------------------------------
6、完整表结构以及相关数据插入
-- DROP DATABASE IF EXISTS admin_template;
--
-- CREATE DATABASE admin_template;
-- --------------------------sys_user 用户表---------------------------------------
USE admin_template;
DROP TABLE IF EXISTS sys_user;
-- 用户表
CREATE TABLE sys_user (
id bigint NOT NULL COMMENT '用户 ID',
name varchar(20) NOT NULL COMMENT '用户名',
mobile varchar(20) NOT NULL COMMENT '用户手机号',
password varchar(64) NOT NULL COMMENT '用户密码',
sex tinyint DEFAULT NULL COMMENT '性别, 0 表示女, 1 表示男',
age tinyint DEFAULT NULL COMMENT '年龄',
avatar varchar(255) DEFAULT NULL COMMENT '头像',
email varchar(100) DEFAULT NULL COMMENT '邮箱',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_time datetime DEFAULT NULL COMMENT '修改时间',
delete_flag tinyint DEFAULT NULL COMMENT '逻辑删除标志,0 表示未删除, 1 表示删除',
disabled_flag tinyint DEFAULT NULL COMMENT '禁用标志, 0 表示未禁用, 1 表示禁用',
wx_id varchar(128) DEFAULT NULL COMMENT '微信 openid(拓展字段、用于第三方微信登录)',
qq_id varchar(128) DEFAULT NULL COMMENT 'QQ openid(拓展字段、用于第三方 QQ 登录)',
PRIMARY KEY(id),
UNIQUE INDEX(name, mobile)
) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COMMENT='系统用户表';
-- 插入数据
INSERT INTO `sys_user`(`id`, `name`, `mobile`, `password`, `sex`, `age`, `avatar`, `email`, `create_time`, `update_time`, `delete_flag`, `disabled_flag`, `wx_id`, `qq_id`)
VALUES (1278601251755454466, 'superAdmin', '17730125031', 'e10adc3949ba59abbe56e057f20f883e', 1, 23, NULL, "m_17730125031@163.com", '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0, 0, NULL, NULL),
(1278601251755451232, 'admin', '17730125032', 'e10adc3949ba59abbe56e057f20f883e', 1, 23, NULL, "m_17730125031@163.com", '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0, 0, NULL, NULL),
(1278601251755456778, 'jack', '17730125033', 'e10adc3949ba59abbe56e057f20f883e', 1, 23, NULL, "m_17730125031@163.com", '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0, 0, NULL, NULL);
-- --------------------------sys_user 用户表---------------------------------------
-- --------------------------sys_role 角色表---------------------------------------
USE admin_template;
DROP TABLE IF EXISTS sys_role;
-- 系统用户角色表
CREATE TABLE sys_role (
id bigint NOT NULL COMMENT '角色 ID',
role_name varchar(20) NOT NULL COMMENT '角色名称',
role_code varchar(20) DEFAULT NULL COMMENT '角色码',
remark varchar(255) DEFAULT NULL COMMENT '角色备注',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_time datetime DEFAULT NULL COMMENT '修改时间',
delete_flag tinyint DEFAULT NULL COMMENT '逻辑删除标志,0 表示未删除, 1 表示删除',
PRIMARY KEY(id)
) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COMMENT='系统用户角色表';
-- 插入数据
INSERT INTO `sys_role`(`id`, `role_name`, `role_code`, `remark`, `create_time`, `update_time`, `delete_flag`)
VALUES (1278601251755451245, 'superAdmin', '1001', '超级管理员','2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755452551, 'admin', '2001', '普通管理员','2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755458779, 'user', '3001', '普通用户','2020-07-02 16:07:48', '2020-07-02 16:07:48', 0);
-- --------------------------sys_role 角色表---------------------------------------
-- --------------------------sys_user_role 用户角色表---------------------------------------
USE admin_template;
DROP TABLE IF EXISTS sys_user_role;
-- 系统用户角色表
CREATE TABLE sys_user_role (
id bigint NOT NULL COMMENT '用户角色表 ID',
role_id bigint NOT NULL COMMENT '角色 ID',
user_id bigint NOT NULL COMMENT '用户 ID',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_time datetime DEFAULT NULL COMMENT '修改时间',
delete_flag tinyint DEFAULT NULL COMMENT '逻辑删除标志,0 表示未删除, 1 表示删除',
PRIMARY KEY(id)
) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COMMENT='系统用户角色表';
-- 插入数据
INSERT INTO `sys_user_role`(`id`, `role_id`, `user_id`, `create_time`, `update_time`, `delete_flag`)
VALUES (1278601251755452234, '1278601251755451245', '1278601251755454466', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755453544, '1278601251755452551', '1278601251755451232', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755454664, '1278601251755458779', '1278601251755456778', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0);
-- --------------------------sys_user_role 用户角色表---------------------------------------
-- --------------------------sys_menu 菜单权限表---------------------------------------
USE admin_template;
DROP TABLE IF EXISTS sys_menu;
-- 系统菜单权限表
CREATE TABLE sys_menu (
menu_id bigint NOT NULL COMMENT '当前菜单 ID',
parent_id bigint NOT NULL COMMENT '当前菜单父菜单 ID',
name_zh varchar(20) NOT NULL COMMENT '中文菜单名称',
name_en varchar(40) NOT NULL COMMENT '英文菜单名称',
type tinyint NOT NULL COMMENT '菜单类型,0 表示目录,1 表示菜单项,2 表示按钮',
url varchar(100) NOT NULL COMMENT '访问路径',
icon varchar(100) DEFAULT NULL COMMENT '菜单图标',
order_num int DEFAULT NULL COMMENT '菜单项顺序',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_time datetime DEFAULT NULL COMMENT '修改时间',
delete_flag tinyint DEFAULT NULL COMMENT '逻辑删除标志,0 表示未删除, 1 表示删除',
PRIMARY KEY(menu_id)
) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COMMENT='系统菜单权限表';
-- 插入数据
INSERT INTO `sys_menu`(`menu_id`, `parent_id`, `name_zh`, `name_en`, `type`, `url`, `icon`, `order_num`, `create_time`, `update_time`, `delete_flag`)
VALUES (127860125171111, 0, '系统管理', 'System Control', 0, '', 'el-icon-setting', 0,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(127860125172211, 127860125171111, '用户管理', 'User Control', 1, 'sys/UserList', 'el-icon-user', 1,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(127860125173311, 127860125171111, '角色管理', 'Role Control', 1, 'sys/RoleControl', 'el-icon-price-tag', 2,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(127860125174411, 127860125171111, '菜单管理', 'Menu Control', 1, 'sys/MenuControl', 'el-icon-menu', 3,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(127860125172221, 127860125172211, '添加', 'Add', 2, '', '', 1,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(127860125172231, 127860125172211, '删除', 'Delete', 2, '', '', 2,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(127860125172241, 127860125172211, '修改', 'Update', 2, '', '', 3,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(127860125172251, 127860125172211, '查看', 'List', 2, '', '', 4,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(127860125173321, 127860125173311, '添加', 'Add', 2, '', '', 1,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(127860125173331, 127860125173311, '删除', 'Delete', 2, '', '', 2,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(127860125173341, 127860125173311, '修改', 'Update', 2, '', '', 3,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(127860125173351, 127860125173311, '查看', 'List', 2, '', '', 4,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(127860125174421, 127860125174411, '添加', 'Add', 2, '', '', 1,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(127860125174431, 127860125174411, '删除', 'Delete', 2, '', '', 2,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(127860125174441, 127860125174411, '修改', 'Update', 2, '', '', 3,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(127860125174451, 127860125174411, '查看', 'List', 2, '', '', 4,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(127860125175511, 0, '帮助', 'help', 0, '', 'el-icon-info', 1,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(127860125175521, 127860125175511, '百度', 'Baidu', 1, 'https://www.baidu.com/', 'el-icon-menu', 1,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(127860125175531, 127860125175511, '博客', 'Blog', 1, '', 'el-icon-menu', 2,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0);
-- --------------------------sys_menu 菜单权限表---------------------------------------
-- --------------------------sys_role_menu 系统角色菜单表---------------------------------------
USE admin_template;
DROP TABLE IF EXISTS sys_role_menu;
-- 系统角色菜单表
CREATE TABLE sys_role_menu (
id bigint NOT NULL COMMENT '角色菜单表 ID',
role_id bigint NOT NULL COMMENT '角色 ID',
menu_id varchar(20) NOT NULL COMMENT '菜单 ID',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_time datetime DEFAULT NULL COMMENT '修改时间',
delete_flag tinyint DEFAULT NULL COMMENT '逻辑删除标志,0 表示未删除, 1 表示删除',
PRIMARY KEY(id)
) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COMMENT='系统角色菜单表';
-- 插入数据
INSERT INTO `sys_role_menu`(`id`, `role_id`, `menu_id`, `create_time`, `update_time`, `delete_flag`)
VALUES (1278601251755461111, '1278601251755451245', '1278601251755451111', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755461112, '1278601251755451245', '1278601251755452211', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755461113, '1278601251755451245', '1278601251755453311', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755461114, '1278601251755451245', '1278601251755454411', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755461115, '1278601251755451245', '1278601251755452221', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755461116, '1278601251755451245', '1278601251755452231', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755461117, '1278601251755451245', '1278601251755452241', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755461118, '1278601251755451245', '1278601251755452251', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755461119, '1278601251755451245', '1278601251755453321', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755461120, '1278601251755451245', '1278601251755453331', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755461121, '1278601251755451245', '1278601251755453341', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755461122, '1278601251755451245', '1278601251755453351', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755461123, '1278601251755451245', '1278601251755454421', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755461124, '1278601251755451245', '1278601251755454431', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755461125, '1278601251755451245', '1278601251755454441', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755461126, '1278601251755451245', '1278601251755454451', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755461127, '1278601251755451245', '1278601251755455511', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755461128, '1278601251755451245', '1278601251755455521', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755461129, '1278601251755451245', '1278601251755455531', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755462111, '1278601251755452551', '1278601251755451111', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755462112, '1278601251755452551', '1278601251755452211', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755462113, '1278601251755452551', '1278601251755453311', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755462114, '1278601251755452551', '1278601251755454411', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755462115, '1278601251755452551', '1278601251755452251', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755462116, '1278601251755452551', '1278601251755453351', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755462117, '1278601251755452551', '1278601251755454451', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755462118, '1278601251755452551', '1278601251755455511', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755462119, '1278601251755452551', '1278601251755455521', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755462120, '1278601251755452551', '1278601251755455531', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755463111, '1278601251755458779', '1278601251755455511', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755463112, '1278601251755458779', '1278601251755455521', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0),
(1278601251755463113, '1278601251755458779', '1278601251755455531', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0);
-- --------------------------sys_role_menu 系统角色菜单表---------------------------------------
二、完善注册登录逻辑
1、注册、登录需求分析:
(1)用户种类:
超级管理员、普通管理员、普通用户。
其中:
通过注册方式创建的用户均为 普通用户。
普通管理员由超级管理员创建。
超级管理员使用 系统默认的数据(不可创建、修改)。
默认:
普通用户 -- 账号:jack 密码:123456
普通管理员 -- 账号:admin 密码:123456
超级管理员 -- 账号:superAdmin 密码:123456
(2)注册需求:
输入用户名、密码,并根据 手机号 发送验证码进行注册。
其中:
用户名 不能为 纯数字 组成 或者 包含 @ 符号(为了与手机号、邮箱进行区分)。
密码前后端均采用 MD5 加密,两次加密。
验证码时效性为 5 分钟(此项目中借用 redis 进行过期时间控制)。
(3)登录需求:
登录方式:密码登录、短信登录。
其中:
短信登录 是根据 手机号以及验证码 进行登录(跳过密码输入操作)。
密码登录 是根据 手机号 或者 用户名 加密码 的方式进行登录。
登录时提供忘记密码功能,根据手机号重置密码。
登录时限制同一账号登陆人数。
注:
此项目中限制同一账号登陆人数为 1 人,即同时只允许一个 账号登陆系统。
实现限制同一账号登陆人数思路:
并发执行时,存在同一个用户在多处同时登陆,此处为了限制只能允许一个人登陆系统,使用 redis 进行辅助。其中 key 为 用户名(或者 ID 值)、 value 为 token 值(JWT 值)。
用户第一次访问系统时,首先判定是否为第一次登录系统(检查 redis 中是否存在 token),不存在则为第一次登录,需要将 token 存入 redis 中,并将该 token 返回给用户。存在则继续判定是否为重复登录系统(检查 token 是否一致)。token 一致,则为同一用户再次访问系统。token 不一致,则用户为重复登录系统,此时需要剔除前一个登录用户(比较当前 token 与 redis 中 token 的时间戳),如果当前 token 时间戳 大于等于 redis 中 token 时间戳,则当前时间戳为最新登录者,此时剔除 redis 中的 token 数据(即将 当前 token 数据存入 redis),如果 小于 redis 中 token 时间戳,则 redis 中 token 为最新登录者,需剔除当前 token(不返回 token 给用户,即登录失败,引导用户重新登录)。
注意:
此处为了实现效果,还需要修改 单点登录 逻辑,之前单点登录逻辑中,根据 token 可以直接解析出 用户信息。
但是在此处 token 并不一定有效,因为存在同一用户在多处登录,每一次登录均会产生一个 token(定义拦截器,拦截除了登录请求外的所有请求,这样使每次登录请求均能产生 token,非登录请求验证是否存在 token),此时为了限制只允许一人登录,即只有一个 token 生效。
需要与 redis 中存储的 token 比较后才可确认。若 两者 token 不同,需引导用户重新进行登录操作,并将最新的 token 存入 redis(感觉代码好像变得有点冗余了(=_=),毕竟每次还得与 redis 进行交互,有更方便的方法还望不吝赐教)。
2、生成基本代码
(1)使用 mybatis-plus 代码生成器根据 sys_user 表生成基本代码。
此处不再重复截图,详细使用过程参考:
#_label2_1此处只截细节部分:
Step1:
修改实体类,添加 @TableField(用于自动填充)、@TableLogic(用于逻辑删除) 注解。
Step2:
由于新增了填充字段 disabledFlag,所以需给其添加填充规则。
Step3:
修改 mapper 扫描路径,此处可以使用通配符 **(只用一个 * 不生效时使用两个 **)。
3、编写一个工具类( Md5Util.java) 用于加密密码
(1)目的
此项目中使用 MD5 进行密码加密,使用其他方式亦可。
此加密方式网上随便搜搜就可以搜的到,代码实现也不尽相同,此处代码来源于网络。
(2)代码实现如下:
package com.lyh.admin_template.back.common.utils;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
public class MD5Util {
public static String encrypt(String strSrc) {
try {
char hexChars[] = { '0', '1', '2', '3', '4', '5', '6', '7', '8',
'9', 'a', 'b', 'c', 'd', 'e', 'f' };
byte[] bytes = strSrc.getBytes();
MessageDigest md = MessageDigest.getInstance("MD5");
md.update(bytes);
bytes = md.digest();
int j = bytes.length;
char[] chars = new char[j * 2];
int k = 0;
for (int i = 0; i < bytes.length; i++) {
byte b = bytes[i];
chars[k++] = hexChars[b >>> 4 & 0xf];
chars[k++] = hexChars[b & 0xf];
}
return new String(chars);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("MD5加密出错!!+" + e);
}
}
}
4、调整 JWT 工具类、SMS 工具类
(1)目的:
之前考虑的有点欠缺,这两个工具类使用起来有点问题,稍作修改。
(2)修改 JWT 工具类 JwtUtil.java
主要修改 自定义数据 的方式,以及自定义 过期时间。
package com.lyh.admin_template.back.common.utils;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.apache.commons.lang3.StringUtils;
import javax.servlet.http.HttpServletRequest;
import java.util.Date;
/**
* JWT 操作工具类
*/
public class JwtUtil {
// 设置默认过期时间(15 分钟)
private static final long DEFAULT_EXPIRE = 1000L * 60 * 15;
// 设置 jwt 生成 secret(随意指定)
private static final String APP_SECRET = "ukc8BDbRigUDaY6pZFfWus2jZWLPHO";
/**
* 生成 jwt token,并指定默认过期时间 15 分钟
*/
public static String getJwtToken(Object data) {
return getJwtToken(data, DEFAULT_EXPIRE);
}
/**
* 生成 jwt token,根据指定的 过期时间
*/
public static String getJwtToken(Object data, Long expire) {
String JwtToken = Jwts.builder()
// 设置 jwt 类型
.setHeaderParam("typ", "JWT")
// 设置 jwt 加密方法
.setHeaderParam("alg", "HS256")
// 设置 jwt 主题
.setSubject("admin-user")
// 设置 jwt 发布时间
.setIssuedAt(new Date())
// 设置 jwt 过期时间
.setExpiration(new Date(System.currentTimeMillis() + expire))
// 设置自定义数据
.claim("data", data)
// 设置密钥与算法
.signWith(SignatureAlgorithm.HS256, APP_SECRET)
// 生成 token
.compact();
return JwtToken;
}
/**
* 判断token是否存在与有效,true 表示未过期,false 表示过期或不存在
*/
public static boolean checkToken(String jwtToken) {
if (StringUtils.isEmpty(jwtToken)) {
return false;
}
try {
// 获取 token 数据
Jws<Claims> claimsJws = Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken);
// 判断是否过期
return claimsJws.getBody().getExpiration().after(new Date());
} catch (Exception e) {
throw new RuntimeException(e);
}
}
/**
* 判断token是否存在与有效
*/
public static boolean checkToken(HttpServletRequest request) {
return checkToken(request.getHeader("token"));
}
/**
* 根据 token 获取数据
*/
public static Claims getTokenBody(HttpServletRequest request) {
return getTokenBody(request.getHeader("token"));
}
/**
* 根据 token 获取数据
*/
public static Claims getTokenBody(String jwtToken) {
if (StringUtils.isEmpty(jwtToken)) {
return null;
}
Jws<Claims> claimsJws = Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken);
return claimsJws.getBody();
}
}
(3)修改 短信发送工具类 SmsUtil.java
主要修改 其返回数据的方式,返回 code,而非 boolean 数据。
package com.lyh.admin_template.back.common.utils;
import com.aliyuncs.CommonRequest;
import com.aliyuncs.CommonResponse;
import com.aliyuncs.DefaultAcsClient;
import com.aliyuncs.IAcsClient;
import com.aliyuncs.http.MethodType;
import com.aliyuncs.profile.DefaultProfile;
import com.lyh.admin_template.back.modules.sms.entity.SmsResponse;
import lombok.Data;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* sms 短信发送工具类
*/
@Data
@Component
public class SmsUtil {
@Value("${aliyun.accessKeyId}")
private String accessKeyId;
@Value("${aliyun.accessKeySecret}")
private String accessKeySecret;
@Value("${aliyun.signName}")
private String signName;
@Value("${aliyun.templateCode}")
private String templateCode;
@Value("${aliyun.regionId}")
private String regionId;
private final static String OK = "OK";
/**
* 发送短信
*/
public String sendSms(String phoneNumbers) {
if (StringUtils.isEmpty(phoneNumbers)) {
return null;
}
DefaultProfile profile = DefaultProfile.getProfile(regionId, accessKeyId, accessKeySecret);
IAcsClient client = new DefaultAcsClient(profile);
CommonRequest request = new CommonRequest();
// 固定参数,无需修改
request.setSysMethod(MethodType.POST);
request.setSysDomain("dysmsapi.aliyuncs.com");
request.setSysVersion("2017-05-25");
request.setSysAction("SendSms");
request.putQueryParameter("RegionId", regionId);
// 设置手机号
request.putQueryParameter("PhoneNumbers", phoneNumbers);
// 设置签名模板
request.putQueryParameter("SignName", signName);
// 设置短信模板
request.putQueryParameter("TemplateCode", templateCode);
// 设置短信验证码
String code = getCode();
request.putQueryParameter("TemplateParam", "{\"code\":" + code +"}");
try {
CommonResponse response = client.getCommonResponse(request);
System.out.println(response.getData());
// 转换返回的数据(需引入 Gson 依赖)
SmsResponse smsResponse = GsonUtil.fromJson(response.getData(), SmsResponse.class);
// 当 message 与 code 均为 ok 时,短信发送成功、否则失败
if (SmsUtil.OK.equals(smsResponse.getMessage()) && SmsUtil.OK.equals(smsResponse.getCode())) {
return code;
}
return null;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
/**
* 获取 6 位验证码
*/
public String getCode() {
return String.valueOf((int)((Math.random()*9+1)*100000));
}
}
5、完善三种登录方式
(1)三种登录方式:
密码登录:
用户名 + 密码。
手机号 + 密码。
验证码登录:
手机号 + 验证码。
(2)定义相关 vo 类 以及 进行 国际化、JSR303 处理
定义 vo(viewObject)实体类去接收数据,并对其进行 JSR303 校验,当然国际化也得一起处理。
国际化数据如下:
详细使用请参考:#_label2_4
【en】
sys.user.name.notEmpty=Sys user name cannot be null
sys.user.phone.notEmpty=Sys user mobile cannot be null
sys.user.password.notEmpty=Sys user password cannot be null
sys.user.code.notEmpty=Sys user code cannot be null
sys.user.phone.format.error=Sys user mobile format error
sys.user.name.format.error=Sys user name format error
【zh】
sys.user.name.notEmpty=用户名不能为空
sys.user.phone.notEmpty=用户手机号不能为空
sys.user.password.notEmpty=用户密码不能为空
sys.user.code.notEmpty=验证码不能为空
sys.user.phone.format.error=用户手机号格式错误
sys.user.name.format.error=用户名格式错误
vo 以及 JSR303 数据校验如下:
定义分组,用于不同场景的数据校验(不定义也行)。
详细使用可参考:#_label2_2
【LoginGroup】
package com.lyh.admin_template.back.common.validator.group.sys;
/**
* 新增登录的 Group 校验规则
*/
public interface LoginGroup {
}
【RegisterGroup】
package com.lyh.admin_template.back.common.validator.group.sys;
/**
* 新增注册的 Group 校验规则
*/
public interface RegisterGroup {
}
为了逻辑看起来简单,此处使用了三种 vo 分别接受不同场景下的登录数据。
三种 vo 如下:
【用户名 + 密码】
package com.lyh.admin_template.back.modules.sys.vo;
import com.lyh.admin_template.back.common.validator.group.sys.LoginGroup;
import lombok.Data;
import javax.validation.constraints.NotEmpty;
/**
* 登录时的视图数据类(view object),
* 用于接收使用 用户名 + 密码 登陆的数据与操作。
*/
@Data
public class NamePwdLoginVo {
@NotEmpty(message = "{sys.user.name.notEmpty}", groups = {LoginGroup.class})
private String userName;
@NotEmpty(message = "{sys.user.password.notEmpty}", groups = {LoginGroup.class})
private String password;
}
【手机号 + 密码】
package com.lyh.admin_template.back.modules.sys.vo;
import com.lyh.admin_template.back.common.validator.group.sys.LoginGroup;
import lombok.Data;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Pattern;
/**
* 登录时的视图数据类(view object),
* 用于接收使用 手机号 + 密码 登陆的数据与操作。
*/
@Data
public class PhonePwdLoginVo {
@NotEmpty(message = "{sys.user.phone.notEmpty}", groups = {LoginGroup.class})
@Pattern(message = "{sys.user.phone.format.error}", regexp = "0?(13|14|15|18|17)[0-9]{9}", groups = {LoginGroup.class})
private String phone;
@NotEmpty(message = "{sys.user.password.notEmpty}", groups = {LoginGroup.class})
private String password;
}
【手机号 + 验证码】
package com.lyh.admin_template.back.modules.sys.vo;
import com.lyh.admin_template.back.common.validator.group.sys.LoginGroup;
import lombok.Data;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Pattern;
/**
* 登录时的视图数据类(view object),
* 用于接收使用 手机号 + 验证码 登陆的数据与操作。
*/
@Data
public class PhoneCodeLoginVo {
@NotEmpty(message = "{sys.user.phone.notEmpty}", groups = {LoginGroup.class})
@Pattern(message = "{sys.user.phone.format.error}", regexp = "0?(13|14|15|18|17)[0-9]{9}", groups = {LoginGroup.class})
private String phone;
@NotEmpty(message = "{sys.user.code.notEmpty}", groups = {LoginGroup.class})
private String code;
}
定义一个 vo,用于存储 jwt 自定义数据。
package com.lyh.admin_template.back.modules.sys.vo;
import lombok.Data;
/**
* 保存 JWT 对应存储的数据
*/
@Data
public class JwtVo {
// 保存用户 ID
private Long id;
// 保存用户名
private String name;
// 保存用户手机号
private String phone;
// 保存 JWT 创建时间戳
private Long time;
}
(3)密码登录
主要流程:
接收数据,并对数据校验,对通过校验的数据进行操作。
根据数据去数据库查找数据,若查找失败,则返回相关异常数据。若存在数据,进行下面操作。
使用 JWT 工具类将相关数据封装,并存放在 redis 中,其中以数据 ID 为 key,jwt 为 value。
最后将 jwt 数据返回,命名为 token(前台接收数据并保存,一般存放于 cookie 的 header )。
jwt 与 redis 逻辑需要注意一下:
由于此项目中只允许某用户同时登陆系统的人数为 1,即某用户多次登录时,后一次登录的 jwt 需要替换掉 redis 中的 jwt,并发操作执行可能导致 后一次 jwt 的生成时机 在 redis 中 jwt 之前,直接替换会使最新的登录者被剔除,所以每次登录操作不能直接替换掉 redis 中的 jwt。
每次登录前,生成 jwt 后,应该去查询 redis 中是否存在对应的 jwt,如果不存在,则直接将当前 jwt 存入 redis 中,如果存在,则比较两个 jwt 的时间戳,若 redis 中 jwt 大于当前 jwt,则当前登录失败,否则将当前 jwt 存入 redis 中。
后台代码实现如下:(前台代码后续再整合)
package com.lyh.admin_template.back.modules.sys.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.lyh.admin_template.back.common.utils.*;
import com.lyh.admin_template.back.common.validator.group.sys.LoginGroup;
import com.lyh.admin_template.back.modules.sys.entity.SysUser;
import com.lyh.admin_template.back.modules.sys.service.SysUserService;
import com.lyh.admin_template.back.modules.sys.vo.JwtVo;
import com.lyh.admin_template.back.modules.sys.vo.NamePwdLoginVo;
import com.lyh.admin_template.back.modules.sys.vo.PhonePwdLoginVo;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Date;
/**
* <p>
* 系统用户表 前端控制器
* </p>
*
* @author lyh
* @since 2020-07-02
*/
@RestController
@RequestMapping("/sys/sys-user")
@Api(tags = "用户登录、注册操作")
public class SysUserController {
/**
* 用于操作 sys_user 表
*/
@Autowired
private SysUserService sysUserService;
/**
* 用于操作 redis
*/
@Autowired
private RedisUtil redisUtil;
/**
* 常量,表示用户密码登录操作
*/
private static final String USER_NAME_STATUS = "0";
/**
* 常量,表示手机号密码登录操作
*/
private static final String PHONE_STATUS = "1";
/**
* 获取 jwt
* @return jwt
*/
private String getJwt(SysUser sysUser) {
// 获取需要保存在 jwt 中的数据
JwtVo jwtVo = new JwtVo();
jwtVo.setId(sysUser.getId());
jwtVo.setName(sysUser.getName());
jwtVo.setPhone(sysUser.getMobile());
jwtVo.setTime(new Date().getTime());
// 获取 jwt 数据,设置过期时间为 30 分钟
String jwt = JwtUtil.getJwtToken(jwtVo, 1000L * 60 * 30);
// 判断用户是否重复登录(code 有值则重复登录,需要保留最新的登录者,剔除前一个登录者)
String code = redisUtil.get(String.valueOf(sysUser.getId()));
// 获取当前时间戳
Long currentTime = new Date().getTime();
// 如果 redis 中存在 jwt 数据,则根据时间戳比较谁为最新的登陆者
if (StringUtils.isNotEmpty(code)) {
// 获取 redis 中存储的 jwt 数据
JwtVo redisJwt = GsonUtil.fromJson(String.valueOf(JwtUtil.getTokenBody(code).get("data")), JwtVo.class);
// redis jwt 大于 当前时间戳,则 redis 中 jwt 为最新登录者,当前登录失败
if (redisJwt.getTime() > currentTime) {
return null;
}
}
// 把数据存放在 redis 中,设置过期时间为 30 分钟
redisUtil.set(String.valueOf(sysUser.getId()), jwt, 60 * 30);
return jwt;
}
/**
* 使用密码进行真实登录操作
* @param account 账号(用户名或手机号)
* @param pwd 密码
* @param status 是否使用用户名登录(0 表示用户名登录,1 表示手机号登录)
* @return jwt
*/
private String pwdLogin(String account, String pwd, String status) {
// 新增查询条件
QueryWrapper queryWrapper = new QueryWrapper();
// 如果是用户名 + 密码登录,则根据 姓名 + 密码 查找数据
if (USER_NAME_STATUS.equals(status)) {
queryWrapper.eq("name", account);
}
// 如果是手机号 + 密码登录,则根据 手机号 + 密码 查找数据
if (PHONE_STATUS.equals(status)) {
queryWrapper.eq("mobile", account);
}
// 添加密码条件,密码进行 MD5 加密后再与数据库数据比较
queryWrapper.eq("password", MD5Util.encrypt(pwd));
// 获取用户数据
SysUser sysUser = sysUserService.getOne(queryWrapper);
// 如果存在用户数据
if (sysUser != null) {
return getJwt(sysUser);
}
return null;
}
@ApiOperation(value = "使用用户名、密码登录")
@PostMapping("/login/namePwdLogin")
public Result namePwdLogin(@Validated({LoginGroup.class}) @RequestBody NamePwdLoginVo namePwdLoginVo) {
String jwt = pwdLogin(namePwdLoginVo.getUserName(), namePwdLoginVo.getPassword(), USER_NAME_STATUS);
if (StringUtils.isNotEmpty(jwt)) {
return Result.ok().message("登录成功").data("token", jwt);
}
return Result.error().message("登录失败");
}
@ApiOperation(value = "使用手机号、密码登录")
@PostMapping("/login/phonePwdLogin")
public Result phonePwdLogin(@Validated({LoginGroup.class}) @RequestBody PhonePwdLoginVo phonePwdLoginVo) {
String jwt = pwdLogin(phonePwdLoginVo.getPhone(), phonePwdLoginVo.getPassword(), PHONE_STATUS);
if (StringUtils.isNotEmpty(jwt)) {
return Result.ok().message("登录成功").data("token", jwt);
}
return Result.error().message("登录失败");
}
}
使用 swagger 简单测试一下:
点击用户名 + 密码登录,生成 token,存入 redis 中并设置过期时间 30 分钟(1800 秒)。
点击手机号 + 密码登录,会重新生成 token,并存入 redis 中。
并发操作,可以使用 Jmeter 进行测试(此处省略)。
(4)验证码登录
获取验证码流程:
首先获取验证码(此处不考虑并发情况,毕竟手机号只有一个用户能用,应该避免重复获取验证码的情况),并将其存放与 redis 中,设置过期时间为 5 分钟。
为了避免重复获取验证码,可以根据其已过期时间是否小于 1 分钟判断,即 1 分钟内不可以重复获取验证码。
验证码登录流程:
接收数据,并校验数据,通过检验的数据进行下面处理。
先检查 redis 中是否存在验证码,若不存在验证码(验证码不存在或失效),则登录失败。否则,根据手机号去查询用户数据,生成 jwt,存放与 redis 中并返回。
后台代码实现如下:(前台代码后续再整合)
package com.lyh.admin_template.back.modules.sys.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.lyh.admin_template.back.common.utils.*;
import com.lyh.admin_template.back.common.validator.group.sys.LoginGroup;
import com.lyh.admin_template.back.modules.sys.entity.SysUser;
import com.lyh.admin_template.back.modules.sys.service.SysUserService;
import com.lyh.admin_template.back.modules.sys.vo.JwtVo;
import com.lyh.admin_template.back.modules.sys.vo.PhoneCodeLoginVo;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.Date;
/**
* <p>
* 系统用户表 前端控制器
* </p>
*
* @author lyh
* @since 2020-07-02
*/
@RestController
@RequestMapping("/sys/sys-user")
@Api(tags = "用户登录、注册操作")
public class SysUserController {
/**
* 用于操作 sys_user 表
*/
@Autowired
private SysUserService sysUserService;
/**
* 用于操作 redis
*/
@Autowired
private RedisUtil redisUtil;
/**
* 用于操作 短信验证码发送
*/
@Autowired
private SmsUtil smsUtil;
/**
* 获取 jwt
* @return jwt
*/
private String getJwt(SysUser sysUser) {
// 获取需要保存在 jwt 中的数据
JwtVo jwtVo = new JwtVo();
jwtVo.setId(sysUser.getId());
jwtVo.setName(sysUser.getName());
jwtVo.setPhone(sysUser.getMobile());
jwtVo.setTime(new Date().getTime());
// 获取 jwt 数据,设置过期时间为 30 分钟
String jwt = JwtUtil.getJwtToken(jwtVo, 1000L * 60 * 30);
// 判断用户是否重复登录(code 有值则重复登录,需要保留最新的登录者,剔除前一个登录者)
String code = redisUtil.get(String.valueOf(sysUser.getId()));
// 获取当前时间戳
Long currentTime = new Date().getTime();
// 如果 redis 中存在 jwt 数据,则根据时间戳比较谁为最新的登陆者
if (StringUtils.isNotEmpty(code)) {
// 获取 redis 中存储的 jwt 数据
JwtVo redisJwt = GsonUtil.fromJson(String.valueOf(JwtUtil.getTokenBody(code).get("data")), JwtVo.class);
// redis jwt 大于 当前时间戳,则 redis 中 jwt 为最新登录者,当前登录失败
if (redisJwt.getTime() > currentTime) {
return null;
}
}
// 把数据存放在 redis 中,设置过期时间为 30 分钟
redisUtil.set(String.valueOf(sysUser.getId()), jwt, 60 * 30);
return jwt;
}
/**
* 使用 验证码进行真实登录操作
* @param phone 手机号
* @param code 验证码
* @return jwt
*/
private String codeLogin(String phone, String code) {
// 获取 redis 中存放的验证码
String redisCode = redisUtil.get(phone);
// 存在验证码,且输入的验证码与 redis 存放的验证码相同,则根据手机号去数据库查询数据
if (StringUtils.isNotEmpty(redisCode) && code.equals(redisCode)) {
// 新增查询条件
QueryWrapper queryWrapper = new QueryWrapper();
// 根据手机号去查询数据
queryWrapper.eq("mobile", phone);
SysUser sysUser = sysUserService.getOne(queryWrapper);
// 如果存在用户数据
if (sysUser != null) {
return getJwt(sysUser);
}
}
return null;
}
@ApiOperation(value = "使用手机号、验证码登录")
@PostMapping("/login/phoneCodeLogin")
public Result phoneCodeLogin(@Validated({LoginGroup.class}) @RequestBody PhoneCodeLoginVo phoneCodeLoginVo) {
String jwt = codeLogin(phoneCodeLoginVo.getPhone(), phoneCodeLoginVo.getCode());
if (StringUtils.isNotEmpty(jwt)) {
return Result.ok().message("登录成功").data("token", jwt);
}
return Result.error().message("登录失败");
}
@ApiOperation(value = "获取短信验证码")
@GetMapping("/login/getCode")
public Result getCode(String phone) {
// 设置默认过期时间
Long defaultTime = 60L * 5;
// 先判断 redis 中是否存储过验证码(设置期限为 1 分钟),防止重复获取验证码
Long expire = redisUtil.getExpire(phone);
if (expire != null && (defaultTime - expire < 60)) {
return Result.error().message("验证码已发送,1 分钟后可再次获取验证码");
} else {
// 获取 短信验证码
String code = smsUtil.sendSms(phone);
if (StringUtils.isNotEmpty(code)) {
// 把验证码存放在 redis 中,并设置 过期时间 为 5 分钟
redisUtil.set(phone, code, defaultTime);
return Result.ok().message("验证码获取成功").data("code", code);
}
}
return Result.error().message("验证码获取失败");
}
}
使用 swagger 简单测试一下:
首先获取验证码,其会存放于 redis 中,过期时间为 5 分钟(300 秒)。若 1 分钟内重复点击验证码,会提示相关信息(验证码已发送,1 分钟后再次获取)。
然后根据 手机号和验证码进行登录操作。
6、完善注册逻辑
(1)主要流程:
先获取验证码,验证码处理与验证码登录相同(此处不再重复)。
输入用户名、密码、手机号、以及得到的验证码,后端对数据进行校验,校验通过的数据进行下面操作。
先检查 redis 中是否存在验证码,若不存在验证码(验证码不存在或失效)或者验证码与当前验证码不同,则注册失败,如存在且相同,则进行下面操作。
根据用户名与手机号,对数据库数据进行查找,若存在数据则注册失败,若不存在,则向数据库添加数据。由于给用户名和手机号添加了唯一性约束,所以可以直接进行插入操作,存在数据会返回异常,不存在数据会直接插入。
(2)代码实现如下:
首先定义一个 vo 类,用于接收数据。
package com.lyh.admin_template.back.modules.sys.vo;
import com.lyh.admin_template.back.common.validator.group.sys.RegisterGroup;
import lombok.Data;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Pattern;
/**
* 注册时对应的视图数据类(view object),
* 用于接收并处理 注册时的数据。
*/
@Data
public class RegisterVo {
@NotEmpty(message = "{sys.user.name.notEmpty}", groups = {RegisterGroup.class})
@Pattern(message = "{sys.user.name.format.error}", regexp = "^.*[^\d].*$", groups = {RegisterGroup.class})
private String userName;
@NotEmpty(message = "{sys.user.password.notEmpty}", groups = {RegisterGroup.class})
private String password;
@NotEmpty(message = "{sys.user.phone.notEmpty}", groups = {RegisterGroup.class})
@Pattern(message = "{sys.user.phone.format.error}", regexp = "0?(13|14|15|18|17)[0-9]{9}", groups = {RegisterGroup.class})
private String phone;
@NotEmpty(message = "{sys.user.code.notEmpty}", groups = {RegisterGroup.class})
private String code;
}
接口如下:
由于 注册 用户均属于 普通用户,所以注册的同时需要给其绑定角色,即向 sys_user 插入数据后,还需要向 sys_user_role 插入数据(需要使用代码生成器生成相关代码,此处省略)。
由于出现多表插入操作,此处使用 @Transactional 对事务进行控制。
注:
@Transactional 需要写在 Service 层,写在 Controller 层不生效。
在 service 层定义一个 saveUser 方法。
package com.lyh.admin_template.back.modules.sys.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.lyh.admin_template.back.modules.sys.entity.SysUser;
/**
* <p>
* 系统用户表 服务类
* </p>
*
* @author lyh
* @since 2020-07-02
*/
public interface SysUserService extends IService<SysUser> {
public boolean saveUser(SysUser sysUser);
}
在 service 实现类中,重写方法并完善注册逻辑。
package com.lyh.admin_template.back.modules.sys.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.lyh.admin_template.back.modules.sys.entity.SysRole;
import com.lyh.admin_template.back.modules.sys.entity.SysUser;
import com.lyh.admin_template.back.modules.sys.entity.SysUserRole;
import com.lyh.admin_template.back.modules.sys.mapper.SysUserMapper;
import com.lyh.admin_template.back.modules.sys.service.SysRoleService;
import com.lyh.admin_template.back.modules.sys.service.SysUserRoleService;
import com.lyh.admin_template.back.modules.sys.service.SysUserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/**
* <p>
* 系统用户表 服务实现类
* </p>
*
* @author lyh
* @since 2020-07-02
*/
@Service
public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> implements SysUserService {
@Autowired
private SysRoleService sysRoleService;
@Autowired
private SysUserRoleService sysUserRoleService;
/**
* 先插入数据到 用户表 sys_user 中。
* 再获取数据 ID 与 角色 ID 并插入到 用户角色表 sys_user_role 中。
* @param sysUser 用户数据
* @return true 表示插入成功, false 表示失败
*/
@Override
@Transactional(propagation = Propagation.REQUIRED,isolation = Isolation.DEFAULT,timeout=36000,rollbackFor=Exception.class)
public boolean saveUser(SysUser sysUser) {
// 向 sys_user 表中插入数据
if (this.save(sysUser)) {
// 获取当前用户的 ID
QueryWrapper queryWrapper = new QueryWrapper();
queryWrapper.eq("name", sysUser.getName());
SysUser sysUser2 = this.getOne(queryWrapper);
// 获取普通用户角色 ID
QueryWrapper queryWrapper2 = new QueryWrapper();
queryWrapper2.eq("role_name", "user");
SysRole sysRole = sysRoleService.getOne(queryWrapper2);
// 插入到 用户-角色 表中(sys_user_role)
SysUserRole sysUserRole = new SysUserRole();
sysUserRole.setUserId(sysUser2.getId()).setRoleId(sysRole.getId());
return sysUserRoleService.save(sysUserRole);
}
return false;
}
}
controller 层接口如下:
package com.lyh.admin_template.back.modules.sys.controller;
import com.lyh.admin_template.back.common.utils.MD5Util;
import com.lyh.admin_template.back.common.utils.RedisUtil;
import com.lyh.admin_template.back.common.utils.Result;
import com.lyh.admin_template.back.common.utils.SmsUtil;
import com.lyh.admin_template.back.common.validator.group.sys.RegisterGroup;
import com.lyh.admin_template.back.modules.sys.entity.SysUser;
import com.lyh.admin_template.back.modules.sys.service.SysUserService;
import com.lyh.admin_template.back.modules.sys.vo.RegisterVo;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
/**
* <p>
* 系统用户表 前端控制器
* </p>
*
* @author lyh
* @since 2020-07-02
*/
@RestController
@RequestMapping("/sys/sys-user")
@Api(tags = "用户登录、注册操作")
public class SysUserController {
/**
* 用于操作 sys_user 表
*/
@Autowired
private SysUserService sysUserService;
/**
* 用于操作 redis
*/
@Autowired
private RedisUtil redisUtil;
/**
* 用于操作 短信验证码发送
*/
@Autowired
private SmsUtil smsUtil;
@ApiOperation(value = "获取短信验证码")
@GetMapping("/login/getCode")
public Result getCode(String phone) {
// 设置默认过期时间
Long defaultTime = 60L * 5;
// 先判断 redis 中是否存储过验证码(设置期限为 1 分钟),防止重复获取验证码
Long expire = redisUtil.getExpire(phone);
if (expire != null && (defaultTime - expire < 60)) {
return Result.error().message("验证码已发送,1 分钟后可再次获取验证码");
} else {
// 获取 短信验证码
String code = smsUtil.sendSms(phone);
if (StringUtils.isNotEmpty(code)) {
// 把验证码存放在 redis 中,并设置 过期时间 为 5 分钟
redisUtil.set(phone, code, defaultTime);
return Result.ok().message("验证码获取成功").data("code", code);
}
}
return Result.error().message("验证码获取失败");
}
@ApiOperation(value = "用户注册")
@PostMapping("/register")
public Result register(@Validated({RegisterGroup.class}) @RequestBody RegisterVo registerVo) {
if (save(registerVo)) {
return Result.ok().message("用户注册成功");
}
return Result.error().message("用户注册失败");
}
/**
* 真实注册操作
* @param registerVo 注册数据
* @return true 为插入成功, false 为失败
*/
public boolean save(RegisterVo registerVo) {
// 判断 redis 中是否存在 验证码
String code = redisUtil.get(registerVo.getPhone());
// redis 中存在验证码且与当前验证码相同
if (StringUtils.isNotEmpty(code) && code.equals(registerVo.getCode())) {
SysUser sysUser = new SysUser();
sysUser.setName(registerVo.getUserName()).setPassword(MD5Util.encrypt(registerVo.getPassword()));
sysUser.setMobile(registerVo.getPhone());
return sysUserService.saveUser(sysUser);
}
return false;
}
}
使用 swagger 简单测试一下,添加数据。
7、完善登出逻辑
(1)目的:
让客户端 保存的 token 失效,则用户再次访问系统后由于 token 失效而无法继续访问,需重新登录后才可访问。
后台操作(非必须操作):
返回一个 过期时间为 1 秒的 token(或返回一个无效 token),并删除 redis 中的 token。
前台操作:
前台保存无效的 token。
清除 token(简单粗暴)。
(2)代码如下:(仅后台代码,前台代码此处省略、后续整合)
package com.lyh.admin_template.back.modules.sys.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.lyh.admin_template.back.common.utils.JwtUtil;
import com.lyh.admin_template.back.common.utils.RedisUtil;
import com.lyh.admin_template.back.common.utils.Result;
import com.lyh.admin_template.back.modules.sys.entity.SysUser;
import com.lyh.admin_template.back.modules.sys.service.SysUserService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* <p>
* 系统用户表 前端控制器
* </p>
*
* @author lyh
* @since 2020-07-02
*/
@RestController
@RequestMapping("/sys/sys-user")
@Api(tags = "用户登录、注册操作")
public class SysUserController {
/**
* 用于操作 sys_user 表
*/
@Autowired
private SysUserService sysUserService;
/**
* 用于操作 redis
*/
@Autowired
private RedisUtil redisUtil;
@ApiOperation(value = "用户登出")
@GetMapping("/logout")
public Result logout(@RequestParam String userName) {
// 先获取用户数据
QueryWrapper queryWrapper = new QueryWrapper();
queryWrapper.eq("name", userName);
SysUser sysUser = sysUserService.getOne(queryWrapper);
// 用户存在时
if (sysUser != null) {
// 生成并返回一个无效的 token
String jwt = JwtUtil.getJwtToken(null, 1000L);
// 删除 redis 中的 token
redisUtil.del(String.valueOf(sysUser.getId()));
return Result.ok().message("登出成功").data("token", jwt);
}
return Result.error().message("登出失败");
}
}
使用 swagger 简单测试一下:
某用户登录后,会返回一个有效 token,并在 redis 中保存。
用户登出后,返回一个无效 token,并删除 redis 中数据。
8、完整的登录、注册、登出接口代码
包括三种登录接口、注册接口、登出接口、获取验证码接口。
package com.lyh.admin_template.back.modules.sys.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.lyh.admin_template.back.common.utils.*;
import com.lyh.admin_template.back.common.validator.group.sys.LoginGroup;
import com.lyh.admin_template.back.common.validator.group.sys.RegisterGroup;
import com.lyh.admin_template.back.modules.sys.entity.SysUser;
import com.lyh.admin_template.back.modules.sys.service.SysUserService;
import com.lyh.admin_template.back.modules.sys.vo.*;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.apache.commons.lang3.StringUtils;
import org.apache.http.HttpStatus;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.Date;
/**
* <p>
* 系统用户表 前端控制器
* </p>
*
* @author lyh
* @since 2020-07-02
*/
@RestController
@RequestMapping("/sys/sys-user")
@Api(tags = "用户登录、注册操作")
public class SysUserController {
/**
* 用于操作 sys_user 表
*/
@Autowired
private SysUserService sysUserService;
/**
* 用于操作 redis
*/
@Autowired
private RedisUtil redisUtil;
/**
* 用于操作 短信验证码发送
*/
@Autowired
private SmsUtil smsUtil;
/**
* 常量,表示用户密码登录操作
*/
private static final String USER_NAME_STATUS = "0";
/**
* 常量,表示手机号密码登录操作
*/
private static final String PHONE_STATUS = "1";
/**
* 获取 jwt
* @return jwt
*/
private String getJwt(SysUser sysUser) {
// 获取需要保存在 jwt 中的数据
JwtVo jwtVo = new JwtVo();
jwtVo.setId(sysUser.getId());
jwtVo.setName(sysUser.getName());
jwtVo.setPhone(sysUser.getMobile());
jwtVo.setTime(new Date().getTime());
// 获取 jwt 数据,设置过期时间为 30 分钟
String jwt = JwtUtil.getJwtToken(jwtVo, 1000L * 60 * 30);
// 判断用户是否重复登录(code 有值则重复登录,需要保留最新的登录者,剔除前一个登录者)
String code = redisUtil.get(String.valueOf(sysUser.getId()));
// 获取当前时间戳
Long currentTime = new Date().getTime();
// 如果 redis 中存在 jwt 数据,则根据时间戳比较谁为最新的登陆者
if (StringUtils.isNotEmpty(code)) {
// 获取 redis 中存储的 jwt 数据
JwtVo redisJwt = GsonUtil.fromJson(String.valueOf(JwtUtil.getTokenBody(code).get("data")), JwtVo.class);
// redis jwt 大于 当前时间戳,则 redis 中 jwt 为最新登录者,当前登录失败
if (redisJwt.getTime() > currentTime) {
return null;
}
}
// 把数据存放在 redis 中,设置过期时间为 30 分钟
redisUtil.set(String.valueOf(sysUser.getId()), jwt, 60 * 30);
return jwt;
}
/**
* 使用密码进行真实登录操作
* @param account 账号(用户名或手机号)
* @param pwd 密码
* @param status 是否使用用户名登录(0 表示用户名登录,1 表示手机号登录)
* @return jwt
*/
private String pwdLogin(String account, String pwd, String status) {
// 新增查询条件
QueryWrapper queryWrapper = new QueryWrapper();
// 如果是用户名 + 密码登录,则根据 姓名 + 密码 查找数据
if (USER_NAME_STATUS.equals(status)) {
queryWrapper.eq("name", account);
}
// 如果是手机号 + 密码登录,则根据 手机号 + 密码 查找数据
if (PHONE_STATUS.equals(status)) {
queryWrapper.eq("mobile", account);
}
// 添加密码条件,密码进行 MD5 加密后再与数据库数据比较
queryWrapper.eq("password", MD5Util.encrypt(pwd));
// 获取用户数据
SysUser sysUser = sysUserService.getOne(queryWrapper);
// 如果存在用户数据
if (sysUser != null) {
return getJwt(sysUser);
}
return null;
}
/**
* 使用 验证码进行真实登录操作
* @param phone 手机号
* @param code 验证码
* @return jwt
*/
private String codeLogin(String phone, String code) {
// 获取 redis 中存放的验证码
String redisCode = redisUtil.get(phone);
// 存在验证码,且输入的验证码与 redis 存放的验证码相同,则根据手机号去数据库查询数据
if (StringUtils.isNotEmpty(redisCode) && code.equals(redisCode)) {
// 新增查询条件
QueryWrapper queryWrapper = new QueryWrapper();
// 根据手机号去查询数据
queryWrapper.eq("mobile", phone);
SysUser sysUser = sysUserService.getOne(queryWrapper);
// 如果存在用户数据
if (sysUser != null) {
return getJwt(sysUser);
}
}
return null;
}
@ApiOperation(value = "使用用户名、密码登录")
@PostMapping("/login/namePwdLogin")
public Result namePwdLogin(@Validated({LoginGroup.class}) @RequestBody NamePwdLoginVo namePwdLoginVo) {
String jwt = pwdLogin(namePwdLoginVo.getUserName(), namePwdLoginVo.getPassword(), USER_NAME_STATUS);
if (StringUtils.isNotEmpty(jwt)) {
return Result.ok().message("登录成功").data("token", jwt);
}
return Result.error().message("登录失败").code(HttpStatus.SC_UNAUTHORIZED);
}
@ApiOperation(value = "使用手机号、密码登录")
@PostMapping("/login/phonePwdLogin")
public Result phonePwdLogin(@Validated({LoginGroup.class}) @RequestBody PhonePwdLoginVo phonePwdLoginVo) {
String jwt = pwdLogin(phonePwdLoginVo.getPhone(), phonePwdLoginVo.getPassword(), PHONE_STATUS);
if (StringUtils.isNotEmpty(jwt)) {
return Result.ok().message("登录成功").data("token", jwt);
}
return Result.error().message("登录失败").code(HttpStatus.SC_UNAUTHORIZED);
}
@ApiOperation(value = "使用手机号、验证码登录")
@PostMapping("/login/phoneCodeLogin")
public Result phoneCodeLogin(@Validated({LoginGroup.class}) @RequestBody PhoneCodeLoginVo phoneCodeLoginVo) {
String jwt = codeLogin(phoneCodeLoginVo.getPhone(), phoneCodeLoginVo.getCode());
if (StringUtils.isNotEmpty(jwt)) {
return Result.ok().message("登录成功").data("token", jwt);
}
return Result.error().message("登录失败").code(HttpStatus.SC_UNAUTHORIZED);
}
@ApiOperation(value = "获取短信验证码")
@GetMapping("/login/getCode")
public Result getCode(String phone) {
// 设置默认过期时间
Long defaultTime = 60L * 5;
// 先判断 redis 中是否存储过验证码(设置期限为 1 分钟),防止重复获取验证码
Long expire = redisUtil.getExpire(phone);
if (expire != null && (defaultTime - expire < 60)) {
return Result.error().message("验证码已发送,1 分钟后可再次获取验证码");
} else {
// 获取 短信验证码
String code = smsUtil.sendSms(phone);
if (StringUtils.isNotEmpty(code)) {
// 把验证码存放在 redis 中,并设置 过期时间 为 5 分钟
redisUtil.set(phone, code, defaultTime);
return Result.ok().message("验证码获取成功").data("code", code);
}
}
return Result.error().message("验证码获取失败");
}
@ApiOperation(value = "用户登出")
@GetMapping("/logout")
public Result logout(@RequestParam String userName) {
// 先获取用户数据
QueryWrapper queryWrapper = new QueryWrapper();
queryWrapper.eq("name", userName);
SysUser sysUser = sysUserService.getOne(queryWrapper);
// 用户存在时
if (sysUser != null) {
// 生成并返回一个无效的 token
String jwt = JwtUtil.getJwtToken(null, 1000L);
// 删除 redis 中的 token
redisUtil.del(String.valueOf(sysUser.getId()));
return Result.ok().message("登出成功").data("token", jwt);
}
return Result.error().message("登出失败");
}
@ApiOperation(value = "用户注册")
@PostMapping("/register")
public Result register(@Validated({RegisterGroup.class}) @RequestBody RegisterVo registerVo) {
if (save(registerVo)) {
return Result.ok().message("用户注册成功");
}
return Result.error().message("用户注册失败").code(HttpStatus.SC_UNAUTHORIZED);
}
/**
* 真实注册操作
* @param registerVo 注册数据
* @return true 为插入成功, false 为失败
*/
public boolean save(RegisterVo registerVo) {
// 判断 redis 中是否存在 验证码
String code = redisUtil.get(registerVo.getPhone());
// redis 中存在验证码且与当前验证码相同
if (StringUtils.isNotEmpty(code) && code.equals(registerVo.getCode())) {
SysUser sysUser = new SysUser();
sysUser.setName(registerVo.getUserName()).setPassword(MD5Util.encrypt(registerVo.getPassword()));
sysUser.setMobile(registerVo.getPhone());
return sysUserService.saveUser(sysUser);
}
return false;
}
}
9、定义一个拦截器,用于拦截除登录注册请求外的所有请求
(1)目的:
由于采用 JWT 进行单点登录,每次请求前都需要对 token 进行校验,为了避免在接口中重复进行校验操作,此处可以使用拦截器,拦截每个请求,校验通过后放行请求并返回数据,校验未通过直接返回错误数据。
拦截器需要直接放行登录、注册等请求,未登录、注册时没有 token 数据,只有登录后才有 token 数据,拦截了 登录、注册请求后,不会产生 token,成为一个死循环。
(2)代码实现如下:
Step1:定义一个拦截器
对于拦截的请求,首先检查 token 是否过期,过期返回 401 状态码。未过期进行下面操作。
获取 token 信息,并根据 token 的 id 值从 redis 中获取 redis 中存储的 token。若 redis 中不存在 token,即用户未登录,返回 401 状态码。存在 token 则进行下面操作。
若两 token 相同,即 同一用户再次访问系统,放行该请求。token 不同,则意味着 同一用户 在不同地方进行登录,需保留最新的登录者信息。根据时间戳比较,谁大谁为最新登录者,并将其值保存在 redis 中。
/**
* 定义一个拦截器,用于拦截请求,并对 JWT 进行验证
*/
class JWTInterceptor extends HandlerInterceptorAdapter {
/**
* 访问 controller 前被调用
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 获取 token(从 header 或者 参数中获取)
String token = request.getHeader("token");
if (StringUtils.isBlank(token)) {
token = request.getParameter("token");
}
// 验证 token 是否过期(根据时间戳比较)
if (JwtUtil.checkToken(token)) {
// 获取 token 中的数据
Claims claims = JwtUtil.getTokenBody(token);
System.out.println(claims.getExpiration());
JwtVo jwt = GsonUtil.fromJson(String.valueOf(claims.get("data")), JwtVo.class);
// 获取 redis 中存储的 token
String redisToken = redisUtil.get(String.valueOf(jwt.getId()));
// 当前 token 与 redis 中存储的 token 进行比较
if (StringUtils.isNotEmpty(redisToken)) {
// 获取 redis 中 token 的数据
JwtVo redisJwt = GsonUtil.fromJson(String.valueOf(JwtUtil.getTokenBody(redisToken).get("data")), JwtVo.class);
// 若两者 token 相同,则为同一用户再次访问系统,放行
if (redisToken.equals(token)) {
return true;
} else if (redisJwt.getTime() <= jwt.getTime()){
// redis 中 token 生成时间戳 小于等于 当前 token 生成时间戳,即当前用户为最新登录者
// redis 保存当前最新的 token,并放行
redisUtil.set(String.valueOf(redisJwt.getId()), token, 60 * 30);
return true;
}
}
}
// 认证失败,返回数据,并返回 401 状态码
returnJsonData(response);
return false;
}
}
Step2:定义拦截请求后的数据返回结果。
返回 json 数据,并定义 code 为 401(授权失败)。
/**
* 返回 json 格式的数据
*/
public void returnJsonData(HttpServletResponse response) {
PrintWriter pw = null;
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json; charset=utf-8");
try {
pw = response.getWriter();
// 返回 code 为 401,表示 token 失效。
pw.print(GsonUtil.toJson(Result.error().message("token 失效或过期").code(HttpStatus.SC_UNAUTHORIZED)));
} catch (IOException e) {
log.error(e.getMessage());
throw new RuntimeException(e);
}
}
Step3:定义拦截请求规则:
/**
* 定义拦截器,拦截请求。
* 其中:
* addPathPatterns 用于添加需要拦截的请求。
* excludePathPatterns 用于添加不需要拦截的请求。
* 此处:
* 拦截所有请求,但是排除 登录、注册 请求 以及 swagger 请求。
*/
@Bean(name = "JWTInterceptor")
public WebMvcConfigurer JWTInterceptor() {
return new WebMvcConfigurer() {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new JWTInterceptor())
// 拦截所有请求
.addPathPatterns("/**")
// 不拦截 登录、注册、忘记密码请求
.excludePathPatterns("/sys/sys-user/login/*", "/sys/sys-user/register")
// 不拦截 swagger 请求
.excludePathPatterns("/swagger-resources/**", "/webjars/**", "/v2/**", "/swagger-ui.html/**");
}
};
}
完整拦截逻辑:
package com.lyh.admin_template.back.common.config;
import com.lyh.admin_template.back.common.utils.GsonUtil;
import com.lyh.admin_template.back.common.utils.JwtUtil;
import com.lyh.admin_template.back.common.utils.RedisUtil;
import com.lyh.admin_template.back.common.utils.Result;
import com.lyh.admin_template.back.modules.sys.vo.JwtVo;
import io.jsonwebtoken.Claims;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.http.HttpStatus;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
@Slf4j
@Configuration
public class JWTConfig {
@Autowired
private RedisUtil redisUtil;
/**
* 定义拦截器,拦截请求。
* 其中:
* addPathPatterns 用于添加需要拦截的请求。
* excludePathPatterns 用于添加不需要拦截的请求。
* 此处:
* 拦截所有请求,但是排除 登录、注册 请求 以及 swagger 请求。
*/
@Bean(name = "JWTInterceptor")
public WebMvcConfigurer JWTInterceptor() {
return new WebMvcConfigurer() {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new JWTInterceptor())
// 拦截所有请求
.addPathPatterns("/**")
// 不拦截 登录、注册、忘记密码请求
.excludePathPatterns("/sys/sys-user/login/*", "/sys/sys-user/register")
// 不拦截 swagger 请求
.excludePathPatterns("/swagger-resources/**", "/webjars/**", "/v2/**", "/swagger-ui.html/**");
}
};
}
/**
* 定义一个拦截器,用于拦截请求,并对 JWT 进行验证
*/
class JWTInterceptor extends HandlerInterceptorAdapter {
/**
* 访问 controller 前被调用
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 获取 token(从 header 或者 参数中获取)
String token = request.getHeader("token");
if (StringUtils.isBlank(token)) {
token = request.getParameter("token");
}
// 验证 token 是否过期(根据时间戳比较)
if (JwtUtil.checkToken(token)) {
// 获取 token 中的数据
Claims claims = JwtUtil.getTokenBody(token);
JwtVo jwt = GsonUtil.fromJson(String.valueOf(claims.get("data")), JwtVo.class);
// 获取 redis 中存储的 token
String redisToken = redisUtil.get(String.valueOf(jwt.getId()));
// 当前 token 与 redis 中存储的 token 进行比较
if (StringUtils.isNotEmpty(redisToken)) {
// 获取 redis 中 token 的数据
JwtVo redisJwt = GsonUtil.fromJson(String.valueOf(JwtUtil.getTokenBody(redisToken).get("data")), JwtVo.class);
// 若两者 token 相同,则为同一用户再次访问系统,放行
if (redisToken.equals(token)) {
return true;
} else if (redisJwt.getTime() <= jwt.getTime()){
// redis 中 token 生成时间戳 小于等于 当前 token 生成时间戳,即当前用户为最新登录者
// redis 保存当前最新的 token,并放行
redisUtil.set(String.valueOf(redisJwt.getId()), token, 60 * 30);
return true;
}
}
}
// 认证失败,返回数据,并返回 401 状态码
returnJsonData(response);
return false;
}
}
/**
* 返回 json 格式的数据
*/
public void returnJsonData(HttpServletResponse response) {
PrintWriter pw = null;
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json; charset=utf-8");
try {
pw = response.getWriter();
// 返回 code 为 401,表示 token 失效。
pw.print(GsonUtil.toJson(Result.error().message("token 失效或过期").code(HttpStatus.SC_UNAUTHORIZED)));
} catch (IOException e) {
log.error(e.getMessage());
throw new RuntimeException(e);
}
}
}
10、给 Swagger 添加统一验证参数(设置 token)
(1)目的:
由于后台使用过滤器拦截了请求,使用 swagger 测试时,由于未携带 token 而被拦截,导致 返回 401 状态码。
可以给 Swagger 添加统一验证参数,在请求发送前统一给 header 加上 token 参数。
(2)代码实现:
来源于网络,没有深究为什么这么写,套用即可。
在原本 swagger 基础上,添加如下代码:
securitySchemes(security())
securityContexts(securityContexts());
package com.lyh.admin_template.back.common.config;
import com.google.common.collect.Lists;
import io.swagger.annotations.ApiOperation;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.service.ApiKey;
import springfox.documentation.service.AuthorizationScope;
import springfox.documentation.service.SecurityReference;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spi.service.contexts.SecurityContext;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;
import java.util.ArrayList;
import java.util.List;
@Configuration
@EnableSwagger2
@Profile({"dev","test"})
public class SwaggerConfig {
@Bean
public Docket createRestApi() {
return new Docket(DocumentationType.SWAGGER_2)
.apiInfo(apiInfo())
.select()
// 加了ApiOperation注解的类,才会生成接口文档
.apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class))
// 指定包下的类,才生成接口文档
.apis(RequestHandlerSelectors.basePackage("com.lyh.admin_template.back"))
.paths(PathSelectors.any())
.build()
.securitySchemes(security())
.securityContexts(securityContexts());
}
private ApiInfo apiInfo() {
return new ApiInfoBuilder()
.title("Swagger 测试")
.description("Swagger 测试文档")
.termsOfServiceUrl("")
.version("1.0.0")
.build();
}
private List<ApiKey> security() {
return Lists.newArrayList(
new ApiKey("token", "token", "header")
);
}
private List<SecurityContext> securityContexts() {
return Lists.newArrayList(
SecurityContext.builder().securityReferences(defaultAuth())
//过滤要验证的路径
.forPaths(PathSelectors.regex("^(?!auth).*$"))
.build()
);
}
//增加全局认证
List<SecurityReference> defaultAuth() {
AuthorizationScope authorizationScope = new AuthorizationScope("global", "accessEverything");
AuthorizationScope[] authorizationScopes = new AuthorizationScope[1];
authorizationScopes[0] = authorizationScope;
List<SecurityReference> securityReferences = new ArrayList<>();
// 由于 securitySchemes() 方法中 header 写入值为 token,所以此处为 token
securityReferences.add(new SecurityReference("token", authorizationScopes));
return securityReferences;
}
}
(3)简单测试一下:
首先登录,获取到 token。没有设置 token 时,访问 登出接口 会被拦截。
设置 token 后,登出接口不会被拦截。