VueBlog
1. 后端项目搭建
1.1 新建数据库
CREATE TABLE `m_user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`username` varchar(64) DEFAULT NULL,
`avatar` varchar(255) DEFAULT NULL,
`email` varchar(64) DEFAULT NULL,
`password` varchar(64) DEFAULT NULL,
`status` int(5) NOT NULL,
`created` datetime DEFAULT NULL,
`modified` datetime DEFAULT NULL, # 新加
`last_login` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `UK_USERNAME` (`username`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
CREATE TABLE `m_blog` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) NOT NULL,
`title` varchar(255) NOT NULL,
`description` varchar(255) NOT NULL,
`content` longtext,
`created` datetime NOT NULL ON UPDATE CURRENT_TIMESTAMP,
`modified` datetime DEFAULT NULL, # 新加
`status` tinyint(4) DEFAULT NULL,
`views` int(5) DEFAULT 0, # 新加
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=utf8mb4;
# 准备添加评论表
INSERT INTO `vueblog`.`m_user` (`id`, `username`, `avatar`, `email`, `password`, `status`, `created`, `last_login`) VALUES ('1', 'markerhub', 'https://image-1300566513.cos.ap-guangzhou.myqcloud.com/upload/images/5a9f48118166308daba8b6da7e466aab.jpg', NULL, '96e79218965eb72c92a549dd5a330112', '0', '2020-04-20 10:44:01', NULL);
1.2 新建Springboot项目
1.3 添加MyBatisPlus依赖
<!--MyBatisPlus-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.1</version>
</dependency>
1.4 修改 yaml 配置
# DataSource Config
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/vueblog?useUnicode=true&useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: 7012+2
# MybatisPlus相关配置
mybatis-plus:
configuration:
# 日志
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
1.5 MyBatisPlus分页插件
package com.lyj.vueblog.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@Configuration
@EnableTransactionManagement
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
2. 统一结果封装
这里用到一个 Result
的类,这个用于我们的异步统一返回的结果封装。一般来说,结果里面有几个要素必要的
- 是否成功,可用code表示(如200表示成功,400表示异常)
- 结果消息
- 结果数据
package com.lyj.vueblog.common;
import lombok.Data;
import java.io.Serializable;
@Data
public class Result implements Serializable {
private Integer code; // 200正常 非200表示异常
private String msg;
private Object data;
public static Result success(Object data) {
return success(200, "操作成功", data);
}
public static Result success(int code, String msg, Object data) {
Result result = new Result();
result.setCode(code);
result.setMsg(msg);
result.setData(data);
return result;
}
public static Result fail(String msg) {
return fail(msg, null);
}
public static Result fail(String msg, Object data) {
return fail(400, msg, data);
}
public static Result fail(int code, String msg, Object data) {
Result result = new Result();
result.setCode(code);
result.setMsg(msg);
result.setData(data);
return result;
}
}
3. 整合Shiro和jwt
考虑到后面可能需要做集群、负载均衡等,所以就需要会话共享,而 shiro
的缓存和会话信息,我们一般考虑使用 redis
来存储这些数据,所以,我们不仅仅需要整合 shiro
,同时也需要整合 redis
。在开源的项目中,我们找到了一个 shiro-redis-spring-boot-starter
可以快速整合 shiro-redis
,配置简单,这里也推荐大家使用。
因为我们需要做的是前后端分离项目的骨架,所以一般我们会采用 token
或者 jwt
作为跨域身份验证解决方案。所以整合 shiro
的过程中,我们需要引入 jwt
的身份验证过程。
- 登录逻辑
3.1 导入依赖
<dependency>
<groupId>org.crazycake</groupId>
<artifactId>shiro-redis-spring-boot-starter</artifactId>
<version>3.3.1</version>
</dependency>
<!-- hutool工具类-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.7.20</version>
</dependency>
<!-- jwt -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
3.2 相关配置
3.2.1 ShiroConfig
package com.lyj.vueblog.config;
import com.lyj.vueblog.shiro.AccountRealm;
import com.lyj.vueblog.shiro.JwtFilter;
import org.apache.shiro.mgt.DefaultSessionStorageEvaluator;
import org.apache.shiro.mgt.DefaultSubjectDAO;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.session.mgt.SessionManager;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.spring.web.config.DefaultShiroFilterChainDefinition;
import org.apache.shiro.spring.web.config.ShiroFilterChainDefinition;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.crazycake.shiro.RedisCacheManager;
import org.crazycake.shiro.RedisSessionDAO;
import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.servlet.Filter;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* shiro启用注解拦截控制器
*/
@Configuration
public class ShiroConfig {
@Autowired
JwtFilter jwtFilter;
@Bean
public SessionManager sessionManager(RedisSessionDAO redisSessionDAO) {
DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
sessionManager.setSessionDAO(redisSessionDAO);
return sessionManager;
}
@Bean
public DefaultWebSecurityManager securityManager(AccountRealm accountRealm,
SessionManager sessionManager,
RedisCacheManager redisCacheManager) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(accountRealm);
securityManager.setSessionManager(sessionManager);
securityManager.setCacheManager(redisCacheManager);
/*
* 关闭shiro自带的session,详情见文档
*/
DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
securityManager.setSubjectDAO(subjectDAO);
return securityManager;
}
@Bean
public ShiroFilterChainDefinition shiroFilterChainDefinition() {
DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();
Map<String, String> filterMap = new LinkedHashMap<>();
filterMap.put("/**", "jwt"); // 主要通过注解方式校验权限
chainDefinition.addPathDefinitions(filterMap);
return chainDefinition;
}
@Bean("shiroFilterFactoryBean")
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager,
ShiroFilterChainDefinition shiroFilterChainDefinition) {
ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
shiroFilter.setSecurityManager(securityManager);
Map<String, Filter> filters = new HashMap<>();
filters.put("jwt", jwtFilter);
shiroFilter.setFilters(filters);
Map<String, String> filterMap = shiroFilterChainDefinition.getFilterChainMap();
shiroFilter.setFilterChainDefinitionMap(filterMap);
return shiroFilter;
}
// 开启注解代理(默认好像已经开启,可以不要)
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
return authorizationAttributeSourceAdvisor;
}
@Bean
public static DefaultAdvisorAutoProxyCreator getDefaultAdvisorAutoProxyCreator() {
DefaultAdvisorAutoProxyCreator creator = new DefaultAdvisorAutoProxyCreator();
return creator;
}
}
上面 ShiroConfig
,主要做了几件事情:
- 引入
RedisSessionDAO
和RedisCacheManager
,为了解决shiro
的权限数据和会话信息能保存到redis
中,实现会话共享。 - 重写了
SessionManager
和DefaultWebSecurityManager
,同时在DefaultWebSecurityManager
中为了关闭shiro
自带的session
方式,我们需要设置为false
,这样用户就不再能通过session
方式登录shiro
。后面将采用jwt
凭证登录。 - 在
ShiroFilterChainDefinition
中,我们不再通过编码形式拦截Controller
访问路径,而是所有的路由都需要经过JwtFilter
这个过滤器,然后判断请求头中是否含有jwt
的信息,有就登录,没有就跳过。跳过之后,有Controller
中的shiro
注解进行再次拦截,比如@RequiresAuthentication
,这样控制权限访问。
3.2.2 AccountRealm
AccountRealm
是 shiro
进行登录或者权限校验的逻辑所在,算是核心了,我们需要重写 3
个方法,分别是
supports
:为了让realm
支持jwt
的凭证校验doGetAuthorizationInfo
:授权doGetAuthenticationInfo
:认证
package com.lyj.vueblog.shiro;
import cn.hutool.core.bean.BeanUtil;
import com.lyj.vueblog.pojo.User;
import com.lyj.vueblog.service.IUserService;
import com.lyj.vueblog.utils.JwtUtils;
import org.apache.shiro.authc.*;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class AccountRealm extends AuthorizingRealm {
@Autowired
JwtUtils jwtUtils;
@Autowired
IUserService userService;
@Override
public boolean supports(AuthenticationToken token) {
/*判断token是不是JwtToken的实例对象*/
return token instanceof JwtToken;
}
// 授权
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
return null;
}
// 认证
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
System.out.println("=========================认证============================");
JwtToken jwtToken = (JwtToken) token;
String usrId = jwtUtils.getClaimByToken((String) jwtToken.getPrincipal()).getSubject();
User user = userService.getById(Long.valueOf(usrId));
if (user == null) {
throw new UnknownAccountException("账户不存在");
}
if (user.getStatus() == -1) {
throw new LockedAccountException("账户已被锁定");
}
AccountProfile profile = new AccountProfile();
BeanUtil.copyProperties(user, profile);
return new SimpleAuthenticationInfo(profile, jwtToken.getCredentials(), getName());
}
}
通过 jwt
获取到用户信息,判断用户的状态,最后异常就抛出对应的异常信息,否者封装成 SimpleAuthenticationInfo
返回给 shiro
。
shiro
默认 supports
的是 UsernamePasswordToken
,而我们现在采用了 jwt
的方式,所以这里我们自定义一个 JwtToken
,来完成 shiro
的 supports
方法。
3.2.3 JwtToken
public class JwtToken implements AuthenticationToken {
private String token;
public JwtToken(String token) {
this.token = token;
}
@Override
public Object getPrincipal() {
return token;
}
@Override
public Object getCredentials() {
return token;
}
}
JwtUtils
是个生成和校验 jwt
的工具类:
package com.lyj.vueblog.utils;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.Date;
/**
* jwt工具类
*/
@Slf4j
@Data
@Component
//@ConfigurationProperties(prefix = "markerhub.jwt")
public class JwtUtils {
private String secret = "f4e2e52034348f86b67cde581c0f9eb5";
private long expire = 604800;
private String header = "Authorization";
/**
* 生成jwt token
*/
public String generateToken(long userId) {
Date nowDate = new Date();
//过期时间
Date expireDate = new Date(nowDate.getTime() + expire * 1000);
return Jwts.builder()
.setHeaderParam("typ", "JWT")
.setSubject(userId+"")
.setIssuedAt(nowDate)
.setExpiration(expireDate)
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
public Claims getClaimByToken(String token) {
try {
return Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
}catch (Exception e){
log.debug("validate is token error ", e);
return null;
}
}
/**
* token是否过期
* @return true:过期
*/
public boolean isTokenExpired(Date expiration) {
return expiration.before(new Date());
}
}
3.2.4 JwtFilter
需要重写几个方法:
createToken
:实现登录,我们需要生成我们自定义支持的JwtToken
onAccessDenied
:拦截校验,当头部没有Authorization
时候,我们直接通过,不需要自动登录;当带有的时候,首先我们校验jwt的有效性,没问题我们就直接执行executeLogin方法实现自动登录onLoginFailure
:登录异常时候进入的方法,我们直接把异常信息封装然后抛出preHandle
:拦截器的前置拦截,因为我们是前后端分析项目,项目中除了需要跨域全局配置之外,我们再拦截器中也需要提供跨域支持。这样,拦截器才不会在进入Controller之前就被限制了。
@Component
public class JwtFilter extends AuthenticatingFilter {
@Autowired
JwtUtils jwtUtils;
@Override
protected AuthenticationToken createToken(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
// 获取 token
HttpServletRequest request = (HttpServletRequest) servletRequest;
String jwt = request.getHeader("Authorization");
if(StringUtils.isEmpty(jwt)){
return null;
}
return new JwtToken(jwt);
}
@Override
protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
HttpServletRequest request = (HttpServletRequest) servletRequest;
String token = request.getHeader("Authorization");
if(StringUtils.isEmpty(token)) {
return true;
} else {
// 判断是否已过期
Claims claim = jwtUtils.getClaimByToken(token);
if(claim == null || jwtUtils.isTokenExpired(claim.getExpiration())) {
throw new ExpiredCredentialsException("token已失效,请重新登录!");
}
}
// 执行自动登录
return executeLogin(servletRequest, servletResponse);
}
@Override
protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) {
HttpServletResponse httpResponse = (HttpServletResponse) response;
try {
//处理登录失败的异常
Throwable throwable = e.getCause() == null ? e : e.getCause();
Result r = Result.fail(throwable.getMessage());
String json = JSONUtil.toJsonStr(r);
httpResponse.getWriter().print(json);
} catch (IOException e1) {
}
return false;
}
/**
* 对跨域提供支持
*/
@Override
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpServletRequest = WebUtils.toHttp(request);
HttpServletResponse httpServletResponse = WebUtils.toHttp(response);
httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));
httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));
// 跨域时会首先发送一个OPTIONS请求,这里我们给OPTIONS请求直接返回正常状态
if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
httpServletResponse.setStatus(org.springframework.http.HttpStatus.OK.value());
return false;
}
return super.preHandle(request, response);
}
}
4. 全局异常处理
通过使用 @ControllerAdvice
来进行统一异常处理,@ExceptionHandler(value = Exception.class)
来指定捕获的 Exception
各个类型异常 ,这个异常的处理,是全局的,所有类似的异常,都会跑到这个地方处理。
package com.lyj.vueblog.exception;
import com.lyj.vueblog.common.Result;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.ShiroException;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@Slf4j
@RestControllerAdvice
public class GlobalException {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(value = RuntimeException.class)
public Result handler(RuntimeException e) {
log.error("运行时异常:----------{}", e);
return Result.fail(e.getMessage());
}
@ResponseStatus(HttpStatus.UNAUTHORIZED)
@ExceptionHandler(value = ShiroException.class)
public Result handler(ShiroException e) {
log.error("Shiro异常:----------{}", e);
return Result.fail(401, e.getMessage(), null);
}
}
5. 实体校验
当我们表单数据提交的时候,前端的校验我们可以使用一些类似于 jQuery Validate
等js插件实现,而后端我们可以使用 Hibernate validatior
来做校验。
-
首先在实体的属性上添加对应的校验规则
-
package com.lyj.vueblog.pojo; import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.IdType; import java.util.Date; import com.baomidou.mybatisplus.annotation.TableId; import java.io.Serializable; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.experimental.Accessors; import javax.validation.constraints.Email; import javax.validation.constraints.NotBlank; /** * @author LiuYunJie * @since 2022-03-29 */ @Data @EqualsAndHashCode(callSuper = false) @Accessors(chain = true) @TableName("m_user") public class User implements Serializable { private static final long serialVersionUID = 1L; /** * 主键 */ @TableId(value = "id", type = IdType.AUTO) private Long id; /** * 用户名 */ @NotBlank(message = "昵称不能为空") private String username; /** * 头像 */ private String avatar; /** * 邮箱 */ @NotBlank(message = "邮箱不能为空") @Email(message = "邮箱格式不正确") private String email; /** * 密码 */ private String password; /** * 状态 */ @NotBlank(message = "状态不能为空") private Integer status; /** * 创建时间 */ private Date created; /** * 修改时间 */ private Date modified; /** * 上次登陆时间 */ private Date lastLogin; }
-
package com.lyj.vueblog.pojo; import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.IdType; import java.util.Date; import com.baomidou.mybatisplus.annotation.TableId; import java.io.Serializable; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.experimental.Accessors; import javax.validation.constraints.NotBlank; /** * <p> * * </p> * * @author LiuYunJie * @since 2022-03-29 */ @Data @EqualsAndHashCode(callSuper = false) @Accessors(chain = true) @TableName("m_blog") public class Blog implements Serializable { private static final long serialVersionUID = 1L; /** * 主键id */ @TableId(value = "id", type = IdType.AUTO) private Long id; /** * 作者id */ @NotBlank(message = "作者Id不能为空") private Long userId; /** * 标题 */ @NotBlank(message = "标题不能为空") private String title; /** * 描述 */ @NotBlank(message = "描述不能为空") private String description; /** * 内容 */ private String content; /** * 创建时间 */ @NotBlank(message = "创建时间不能为空") private Date created; /** * 修改时间 */ private Date modified; /** * 状态 */ private Integer status; /** * 阅读量 */ @NotBlank(message = "阅读量不能为空") private Integer views; }
-
-
异常处理捕获
MethodArgumentNotValidException
-
/** * @Validated校验异常处理 */ @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(value = MethodArgumentNotValidException.class) public Result handle(MethodArgumentNotValidException e) { log.error("校验错误异常:----------{}", e); BindingResult bindingResult = e.getBindingResult(); ObjectError objectError = bindingResult.getAllErrors().stream().findFirst().get(); return Result.fail(objectError.getDefaultMessage()); }
-
6. 跨域问题
/**
* 解决跨域问题
*/
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
//添加映射路径
registry.addMapping("/**")
//是否发送Cookie
.allowCredentials(true)
//设置放行哪些原始域 SpringBoot2.4.4下低版本使用.allowedOrigins("*")
.allowedOriginPatterns("*")
//放行哪些请求方式
.allowedMethods(new String[]{"GET", "POST", "PUT", "DELETE"})
//.allowedMethods("*") //或者放行全部
//放行哪些原始请求头部信息
.allowedHeaders("*")
//暴露哪些原始请求头部信息
.exposedHeaders("*");
}
}
7. 登录接口开发
7.1 登录
package com.lyj.vueblog.controller;
import cn.hutool.core.date.DateUtil;
import cn.hutool.crypto.SecureUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.lyj.vueblog.common.Result;
import com.lyj.vueblog.dto.LoginDTO;
import com.lyj.vueblog.pojo.User;
import com.lyj.vueblog.service.IUserService;
import com.lyj.vueblog.utils.JwtUtils;
import org.apache.shiro.SecurityUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.Assert;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletResponse;
import java.util.Date;
@RestController
public class AccountController {
@Autowired
IUserService userService;
@Autowired
JwtUtils jwtUtils;
/**
* 登录
* @param loginDTO
* @param response
* @return
*/
@RequestMapping("/login")
public Result login(@Validated @RequestBody LoginDTO loginDTO,
HttpServletResponse response) {
String username = loginDTO.getUsername();
QueryWrapper<User> wrapper = new QueryWrapper<User>()
.eq("username", username);
User one = userService.getOne(wrapper);
Assert.notNull(one, "用户不存在");
if (!one.getPassword().equals(SecureUtil.md5(loginDTO.getPassword()))) {
return Result.fail("密码错误!");
}
String token = jwtUtils.generateToken(one.getId());
response.setHeader("Authorization", token);
response.setHeader("Access-Control-Expose-Headers", "Authorization");
one.setLastLogin(new Date());
userService.updateById(one);
return Result.success(one);
}
}
7.2 注销
package com.lyj.vueblog.controller;
import cn.hutool.core.date.DateUtil;
import cn.hutool.crypto.SecureUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.lyj.vueblog.common.Result;
import com.lyj.vueblog.dto.LoginDTO;
import com.lyj.vueblog.pojo.User;
import com.lyj.vueblog.service.IUserService;
import com.lyj.vueblog.utils.JwtUtils;
import org.apache.shiro.SecurityUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.Assert;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletResponse;
import java.util.Date;
@RestController
public class AccountController {
/**
* 退出登录
* @return
*/
@RequiresAuthentication // 登录以后才能操作
@GetMapping("/logout")
public Result logout() {
SecurityUtils.getSubject().logout();
return Result.success(null);
}
}
8. 博客接口开发
package com.lyj.vueblog.controller;
import cn.hutool.core.bean.BeanUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.lyj.vueblog.common.Result;
import com.lyj.vueblog.pojo.Blog;
import com.lyj.vueblog.service.IBlogService;
import com.lyj.vueblog.utils.ShiroUtil;
import org.apache.shiro.authz.annotation.RequiresAuthentication;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.Assert;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.Date;
/**
* <p>
* 前端控制器
* </p>
*
* @author LiuYunJie
* @since 2022-03-29
*/
@RestController
public class BlogController {
@Autowired
IBlogService blogService;
@GetMapping("/blogs")
public Result list(@RequestParam(defaultValue = "1") Integer currentPage,
@RequestParam(defaultValue = "5") Integer pageSize) {
if (currentPage < 1) currentPage = 1;
QueryWrapper<Blog> wrapper = new QueryWrapper<Blog>()
.orderByDesc("created");
return Result.success(blogService.page(new Page(currentPage, pageSize), wrapper));
}
@GetMapping("/blog/{id}")
public Result detail(@PathVariable(name = "id") Long id) {
Blog blog = blogService.getById(id);
Assert.notNull(blog, "该博客已被删除");
return Result.success(blog);
}
@RequiresAuthentication
@PostMapping("/blog/edit")
public Result editOrSave(@Validated @RequestBody Blog blog) {
Blog temp;
if (blog.getId() != null) {
// 更新
temp = blogService.getById(blog);
Assert.isTrue(temp.getUserId().longValue() == ShiroUtil.getProfile().getId().longValue(), "没有权限编辑");
blog.setModified(new Date());
} else {
// 添加博客
temp = new Blog();
temp.setUserId(ShiroUtil.getProfile().getId());
temp.setCreated(new Date());
temp.setModified(new Date());
temp.setStatus(0);
temp.setViews(0);
}
BeanUtil.copyProperties(blog, temp, "id", "userId", "created", "modified", "views");
blogService.saveOrUpdate(temp);
return Result.success(null);
}
}
9. 前端环境搭建
-
创建
Vue
项目vue create blogweb
-
安装
element-ui
、axios
-
cnpm i element-ui -S
-
import Element from 'element-ui' import 'element-ui/lib/theme-chalk/index.css'; Vue.use(Element)
-
-
cnpm install axios --save
-
import Axios from 'axios' Vue.prototype.$axios = Axios
-
-
10. 页面路由
新建页面
- BlogDetail.vue(博客详情页)
- BlogEdit.vue(编辑博客)
- Blogs.vue(博客列表)
- Login.vue(登录页面)
配置路由
import Vue from 'vue'
import VueRouter from 'vue-router'
import Login from '../views/Login'
import Blogs from '../views/Blogs'
import BlogEdit from '../views/BlogEdit'
import BlogDetail from '../views/BlogDetail'
Vue.use(VueRouter)
const routes = [
{
path: '/',
name: 'Index',
redirect: {name: "Blogs"}
},
{
path: '/blogs',
name: 'Blogs',
component: Blogs,
},
{
path: '/login',
name: 'Login',
component: Login,
},
{
path: '/blog/add',
name: 'BlogEdit',
component: BlogEdit,
},
{
path: '/blog/:blogId',
name: 'BlogDetail',
component: BlogDetail,
},
{
path: '/blog/:blogId/edit',
name: 'BlogEdit',
component: BlogEdit,
},
]
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
routes
})
export default router
11. 登录页面
11.1 Login.vue
<template>
<div>
<el-container>
<el-header>
<router-link to="/blogs">
<div class="demo-type">
<div>
<el-avatar src="https://get.wallhere.com/photo/anime-girl-red-refflex-1530111.jpg"></el-avatar>
</div>
</div>
</router-link>
</el-header>
<el-main>
<el-form :model="ruleForm" status-icon :rules="rules" ref="ruleForm" label-width="100px"
class="demo-ruleForm">
<el-form-item label="用户名" prop="username">
<el-input type="text" maxlength="12" v-model="ruleForm.username"></el-input>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input type="password" v-model="ruleForm.password" autocomplete="off"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm('ruleForm')">登录</el-button>
<el-button @click="resetForm('ruleForm')">重置</el-button>
</el-form-item>
</el-form>
</el-main>
</el-container>
</div>
</template>
<script>
export default {
name: 'Login',
data() {
var validatePass = (rule, value, callback) => {
if (value === '') {
callback(new Error('请输入密码'));
} else {
callback();
}
};
return {
ruleForm: {
password: '111111',
username: 'yxj'
},
rules: {
password: [
{validator: validatePass, trigger: 'blur'}
],
username: [
{required: true, message: '请输入用户名', trigger: 'blur'},
{min: 3, max: 12, message: '长度在 3 到 12 个字符', trigger: 'blur'}
]
}
};
},
methods: {
submitForm(formName) {
const _this = this
this.$refs[formName].validate((valid) => {
if (valid) {
// 提交逻辑
this.$axios.post('http://localhost:9090/login', this.ruleForm).then((res) => {
const token = res.headers['authorization']
_this.$store.commit('SET_TOKEN', token)
_this.$store.commit('SET_USERINFO', res.data.data)
_this.$router.push("/blogs")
})
} else {
console.log('error submit!!');
return false;
}
});
},
resetForm(formName) {
this.$refs[formName].resetFields();
}
},
mounted() {
this.$notify({
title: '看这里:',
message: '认真学习,第一个前后端分离系统',
duration: 1500
});
}
}
</script>
<style scoped>
.el-header, .el-footer {
background-color: #B3C0D1;
color: #333;
text-align: center;
line-height: 60px;
}
.el-aside {
background-color: #D3DCE6;
color: #333;
text-align: center;
line-height: 200px;
}
.el-main {
/*background-color: #E9EEF3;*/
color: #333;
text-align: center;
line-height: 160px;
}
body > .el-container {
margin-bottom: 40px;
}
.el-container:nth-child(5) .el-aside,
.el-container:nth-child(6) .el-aside {
line-height: 260px;
}
.el-container:nth-child(7) .el-aside {
line-height: 320px;
}
.mlogo {
height: 60%;
margin-top: 10px;
}
.demo-ruleForm {
max-width: 500px;
margin: 0 auto;
}
</style>
11.2 token状态同步
store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
state: {
token: '',
userInfo: JSON.parse(sessionStorage.getItem("userInfo"))
},
mutations: {
SET_TOKEN: (state, token) => {
state.token = token
localStorage.setItem("token", token)
},
SET_USERINFO: (state, userInfo) => {
state.userInfo = userInfo
sessionStorage.setItem("userInfo", JSON.stringify(userInfo))
},
REMOVE_INFO: (state) => {
localStorage.setItem("token", '')
sessionStorage.setItem("userInfo", JSON.stringify(''))
state.userInfo = {}
}
},
getters: {
getUser: state => {
return state.userInfo
}
},
actions: {},
modules: {}
})
11.3 axios全局拦截器
在 src
目录下创建一个文件 axios.js
(与 main.js
同级)
import axios from 'axios'
import Element from "element-ui";
import store from "./store";
import router from "./router";
axios.defaults.baseURL = 'http://localhost:9090'
axios.interceptors.request.use(config => {
console.log("前置拦截")
// 可以统一设置请求头
return config
})
axios.interceptors.response.use(response => {
const res = response.data;
console.log("后置拦截")
// 当结果的code是否为200的情况
if (res.code === 200) {
return response
} else {
// 弹窗异常信息
Element.Message({
message: response.data.msg,
type: 'error',
duration: 2 * 1000
})
// 直接拒绝往下面返回结果信息
return Promise.reject(response.data.msg)
}
},
error => {
console.log('err' + error)// for debug
if (error.response.data) {
error.message = error.response.data.msg
}
// 根据请求状态觉得是否登录或者提示其他
if (error.response.status === 401) {
store.commit('REMOVE_INFO');
router.push({
path: '/login'
});
error.message = '请重新登录';
}
if (error.response.status === 403) {
error.message = '权限不足,无法访问';
}
Element.Message({
message: error.message,
type: 'error',
duration: 3 * 1000
})
return Promise.reject(error)
})
12. 博客列表
12.1 头部
头部的用户信息,应该包含三部分信息:id,头像、用户名,而这些信息我们是在登录之后就已经存在了 sessionStorage
。因此,我们可以通过 store
的 getters
获取到用户信息。
<template>
<div class="m-content">
<h3>欢迎来到云小杰的博客</h3>
<div class="block">
<el-avatar :size="50" :src="user.avatar"></el-avatar>
<div></div>
</div>
<div class="maction">
<el-link href="/blogs">主页</el-link>
<el-divider direction="vertical"></el-divider>
<span>
<el-link type="success" href="/blog/add" :disabled="!hasLogin">发表文章</el-link>
</span>
<el-divider direction="vertical"></el-divider>
<span v-show="!hasLogin">
<el-link type="primary" href="/login">登陆</el-link>
</span>
<span v-show="hasLogin">
<el-link type="danger" @click="logout">退出</el-link>
</span>
</div>
</div>
</template>
<script>
export default {
name: "Header",
data() {
return {
hasLogin: false,
user: {
username: '请先登录',
avatar: "https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png"
},
blogs: {},
currentPage: 1,
total: 0
}
},
methods: {
logout() {
const _this = this
this.$axios.get('http://localhost:9090/logout', {
headers: {
"Authorization": localStorage.getItem("token")
}
}).then((res) => {
_this.$store.commit('REMOVE_INFO')
_this.$router.push('/login')
});
}
},
created() {
if (this.$store.getters.getUser.username) {
this.user.username = this.$store.getters.getUser.username
this.user.avatar = this.$store.getters.getUser.avatar
this.hasLogin = true
}
}
}
</script>
<style scoped>
.m-content {
max-width: 960px;
margin: 0 auto;
text-align: center;
}
.maction {
margin: 10px 0;
}
</style>
12.2 博客分页
<template>
<div class="mcontaner">
<Header></Header>
<div class="block">
<el-timeline>
<el-timeline-item :timestamp="blog.created" placement="top" v-for="blog in blogs">
<el-card>
<h4>
<router-link :to="{name: 'BlogDetail', params: {blogId: blog.id}}">
</router-link>
</h4>
<p></p>
</el-card>
</el-timeline-item>
</el-timeline>
<el-pagination class="mpage"
background
layout="prev, pager, next"
:current-page="currentPage"
:page-size="pageSize"
:total="total"
@current-change=page>
</el-pagination>
</div>
</div>
</template>
<script>
import Header from "../components/Header";
export default {
name: "Blogs.vue",
components: {Header},
data() {
return {
blogs: {},
currentPage: 1,
total: 0,
pageSize: 5
}
},
methods: {
page(currentPage) {
const _this = this
_this.$axios.get("/blogs?currentPage=" + currentPage).then(res => {
console.log(res)
_this.blogs = res.data.data.records
_this.currentPage = res.data.data.current
_this.total = res.data.data.total
_this.pageSize = res.data.data.size
})
}
},
created() {
this.page(1)
}
}
</script>
<style scoped>
.mpage {
margin: 0 auto;
text-align: center;
}
</style>
格式化时间
10. 博客编辑、发表
10.1 集成markdown
- 安装
cnpm install mavon-editor --save
- 注册
// 全局注册
import Vue from 'vue'
import mavonEditor from 'mavon-editor'
import 'mavon-editor/dist/css/index.css'
// use
Vue.use(mavonEditor)
10.2 博客页面
<template>
<div>
<Header></Header>
<div class="m-content">
<el-form :model="ruleForm" :rules="rules" ref="ruleForm" label-width="100px" class="demo-ruleForm">
<el-form-item label="标题" prop="title">
<el-input v-model="ruleForm.title"></el-input>
</el-form-item>
<el-form-item label="摘要" prop="description">
<el-input type="textarea" v-model="ruleForm.description"></el-input>
</el-form-item>
<el-form-item label="内容" prop="content">
<mavon-editor v-model="ruleForm.content"></mavon-editor>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm('ruleForm')">立即创建</el-button>
<el-button @click="resetForm('ruleForm')">重置</el-button>
</el-form-item>
</el-form>
</div>
</div>
</template>
<script>
import Header from "../components/Header";
export default {
name: "BlogEdit.vue",
components: {Header},
data() {
return {
ruleForm: {
id: '',
title: '',
description: '',
content: ''
},
rules: {
title: [
{required: true, message: '请输入标题', trigger: 'blur'},
{min: 3, max: 25, message: '长度在 3 到 25 个字符', trigger: 'blur'}
],
description: [
{required: true, message: '请输入摘要', trigger: 'blur'}
],
content: [
{trequired: true, message: '请输入内容', trigger: 'blur'}
]
}
};
},
methods: {
submitForm(formName) {
this.$refs[formName].validate((valid) => {
if (valid) {
const _this = this
this.$axios.post('/blog/edit', this.ruleForm, {
headers: {
"Authorization": localStorage.getItem("token")
}
}).then(res => {
console.log(res)
_this.$alert('操作成功', '提示', {
confirmButtonText: '确定',
callback: action => {
_this.$router.push("/blogs")
}
});
})
} else {
console.log('error submit!!');
return false;
}
});
},
resetForm(formName) {
this.$refs[formName].resetFields();
}
},
created() {
const blogId = this.$route.params.blogId
console.log(blogId)
const _this = this
if (blogId) {
this.$axios.get('/blog/' + blogId).then(res => {
const blog = res.data.data
_this.ruleForm.id = blog.id
_this.ruleForm.title = blog.title
_this.ruleForm.description = blog.description
_this.ruleForm.content = blog.content
})
}
}
}
</script>
<style scoped>
.m-content {
text-align: center;
}
</style>
10.3 博客详情页
后端传过来的是博客内容是 markdown
格式的内容,我们需要进行渲染然后显示出来,这里我们使用一个插件 markdown-it
,用于解析 md
文档,然后导入 github-markdown-c
,所谓 md
的样式。
# 用于解析md文档
cnpm install markdown-it --save
# md样式
cnpm install github-markdown-css
使用:
<template>
<div>
<Header></Header>
<div class="mblog">
<h2> </h2>
<el-link icon="el-icon-edit" v-if="ownBlog">
<router-link :to="{name: 'BlogEdit', params: {blogId: blog.id}}" >
编辑
</router-link>
</el-link>
<el-divider></el-divider>
<div class="markdown-body" v-html="blog.content"></div>
</div>
</div>
</template>
<script>
import 'github-markdown-css'
import Header from "../components/Header";
export default {
name: "BlogDetail.vue",
components: {Header},
data() {
return {
blog: {
id: "",
title: "",
content: ""
},
ownBlog: false
}
},
created() {
const blogId = this.$route.params.blogId
console.log(blogId)
const _this = this
this.$axios.get('/blog/' + blogId).then(res => {
const blog = res.data.data
_this.blog.id = blog.id
_this.blog.title = blog.title
var MardownIt = require("markdown-it")
var md = new MardownIt()
var result = md.render(blog.content)
_this.blog.content = result
_this.ownBlog = (blog.userId === _this.$store.getters.getUser.id)
})
}
}
</script>
<style scoped>
.mblog {
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
width: 100%;
min-height: 700px;
padding: 20px 15px;
}
</style>
11. 路由权限拦截
页面已经开发完毕之后,我们来控制一下哪些页面是需要登录之后才能跳转的,如果未登录访问就直接重定向到登录页面,因此我们在 src
目录下定义一个 js
文件:
import router from "./router";
// 路由判断登录 根据路由配置文件的参数
router.beforeEach((to, from, next) => {
if (to.matched.some(record => record.meta.requireAuth)) { // 判断该路由是否需要登录权限
const token = localStorage.getItem("token")
console.log("------------" + token)
if (token) { // 判断当前的token是否存在 ; 登录存入的token
if (to.path === '/login') {
} else {
next()
}
} else {
next({
path: '/login'
})
}
} else {
next()
}
})
通过之前我们再定义页面路由时候的的 meta
信息,指定 requireAuth: true
,需要登录才能访问,因此这里我们在每次路由之前(router.beforeEach
)判断token
的状态,觉得是否需要跳转到登录页面。
{
path: '/blog/add', // 注意放在 path: '/blog/:blogId'之前
name: 'BlogAdd',
meta: {
requireAuth: true
},
component: BlogEdit
}
然后我们再 main.js
中 import
我们的 permission.js
import './permission.js' // 路由拦截