Springboot+Shiro+Vue博客管理系统

Springboot+vue博客管理系统


项目是前后端分离的一个项目,文章总体分为java后端接口和vue前端页面。

Java后端接口

1.前言

编译器使用的IDEA2021,一开始先创建新的项目骨架,直接选择Spring Initializr创建。数据层一开始学习springboot的时候用的mybatis,易上手也很方便,但是编写SQL语句时工作量很大,尤其是字段多、关联表多时,更是如此,所以用的mybatis-plus(https://baomidou.com/guide/),为简化而生只…,启动自动注入基本CRUD。性能基本无损耗,直接面向对象操作,还有分页插件等等。然后同时权限也是需要注意的,所以用了Shiro的配置,直接使用,节约时间。其次考虑项目可能要部署多台所以一些会话信息等需要共享,然后使用了redis,因为是前后端分离,因此使用jwt作为用户身份验证,简介完开始搭建项目脚手架。

使用技术:
  • Springboot
  • Mybatis plus
  • Shiro
  • lombok
  • redis
  • hibernate validatior
  • jwt
项目目录概览:

Springboot+Shiro+Vue博客管理系统_第1张图片

2.新建Springboot项目

用IDEA开发项目,创建项目步骤比较简单就直接跳过了。
Springboot+Shiro+Vue博客管理系统_第2张图片
开发工具与环境:

  • IDEA
  • mysql
  • jdk8
  • maven3.6.3

pom的依赖引入:

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-devtools</artifactId>
	<scope>runtime</scope>
	<optional>true</optional>
</dependency>
<dependency>
	<groupId>org.projectlombok</groupId>
	<artifactId>lombok</artifactId>
	<optional>true</optional>
</dependency>
  • devtools:项目加热部署
  • lombok:简化代码的工具例如用@Data注解省略getter setter ToString等

3.整合mybatis plus

Mybatis-Puls官方文档
第一步:引入mybastis-puls依赖、以及模板引擎依赖后续还有使用代码生成

<!--mp-->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.2.0</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-freemarker</artifactId>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>runtime</scope>
</dependency>
<!--mp代码生成器-->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-generator</artifactId>
    <version>3.2.0</version>
</dependency>

第二步:写yml配置文件

# DataSource Config
server:
  port: 8082
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: root
mybatis-plus:
  mapper-locations: classpath*:/mapper/**Mapper.xml

这里配置了基本的接口、数据库信息以及还要配置mybatis-plus的mapper的xml文件的扫描路径

第三步:开启mapper接口扫描,添加分页插件
新建一个包:通过@mapperScan注解指定要变成实现类的接口所在的包,然后包下面的所有接口在编译之后都会生成相应的实现类。PaginationInterceptor是一个分页插件。

@Configuration
@EnableTransactionManagement
@MapperScan("com.vueblog.mapper")

public class MybatisPlusConfig {
    @Bean
    public PaginationInterceptor paginationInterceptor() {
        PaginationInterceptor paginationInterceptor = new PaginationInterceptor();
        return paginationInterceptor;
    }
}

  • @MapperScan注解

    之前是,直接在Mapper类上面添加注解@Mapper,这种方式要求每一个mapper类都需要添加此注解,麻烦,通过使用@MapperScan可以指定要扫描的Mapper类的包的路径

  • @EnableTransactionManagement

    @EnableTransactionManagement表示开启事务支持,在springboot项目中一般配置在启动类上,效果等同于xml配置的。开启事务支持后,然后在访问数据库的Service方法上添加注解 @Transactional 便可。

第四步:代码生成
官方文档有提供代码生成器,直接使用即可,写上了自己的参数后,就可以根据数据表信息生成entity层、service层、mapper层等
Springboot+Shiro+Vue博客管理系统_第3张图片
大致生成如下:
Springboot+Shiro+Vue博客管理系统_第4张图片
第五步:测试

@RestController
@RequestMapping("/user")
public class UserController {
    @Autowired
    UserService userService;
    @GetMapping("/{id}")
    public Object test(@PathVariable("id") Long id) {
        return userService.getById(id);
    }
}

数据库信息
Springboot+Shiro+Vue博客管理系统_第5张图片

postman检验:
Springboot+Shiro+Vue博客管理系统_第6张图片

4.统一结果封装

目前的前后端开发大部分数据的传输格式都是json,因此定义一个统一规范的数据格式有利于前后端的交互与UI的展示。
创建一个Rusult的类,用来做异步统一返回的将结果封装主要是几点

  • code-响应状态码(200表示成功 400表示异常)
  • msg-响应信息
  • data-响应数据

也可以是通过一般形式

  • success-是否响应成功
  • code-响应状态码
  • message-状态码描述
  • data-响应数据
  • 其他标识符
@Data
public class Result implements Serializable {
    private String code;
    private String msg;
    private Object data;
    public static Result succ(Object data) {
        Result m = new Result();
        m.setCode("0");
        m.setData(data);
        m.setMsg("操作成功");
        return m;
    }
    public static Result succ(String mess, Object data) {
        Result m = new Result();
        m.setCode("0");
        m.setData(data);
        m.setMsg(mess);
        return m;
    }
    public static Result fail(String mess) {
        Result m = new Result();
        m.setCode("-1");
        m.setData(null);
        m.setMsg(mess);
        return m;
    }
    public static Result fail(String mess, Object data) {
        Result m = new Result();
        m.setCode("-1");
        m.setData(data);
        m.setMsg(mess);
        return m;
    }
}

5.整合Shiro+jwt

Shiro 是一个强大易用的 Java 安全框架,提供了认证、授权、加密和会话管理等功能,对于任何一个应用程序,Shiro 都可以提供全面的安全管理服务。并且相对于其他安全框架,Shiro 要简单的多,1.Shiro官方文档2.shiro官方教程里面有教程如何上手shiro

JSON Web TokenJWT)是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准,用于在各方之间以JSON对象安全传输信息,这些信息可以通过数字签名进行验证和信任。 可以使用RSA的公钥/私钥对对JWT进行签名。这里是通过采用token或者jwt作为跨域身份验证解决方案,来在登录环节进行用户身份验证,以获取资源。是目前最流行的跨域认证解决方案。

  • 首先梳理一下shiro在项目里是怎么样的一个流程,以及用户登录,登录完成后这个jwt的校验逻辑是怎么样的,结合一下流程图:
    Springboot+Shiro+Vue博客管理系统_第7张图片
  1. 首先用户通过用户名密码登录
  2. 后端进行校验,成功将生成jwt并且返回jwt给用户。如果在校验中出现密码错误,用户不存在等,将抛出一处。
  • 再当用户拿到jwt凭证时,再去访问其他后端接口或者受限资源时,需要带上jwt,这是它的身份凭证,这样才能知道访问这个接口的用户到底是谁,结合流程图再梳理这个逻辑过程:
    Springboot+Shiro+Vue博客管理系统_第8张图片
  1. 在进入接口之前,访问可能有分为用户登录和用户未登录状态,没登录前是没jwt身份凭证,登录了才有jwt身份凭证,有jwt的话在项目里有全局的处理逻辑,在接口之前都会有拦截或者过滤,在Shiro中就是一个过滤的逻辑,例如一个JwtFilter
  2. 有jwt的话,交给shiro登录处理,调用shiro的Filter,内置的一些登录的逻辑,比如将jwt封装成token再使用shiro的SecurityManager进行一个登录,登录完成就能识别了当前用户是谁。
  3. 随即检验是正常或者异常(jwt过期、密钥不对)状态,如果是异常状态那么将抛出异常,之后将有全局异常处理器就行一个拦截,再返回一个json数据给前端。
  4. 正常或者无jwt的访问我们的接口,shiro除了需要对登录就行一个处理之外,还需要去判断资源的一个权限的情况,不如xxxcontroller需要一个admin管理员的角色进行一个管理,就需要在controller接口的前置设置一个拦截,可以通过一个注解拦截过滤例如controller前置一个@RequiresRoles("admin")注解就是需要admin用户权限才可以访问该接口。
  5. 经过注解过滤判断是否有权限,无权限的话同样抛出异常,交由全局异常处理器进行拦截;有权限就访问controller。

第一步:接下来先引入shiro-redis依赖、jwt依赖、还有hutool工具包

先简介一下hutool:

hutool是一个小而全的Java工具类库,通过静态方法封装,降低相关API的学习成本,提高工作效率,使Java拥有函数式语言般的优雅,Hutool是项目中“util”包友好的替代,它节省了开发人员对项目中公用类和公用工具方法的封装时间,使开发专注于业务,同时可以最大限度的避免封装不完善带来的bug。
以计算MD5为例:
【以前】打开搜索引擎 -> 搜“Java MD5加密” -> 打开某篇博客-> 复制粘贴 -> 改改好用
【现在】引入Hutool -> SecureUtil.md5()
Hutool的存在就是为了减少代码搜索成本,避免网络上参差不齐的代码出现导致的bug。

接着按官方文档引入依赖
Springboot+Shiro+Vue博客管理系统_第9张图片

<dependency>
    <groupId>org.crazycake</groupId>
    <artifactId>shiro-redis-spring-boot-starter</artifactId>
    <version>3.2.1</version>
</dependency>
<!-- hutool工具类-->
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.3.3</version>
</dependency>
<!-- jwt -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

第二步:编写配置(直接套用官方文档)
先简述Shiro的工作流程:
Shiro进行认证的本质还是通过过滤器进行拦截,过滤器拦截后判断是否需要进行认证,如果需要,取出”token”并交给SecurityManager进行认证,认证通过后放行,如果不需要认证则直接放行。

为什么token打上引号,是因为token并不一定是普遍意义上的JWT(json web token),也可以是基于BASIC HTTP的token,还可以是表单中的用户名和密码。

所以Shiro中就内置了一些常用的Filter,比如内置的AuthenticatingFilter类,有基于表单认证的FormAuthenticationFilter类和基于BASIC HTTP的BasicHttpAuthenticationFilter类,还有不需要认证直接放行的AnonymousFilter类,也有一些用于检查RolesPermissions的过滤器,具体的可以去DefaultFilter中看看。

  • ShiroConfig:
    按官方文档:
    Springboot+Shiro+Vue博客管理系统_第10张图片
    重写sessionManage和SessionsSecurityManager
/**
 * 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,我们主要做了几件事情:

  • 引入RedisSessionDAORedisCacheManager,为了解决shiro的权限数据和会话信息能保存到redis中,实现会话共享。

  • 重写了SessionManagerDefaultWebSecurityManager,同时在DefaultWebSecurityManager中为了关闭shiro自带的session方式,我们需要设置为false,这样用户就不再能通过session方式登录shiro。后面将采用jwt凭证登录。

  • ShiroFilterChainDefinition中,我们不再通过编码形式拦截Controller访问路径,而是所有的路由都需要经过JwtFilter这个过滤器,然后判断请求头中是否含有jwt的信息,有就登录,没有就跳过。跳过之后,有Controller中的shiro注解进行再次拦截,比如@RequiresAuthentication,这样控制权限访问。

  • AccountRealm:
    AccountRealm是shiro进行登录或者权限校验的逻辑所在,算是核心了,我们需要重写3个方法,分别是

    • supports:为了让realm支持jwt的凭证校验
    • doGetAuthorizationInfo:权限校验
    • doGetAuthenticationInfo:登录认证校验
      总体代码:
@Slf4j
@Component
public class AccountRealm extends AuthorizingRealm {
    @Autowired
    JwtUtils jwtUtils;
    @Autowired
    UserService userService;
    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof JwtToken;
    }
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        return null;
    }
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        JwtToken jwt = (JwtToken) token;
        log.info("jwt----------------->{}", jwt);
        String userId = jwtUtils.getClaimByToken((String) jwt.getPrincipal()).getSubject();
        User user = userService.getById(Long.parseLong(userId));
        if(user == null) {
            throw new UnknownAccountException("账户不存在!");
        }
        if(user.getStatus() == -1) {
            throw new LockedAccountException("账户已被锁定!");
        }
        AccountProfile profile = new AccountProfile();
        BeanUtil.copyProperties(user, profile);
        log.info("profile----------------->{}", profile.toString());
        return new SimpleAuthenticationInfo(profile, jwt.getCredentials(), getName());
    }
}

其实主要就是doGetAuthenticationInfo登录认证这个方法,可以看到我们通过jwt获取到用户信息,判断用户的状态,最后异常就抛出对应的异常信息,否者封装成SimpleAuthenticationInfo返回给shiro。
接下来我们逐步分析里面出现的新类:
1、shiro默认supports的是UsernamePasswordToken,而我们现在采用了jwt的方式,这里是我们自定义一个JwtToken,来完成shiro的supports方法。

  • 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的工具类
@Component
@ConfigurationProperties(prefix = "markerhub.jwt")
public class JwtUtils {
    private String secret;
    private long expire;
    private String header;
    /**
     * 生成jwt token
     */
    public String generateToken(long userId) {
    ...
    }
    
    // 获取jwt的信息
    public Claims getClaimByToken(String token) {
    ...
    }
    
    /**
     * token是否过期
     * @return  true:过期
     */
    public boolean isTokenExpired(Date expiration) {
        return expiration.before(new Date());
    }
}
  • AccountProfile
    在登录成功之后返回的一个用户信息的载体
@Data
public class AccountProfile implements Serializable {
    private Long id;
    private String username;
    private String avatar;
}
  • 在yml配置文件增加shiro-jwt的配置
shiro-redis:
  enabled: true
  redis-manager:
    host: 127.0.0.1:6379
markerhub:
  jwt:
    # 加密秘钥
    secret: f4e2e52034348f86b67cde581c0f9eb5
    # token有效时长,7天,单位秒
    expire: 604800
    header: token

前面引入了spring-boot-devtools热部署,需要添加一个配置文件,在resources目录下新建文件夹META-INF,然后新建文件spring-devtools.properties,这样热重启时候才不会报错。

  • resources/META-INF/spring-devtools.properties
    Springboot+Shiro+Vue博客管理系统_第11张图片
restart.include.shiro-redis=/shiro-[\\w-\\.]+jar
  • JwtFilter
    自定义jwt的过滤器,这样每次带token的都自动登陆服务器了
    这个Filter是核心内容,JwtFilter继承shiro内置的AuthenticatingFilter方法,继承BasicHttpAuthenticationFilter也是可以的。

AuthenticationFilter类AuthenticatingFilter
注意这两个类名的不同。
AuthenticationFilter只实现了isAccessAllowed(...)方法:

protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
    // 返回绑定到当前线程上的主体是否已经认证的结果
    Subject subject = getSubject(request, response);
    return subject.isAuthenticated();
}

AuthenticatingFilter继承自AuthenticationFilter类

protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
    AuthenticationToken token = createToken(request, response);
    if (token == null) {
        String msg = "createToken method implementation returned null. A valid non-null AuthenticationToken " +
                "must be created in order to execute a login attempt.";
        throw new IllegalStateException(msg);
    }
    try {
        Subject subject = getSubject(request, response);
        subject.login(token);
        return onLoginSuccess(token, subject, request, response);
    } catch (AuthenticationException e) {
        return onLoginFailure(token, e, request, response);
    }
}

protected abstract AuthenticationToken createToken(ServletRequest request, ServletResponse response) throws Exception;

protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
    return super.isAccessAllowed(request, response, mappedValue) ||
            (!isLoginRequest(request, response) && isPermissive(mappedValue));
}

这个isAccessAllowed(…)方法:如果已经认证则返回true,或者请求不需要认证并且设置了”permissive”,设置”permissive”是什么意思呢:比如说配置过滤器的路由策略时:map.add(“/**”,”authc[permissive]”)

  • 在这个博客管理系统是用内置的AuthenticatingFilter
@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);
    }
}
  • 一般我们自定义过滤器就是继承AuthenticatingFilter类,然后只需要重写onAccessDenied(...)方法,当头部没有Authorization时候,我们直接通过,不需要自动登录;当带有的时候,首先我们校验jwt的有效性在其中调用executeLogin方法进行认证,然后提供创建Token的具体逻辑也就是createToken(..)方法就可以了。

  • 如果有特殊需求,比如对于Option请求直接放行,那么可以重写isAccessAllowed(...)方法,判断是否是option请求,一般来说我们还是会调用一下父类的通用判断方法:super.isAccessAllowed(...)

  • onLoginFailure:登录异常时候进入的方法,我们直接把异常信息封装然后抛出

  • preHandle:拦截器的前置拦截,因为我们是前后端分析项目,项目中除了需要跨域全局配置之外,我们再拦截器中也需要提供跨域支持。这样,拦截器才不会在进入Controller之前就被限制了。

6.全局异常处理

处理办法如下:通过使用@ControllerAdvice来进行统一异常处理,@ExceptionHandler(value = RuntimeException.class)来指定捕获的Exception各个类型异常 ,这个异常的处理,是全局的,所有类似的异常,都会跑到这个地方处理。
@ControllerAdvice表示定义全局控制器异常处理
@ExceptionHandler表示针对性异常处理,可对每种异常针对性处理。

主要捕获以下几个异常:

  • ShiroException:shiro抛出的异常,比如没有权限,用户登录异常
  • IllegalArgumentException:处理Assert的异常
  • MethodArgumentNotValidException:处理实体校验的异常
  • RuntimeException:捕捉其他异常
/**
 * 全局异常处理
 */
@Slf4j
@RestControllerAdvice
public class GlobalExcepitonHandler {
    // 捕捉shiro的异常
    @ResponseStatus(HttpStatus.UNAUTHORIZED)
    @ExceptionHandler(ShiroException.class)
    public Result handle401(ShiroException e) {
        return Result.fail(401, e.getMessage(), null);
    }
    /**
     * 处理Assert的异常
     */
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(value = IllegalArgumentException.class)
    public Result handler(IllegalArgumentException e) throws IOException {
        log.error("Assert异常:-------------->{}",e.getMessage());
        return Result.fail(e.getMessage());
    }
    /**
     * @Validated 校验错误异常处理
     */
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(value = MethodArgumentNotValidException.class)
    public Result handler(MethodArgumentNotValidException e) throws IOException {
        log.error("运行时异常:-------------->",e);
        BindingResult bindingResult = e.getBindingResult();
        ObjectError objectError = bindingResult.getAllErrors().stream().findFirst().get();
        return Result.fail(objectError.getDefaultMessage());
    }

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(value = RuntimeException.class)
    public Result handler(RuntimeException e) throws IOException {
        log.error("运行时异常:-------------->",e);
        return Result.fail(e.getMessage());
    }
}

postman测试shiro异常:
在未使用@RequiresAuthentication注解:

使用该注解标注的类,实例,方法在访问或调用时,当前Subject必须在当前session中已经过认证。就是需要认证才能访问。

Springboot+Shiro+Vue博客管理系统_第12张图片
Springboot+Shiro+Vue博客管理系统_第13张图片
使用@RequiresAuthentication注解:
Springboot+Shiro+Vue博客管理系统_第14张图片
测试成功

7.实体校验

springboot框架集成了Hibernate validatior
因此直接使用
第一步:首先在实体的属性上添加对应的校验规则,比如:

@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;
    @NotBlank(message = "邮箱不能为空")
    @Email(message = "邮箱格式不正确")
    private String email;
    ...
}

第二步 :这里我们使用@Validated注解方式,如果实体不符合要求,系统会抛出异常,那么我们的异常处理中就捕获到MethodArgumentNotValidException

/**
 * 测试实体校验
 * @param user
 * @return
 */
@PostMapping("/save")
public Object testUser(@Validated @RequestBody User user) {
    return user.toString();
}

8.跨域处理

/**
 * 解决跨域问题
 */
@Configuration
public class CorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("*")
                .allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS")
                .allowCredentials(true)
                .maxAge(3600)
                .allowedHeaders("*");
    }
}

9.登录接口

通过使用账户密码,把用户的id生成jwt,返回给前端。

@RestController
public class AccountController {
    @Autowired
    JwtUtils jwtUtils;
    @Autowired
    UserService userService;
    /**
     * 默认账号密码:Gliogz / 111111
     *
     */
    @CrossOrigin
    @PostMapping("/login")
    public Result login(@Validated @RequestBody LoginDto loginDto, HttpServletResponse response) {
        User user = userService.getOne(new QueryWrapper<User>().eq("username", loginDto.getUsername()));
        Assert.notNull(user, "用户不存在");
        if(!user.getPassword().equals(SecureUtil.md5(loginDto.getPassword()))) {
            return Result.fail("密码错误!");
        }
        String jwt = jwtUtils.generateToken(user.getId());
        response.setHeader("Authorization", jwt);
        response.setHeader("Access-Control-Expose-Headers", "Authorization");
        // 用户可以另一个接口
        return Result.succ(MapUtil.builder()
                .put("id", user.getId())
                .put("username", user.getUsername())
                .put("avatar", user.getAvatar())
                .put("email", user.getEmail())
                .map()
        );
    }
    
    // 退出
    @GetMapping("/logout")
    @RequiresAuthentication
    public Result logout() {
        SecurityUtils.getSubject().logout();
        return Result.succ(null);
    }
}

postman登录接口测试:
Springboot+Shiro+Vue博客管理系统_第15张图片
Springboot+Shiro+Vue博客管理系统_第16张图片

10.博客接口

开始业务接口,完成博客列表和博客详情页接口开发

@RestController
public class BlogController {
    @Autowired
    BlogService blogService;
    @GetMapping("/blogs")
    public Result blogs(Integer currentPage) {
        if(currentPage == null || currentPage < 1) currentPage = 1;
        Page page = new Page(currentPage, 5)
        IPage pageData = blogService.page(page, new QueryWrapper<Blog>().orderByDesc("created"));
        return Result.succ(pageData);
    }
    @GetMapping("/blog/{id}")
    public Result detail(@PathVariable(name = "id") Long id) {
        Blog blog = blogService.getById(id);
        Assert.notNull(blog, "该博客已删除!");
        return Result.succ(blog);
    }
    
    @RequiresAuthentication
@PostMapping("/blog/edit")
public Result edit(@Validated @RequestBody Blog blog) {
    System.out.println(blog.toString());
    Blog temp = null;
    if(blog.getId() != null) {
        temp = blogService.getById(blog.getId());
        Assert.isTrue(temp.getUserId() == ShiroUtil.getProfile().getId(), "没有权限编辑");
    } else {
        temp = new Blog();
        temp.setUserId(ShiroUtil.getProfile().getId());
        temp.setCreated(LocalDateTime.now());
        temp.setStatus(0);
    }
    BeanUtil.copyProperties(blog, temp, "id", "userId", "created", "status");
    blogService.saveOrUpdate(temp);
    return Result.succ("操作成功", null);
}
}

博客列表接口测试:
Springboot+Shiro+Vue博客管理系统_第17张图片
博客详情接口测试:
Springboot+Shiro+Vue博客管理系统_第18张图片
博客修改接口测试:

  • 先测试之前的实体校验用@Validated注解
    Springboot+Shiro+Vue博客管理系统_第19张图片

Springboot+Shiro+Vue博客管理系统_第20张图片

  • 测试成功内容为空会报错
  • 再测试@RequiresAuthentication注解,未认证会无权限
    Springboot+Shiro+Vue博客管理系统_第21张图片
  • 再加上请求头测试
    Springboot+Shiro+Vue博客管理系统_第22张图片
  • 测试成功
    Springboot+Shiro+Vue博客管理系统_第23张图片

Springboot+Shiro+Vue博客管理系统_第24张图片

  • 以上就是基本的后端的东西。

vue前端页面

接下来完成vueblog前端的部分功能。
用到以下技术:

  • vue
  • element-ui
  • axios
  • mavon-editor
  • markdown-it
  • github-markdown-css

项目目录结构:
Springboot+Shiro+Vue博客管理系统_第25张图片

1.页面路由

  • router\index.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import Login from '../views/Login.vue'
import BlogDetail from '../views/BlogDetail.vue'
import BlogEdit from '../views/BlogEdit.vue'
Vue.use(VueRouter)
const routes = [
  {
    path: '/',
    name: 'Index',
    redirect: { name: 'Blogs' }
  },
  {
    path: '/login',
    name: 'Login',
    component: Login
  },
  {
    path: '/blogs',
    name: 'Blogs',
    // 懒加载
    component: () => import('../views/Blogs.vue')
  },
  {
    path: '/blog/add', // 注意放在 path: '/blog/:blogId'之前
    name: 'BlogAdd',
    meta: {
      requireAuth: true
    },
    component: BlogEdit
  },
  {
    path: '/blog/:blogId',
    name: 'BlogDetail',
    component: BlogDetail
  },
  {
    path: '/blog/:blogId/edit',
    name: 'BlogEdit',
    meta: {
      requireAuth: true
    },
    component: BlogEdit
  }
];
const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes
})
export default router

2.页面登录

  • views/Login.vue
<template>
  <div>
    <el-container>
      <el-header>
        <img src="https://api.freelogodesign.org/files/d50c0f3f9bde4137a5d6708500a652a2/thumb/logo_200x200.png?v=0"
             style="height: 300%; margin-top: -60px; width:350px">
      el-header>
      <el-main>
        <el-form :model="ruleForm" :rules="rules" ref="ruleForm" label-width="100px" class="demo-ruleForm">
          <el-form-item label="用户名" prop="username">
            <el-input v-model="ruleForm.username">el-input>
          el-form-item>
          <el-form-item label="密码" prop="password">
            <el-input type="password" v-model="ruleForm.password">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.vue",
  data() {
    return {
      ruleForm: {
        username: 'Gilogz',
        password: '111111',
      },
      rules: {
        username: [
          { required: true, message: '请输入用户名', trigger: 'blur' },
          { min: 3, max: 15, message: '长度在 3 到 15 个字符', trigger: 'blur' }
        ],
        password: [
          { required: true, message: '请输入密码', trigger: 'change' }
        ],
      }
    };
  },
  methods: {
    submitForm(formName) {
      this.$refs[formName].validate((valid) => {
        if (valid) {
          const _this = this
          this.$axios.post("http://localhost:8082/login", this.ruleForm).then(res => {
            const jwt = res.headers['authorization']
            const userInfo = res.data.data

            // 把数据共享出去
            _this.$store.commit("SET_TOKEN", jwt)
            _this.$store.commit("SET_USERINFO", userInfo)

            // 获取
            console.log(_this.$store.getters.getUser)

            _this.$router.push("/blogs")
          })
        }
      });
    },
    resetForm(formName) {
      this.$refs[formName].resetFields();
    }
  }
}
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;
  }

  .demo-ruleForm{
    max-width: 500px;
    margin: 0 auto;
  }

style>

3.博客列表

  • components\Header.vue
<template>
  <div class="m-content">
    <h3>Gilogz的博客h3>
    <div class="block">
      <el-avatar :size="50" :src="user.avatar">el-avatar>
      <div>{{ user.username }}div>
    div>

    <div class="maction">
      <span><el-link href="/blogs">主页el-link>span>
      <el-divider direction="vertical">el-divider>
      <span><el-link type="success" href="/blog/add">发表博客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 {
        user: {
          username: '请先登录',
          avatar: 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png'
        },
        hasLogin: false
      }
    },
    methods: {
      logout() {
        const _this = this
        _this.$axios.get("/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>

4.博客分页

  • views\Blogs.vue
<template>
  <div>
    <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}}">
                {{blog.title}}
              router-link>
            h4>
            <p>{{blog.description}}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>

5.博客编辑(发表)

  • views\BlogEdit.vue
<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>

6.博客详情

  • views\BlogDetail.vue
<template>
  <div>
    <Header>Header>

    <div class="mblog">
      <h2> {{ blog.title }}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>

项目演示

  • 登录页面
    Springboot+Shiro+Vue博客管理系统_第26张图片
  • 主页面
    Springboot+Shiro+Vue博客管理系统_第27张图片
    Springboot+Shiro+Vue博客管理系统_第28张图片
  • 发布博客页面
    Springboot+Shiro+Vue博客管理系统_第29张图片
    Springboot+Shiro+Vue博客管理系统_第30张图片
  • 详情页面
    Springboot+Shiro+Vue博客管理系统_第31张图片

简单的一个博客管理系统完成!仍在学习中

你可能感兴趣的:(springboot,spring,java,java-ee,maven,intellij-idea)