云小杰

相对于绝对成功的汲汲渴求,越无杂质的奔赴,越是动人。

要一个黄昏, 满是风, 和正在落下的夕阳。如此, 足够我爱这破碎泥泞的人间。


Download the theme

Vueblog

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项目

image-20220329101418948

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 的身份验证过程。

  • 登录逻辑
    • image-20220329114942626

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,主要做了几件事情:

  1. 引入 RedisSessionDAO 和 RedisCacheManager,为了解决 shiro 的权限数据和会话信息能保存到 redis 中,实现会话共享。
  2. 重写了 SessionManager 和 DefaultWebSecurityManager,同时在 DefaultWebSecurityManager 中为了关闭 shiro 自带的 session 方式,我们需要设置为 false,这样用户就不再能通过 session 方式登录 shiro。后面将采用 jwt 凭证登录。
  3. 在 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

需要重写几个方法:

  1. createToken:实现登录,我们需要生成我们自定义支持的 JwtToken
  2. onAccessDenied:拦截校验,当头部没有 Authorization 时候,我们直接通过,不需要自动登录;当带有的时候,首先我们校验jwt的有效性,没问题我们就直接执行executeLogin方法实现自动登录
  3. onLoginFailure:登录异常时候进入的方法,我们直接把异常信息封装然后抛出
  4. 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 来做校验。

  1. 首先在实体的属性上添加对应的校验规则

    • 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;
      }
      
  2. 异常处理捕获 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. 前端环境搭建

  1. 创建 Vue 项目

    • vue create blogweb
  2. 安装 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>

格式化时间

image-20220330115014880

10. 博客编辑、发表

10.1 集成markdown

  1. 安装
cnpm install mavon-editor --save
  1. 注册
// 全局注册
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' // 路由拦截
最近的文章

Easypoi

学习EasyPOI1. 简介官方网站:http://doc.wupaas.com/docs/easypoi/easypoi-1c0u6ksp2r091。Easypoi 的目标不是替代 poi ,而是让一个不懂导入导出的快速使用 poi 完成 Excel 和 word 的各种操作,而不是看很多 api 才可以完成这样工作。2. 使用EasyPOI2.1 环境搭建 引入相关依赖 <dependency> <groupId>cn.af...…

• 继续阅读
更早的文章

Vue集成视频组件

Vue集成视频组件1. 修改配置# 修改上传文件大小的限制spring: multipart: max-file-size: 100MB max-request-size: 100MB2. Vue集成npm install vue-video-player@5.0.2 --save 引入 main.js 中 import VideoPlayer from 'vue-video-pla...…

• 继续阅读