用户表
#用户表
drop table if exists `user`;
create table `user`(
`id` bigint not null comment '用户id',
`login_name` varchar(50) not null comment '登录名',
`name` varchar(50) not null comment '昵称',
`password` char(32) not null comment '密码',
primary key (`id`),
unique key `login_name_unique` (`login_name`)
)engine=innodb default charset=utf8mb4 comment '用户';
insert into `user` (id, login_name, name, password) VALUES (1,'tangssst','一号','123456')
使用mybatisX插件生成代码。
并实现前后端基本功能,见代码提交的7-1(内容较多)。
用户名重复校验、自定义异常
service:
/**
* 通过loginName获取用户信息
* @param loginName
*/
public User selectByLoginName(String loginName){
QueryWrapper<User> userQueryWrapper = new QueryWrapper<User>()
.like("login_name",loginName);
List<User> userList = userMapper.selectList(userQueryWrapper);
if (CollectionUtils.isEmpty(userList)){
return null;
}else {
return userList.get(0);
}
}
然后调用:
if (!ObjectUtils.isEmpty(req.getLoginName())) {
//有loginName则模糊查询
wrapper.like("name", req.getLoginName());
IPage<User> userIPage = userMapper.selectPage(page, wrapper);
userList = userIPage.getRecords();
}else {
//无参时查全部
userList = userMapper.selectPage(page, null).getRecords();
}
创建异常包
新建BusinessException类:
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;
}
}
对应的异常枚举类BusinessExceptionCode:
/**
* 异常的枚举类,定义了常见异常
* Created by tangssst@qq.com on 2021/07/02
*/
public enum BusinessExceptionCode {
USER_LOGIN_NAME_EXIST("登录名已存在"),
LOGIN_USER_ERROR("用户名不存在或密码错误"),
VOTE_REPEAT("您已点赞过"),
;
private String desc;
BusinessExceptionCode(String desc) {
this.desc = desc;
}
public String getDesc() {
return desc;
}
public void setDesc(String desc) {
this.desc = desc;
}
}
使用:
//新增
//判断用户名是否已存在
if (ObjectUtils.isEmpty(selectByLoginName(saveReq.getLoginName()))){
user.setId(snowFlake.nextId());
userMapper.insert(user);
}else {
throw new BusinessException(BusinessExceptionCode.USER_LOGIN_NAME_EXIST);
}
异常统一处理
使用ExceptionHandler拦截器对代码抛出的异常进行统一处理,包括业务异常、系统异常,分类处理。
/**
* 统一异常处理、数据预处理
* Created by tangssst@qq.com on 2021/06/14
*/
@ControllerAdvice
public class ControllerExceptionHandler {
private static final Logger LOG = LoggerFactory.getLogger(ControllerExceptionHandler.class);
/**
* 校验异常统一处理
* @param e
* @return
*/
@ExceptionHandler(value = BindException.class)
@ResponseBody
public CommonResp validExceptionHandler(BindException e) {
CommonResp commonResp = new CommonResp();
LOG.warn("参数校验失败:{}", e.getBindingResult().getAllErrors().get(0).getDefaultMessage());
commonResp.setSuccess(false);
commonResp.setMessage(e.getBindingResult().getAllErrors().get(0).getDefaultMessage());
return commonResp;
}
/**
* 业务异常统一处理
* @param e
* @return
*/
@ExceptionHandler(value = BusinessException.class)
@ResponseBody
public CommonResp validExceptionHandler(BusinessException e) {
CommonResp commonResp = new CommonResp();
LOG.warn("业务异常:{}", e.getCode().getDesc());
commonResp.setSuccess(false);
commonResp.setMessage(e.getCode().getDesc());
return commonResp;
}
/**
* 其余的异常
* @return
*/
@ExceptionHandler(value = Exception.class)
@ResponseBody
public CommonResp validExceptionHandler(Exception e){
CommonResp commonResp = new CommonResp();
LOG.error("系统异常:", e);
commonResp.setSuccess(false);
commonResp.setMessage("系统异常,请联系管理员");
return commonResp;
}
}
此时新建用户如果用户名重复的话就会提示异常。
见7-2
用户名在不可选状态
新增时可操作,编辑时用户名不能被操作:
<a-form-item label="用户名">
<a-input v-model:value="user.loginName" :disabled="user.id"/>
</a-form-item>
这是前段,后端UserService.java也要做校验:
//更新
//设置为null后不会更新
user.setLoginName(null);
userMapper.updateById(user);
见7-3
密码加密
md5存储:
在UserController.java里:
@PostMapping("/save")
public CommonResp save(@Valid @RequestBody UserSaveReq req){
//对密码做md5加密
req.setPassword(DigestUtils.md5DigestAsHex(req.getPassword().getBytes()));
CommonResp commonResp = new CommonResp<>();
userService.save(req);
return commonResp;
}
public下新建js文件夹,放入Md5.js。
在index.html里引入:
<script src="<%= BASE_URL %>js/Md5.js"></script>
在userAdmin的save方法使用:
user.value.password = hexMd5(user.value.password + KEY);
会爆红,我们将对应的变量定义一下,告诉vue他们是存在的:
declare let hexMd5:any;
declare let KEY:any;
此时后端的正则校验没用了,我们在请求封装类的去掉:
@Pattern(regexp = "^(?![0-9]+$)(?![a-zA-Z]+$)[0-9A-Za-z]{6,32}$", message = "【密码】至少包含 数字和英文,长度6-32")
前端编辑框隐藏密码选项:
<a-form-item label="密码">
<a-input v-model:value="user.password" type="password" v-show="!user.id"/>
</a-form-item>
后端save时把密码也清空:
else {
//保存更新时不接受loginName和password
user.setLoginName(null);
user.setPassword(null);
userMapper.updateById(user);
}
此时点编辑就不会显示密码了,后端也不接受密码传参。
见7-5。
重置密码
新建UserResetPasswordReq.java,内容:
@Data
public class UserResetPasswordReq {
/**
* 用户id
*/
private Long id;
/**
* 密码
*/
@NotNull(message = "密码不能为空")
private String password;
}
UserController:
@PostMapping("/resetPassword")
public CommonResp resetPassword(@Valid UserResetPasswordReq passwordReq){
//对密码做md5加密
passwordReq.setPassword(DigestUtils.md5DigestAsHex(passwordReq.getPassword().getBytes()));
CommonResp commonResp = new CommonResp<>();
userService.resetPassword(passwordReq);
return commonResp;
}
UserService:
/**
* 重置密码
* @param passwordReq
*/
public void resetPassword(UserResetPasswordReq passwordReq){
User user = CopyUtil.copy(passwordReq,User.class);
userMapper.updateById(user);
}
前端:
新增modal:
<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" type="password" />
</a-form-item>
</a-form>
</a-modal>
函数:
/**
* 密码重置
*/
const resetModalVisible = ref(false);
const resetModalLoading = ref(false);
const handleResetModalOk = () => {
resetModalLoading.value = true;
axios.post("/user/resetPassword", user.value).then((response) => {
resetModalLoading.value = false;
//前端对密码进行一次加密,KEY为盐值
user.value.password = hexMd5(user.value.password + KEY);
const data = response.data; // data = commonResp
if (data.success) { //如果成功
resetModalVisible.value = false;
// 重新加载列表
handleQuery({
page: pagination.value.current,
size: pagination.value.pageSize,
});
} else {
message.error(data.message);
}
});
};
/**
* 编辑
*/
const resetPassword = (record: any) => {
resetModalVisible.value = true;
user.value = Tool.copy(record); //对象复制
user.value.password = null;
};
增加按钮:
<a-button type="primary" @click="resetPassword(record)">
密码
</a-button>
单点登录
token官网:https://jwt.io/
引入依赖:
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.4.0</version>
</dependency>
新建UserLoginReq,保留用户名、密码
@Data
public class UserLoginReq {
/**
* 登录名
*/
@NotNull(message = "登录名不能为空")
// @Length(min = 6, max = 20, message = "【密码】6~20位")
@Pattern(regexp = "^(?![0-9]+$)(?![a-zA-Z]+$)[0-9A-Za-z]{6,32}$", message = "【密码】不符合规则!")
private String loginName;
/**
* 密码
*/
@NotNull(message = "密码不能为空")
private String password;
}
新建UserLoginResp:
@Data
public class UserLoginResp {
private Long id;
private String loginName;
private String name;
}
controller:
/**
* 登录login接口
* @param userSaveReq
* @return
*/
@PostMapping("/login")
public CommonResp login(@Valid @RequestBody UserLoginReq userLoginReq){
userLoginReq.setPassword(DigestUtils.md5DigestAsHex(userLoginReq.getPassword().getBytes()));
CommonResp<Object> commonResp = new CommonResp<>();
UserLoginResp userLoginResp = userService.login(userLoginReq);
commonResp.setContent(userLoginResp);
return commonResp;
}
增加异常:
LOGIN_USER_ERROR("用户名不存在或密码错误"),
service:
/**
* 登录login
* @param userLoginReq
* @return
*/
public UserLoginResp login(UserLoginReq userLoginReq){
User user = selectByLoginName(userLoginReq.getLoginName());
if (ObjectUtils.isEmpty(user)){
//用户名为空,抛出异常
log.info("用户名不存在,{}",userLoginReq.getLoginName());
throw new BusinessException(BusinessExceptionCode.LOGIN_USER_ERROR);
}else {
//比对数据库的密码和传入的密码,都是两层加密
if (user.getPassword().equals(userLoginReq.getPassword())){
//登录成功
UserLoginResp loginResp = CopyUtil.copy(user, UserLoginResp.class);
return loginResp;
}else {
//密码不对
log.info("密码不对,输入的密码:{},数据库密码:{}",userLoginReq.getPassword(),user.getPassword());
throw new BusinessException(BusinessExceptionCode.LOGIN_USER_ERROR);
}
}
}
前端:
增加登录a标签,增加modal,showLoginModal、login方法。
见提交7-7
bug修复
7-7.1 修复多个bug:logger、前端md5加密应在axios前、controller缺少@RequestBody导致无法接收到参数
7-7.2 将notnull改为@notEmpty,前者只能校验null,后者还能校验空字符串。
登录处理redis
token+redis
集成redis:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
在controller里注入内置的工redis具类:
@Resource
private RedisTemplate redisTemplate;
因为要将token给前端,所以在UserLoginResp增加属性:
private String token;
String更通用,可以用md5生成token。
controller代码:
@PostMapping("/login")
public CommonResp login(@Valid @RequestBody UserLoginReq userLoginReq){
userLoginReq.setPassword(DigestUtils.md5DigestAsHex(userLoginReq.getPassword().getBytes()));
CommonResp<Object> commonResp = new CommonResp<>();
UserLoginResp userLoginResp = userService.login(userLoginReq);
Long token = snowFlake.nextId();
userLoginResp.setToken(token.toString());
ValueOperations ops = redisTemplate.opsForValue();
ops.set(token.toString(),userLoginResp,24, TimeUnit.HOURS);
log.info("生成了token:{}, 并放入redis", token);
commonResp.setContent(userLoginResp);
return commonResp;
}
redis的value需要序列化,此处我将类实现了serializable接口。也可以用Jackson将类转为json
增加redis配置:
redis:
host: 127.0.0.1
port: 6379
本地我们启动redis,就能正常用了。
见7-8
引入vuex
vuex是一个全局的变量,能同时被多个组件使用
store.js:
import {createStore} from 'vuex'
declare let SessionStorage: any;
const USER = "USER";
const store = createStore({
state: {
user: SessionStorage.get(USER) || {}
},
mutations: {
setUser (state, user) {
console.log("store user:", user);
state.user = user;
SessionStorage.set(USER, user);
}
},
actions: {
},
modules: {
}
});
export default store;
header:
// 登录后保存
const user = computed(() => store.state.user);
……
store.commit("setUser", data.content);
……
store.commit("setUser", {});
整合H5缓存
vuex的组件在页面刷新后数据会丢失,通过整合h5的缓存session-storage解决。
添加session-storage.js,在index.html里引入:
<script src="<%= BASE_URL %>js/session-storage.js"></script>
登录功能前后端开发见7-9
退出登录
token增加过期时间
后端增加退出登录logout接口,退出后清除redis信息
前端增加退出按钮,退出后清除前端信息
controller:
@GetMapping("/logout/{token}")
public CommonResp logout(@PathVariable String token) {
CommonResp resp = new CommonResp<>();
redisTemplate.delete(token);
log.info("从redis中删除token: {}", token);
return resp;
}
前端:
<a-popconfirm
title="确认退出登录?"
ok-text="是"
cancel-text="否"
@confirm="logout()"
>
<a class="login-menu" v-show="user.id">
<span>退出登录</span>
</a>
</a-popconfirm>
// 退出登录
const logout = () => {
console.log("退出登录开始");
axios.get('/user/logout/' + user.value.token).then((response) => {
const data = response.data;
if (data.success) {
message.success("退出登录成功!");
store.commit("setUser", {});
} else {
message.error(data.message);
}
});
};
登录校验
后端增加拦截器,检查token有效性
前端请求参数增加token
使用拦截器,在interceptor包下新建LoginInterceptor.java:
@Component
public class LoginInterceptor implements HandlerInterceptor {
private static final Logger LOG = LoggerFactory.getLogger(LoginInterceptor.class);
@Resource
private RedisTemplate redisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 打印请求信息
LOG.info("------------- LoginInterceptor 开始 -------------");
long startTime = System.currentTimeMillis();
request.setAttribute("requestStartTime", startTime);
// OPTIONS请求不做校验,
// 前后端分离的架构, 前端会发一个OPTIONS请求先做预检, 对预检请求不做校验
if(request.getMethod().toUpperCase().equals("OPTIONS")){
return true;
}
String path = request.getRequestURL().toString();
LOG.info("接口登录拦截:,path:{}", path);
//获取header的token参数
String token = request.getHeader("token");
LOG.info("登录校验开始,token:{}", token);
if (token == null || token.isEmpty()) {
LOG.info( "token为空,请求被拦截" );
response.setStatus(HttpStatus.UNAUTHORIZED.value());
return false;
}
Object object = redisTemplate.opsForValue().get(token);
if (object == null) {
LOG.warn( "token无效,请求被拦截" );
response.setStatus(HttpStatus.UNAUTHORIZED.value());
return false;
} else {
LOG.info("已登录:{}", object);
return true;
}
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
long startTime = (Long) request.getAttribute("requestStartTime");
LOG.info("------------- LoginInterceptor 结束 耗时:{} ms -------------", System.currentTimeMillis() - startTime);
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// LOG.info("LogInterceptor 结束");
}
}
然后是SpringMvcConfig:
@Configuration
public class SpringMvcConfig implements WebMvcConfigurer {
@Resource
LoginInterceptor loginInterceptor;
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(
"/test/**",
"/redis/**",
"/user/login",
"/category/all",
"/ebook/search",
"/doc/all/**",
"/doc/vote/**",
"/doc/getContent/**",
"/ebook-snapshot/**"
);
}
}
见7-11
路由校验
管理菜单在非登录状态不应该显示出来:
<a-menu-item key="/admin/ebook" :style="user.id? {} : {display:'none'}">
<router-link to="/admin/ebook">电子书管理</router-link>
</a-menu-item>
此时非登录用户看不到管理菜单,但是还要避免用户直接去访问url:
在前端路由router下的index.ts里对应的路径下增加自定义数据:
meta: {
loginRequire: true
}
底部增加:
// 路由登录拦截
router.beforeEach((to, from, next) => {
// 要不要对meta.loginRequire属性做监控拦截
if (to.matched.some(function (item) {
console.log(item, "是否需要登录校验:", item.meta.loginRequire);
return item.meta.loginRequire
})) {
const loginUser = store.state.user;
if (Tool.isEmpty(loginUser)) {
console.log("用户未登录!");
next('/');
} else {
next();
}
} else {
next();
}
});
见提交7-12.