開發SpringBoot+Jwt+Vue的先後端分離後臺管理系統VueAdmin - 後端筆記

爲了讓更多同窗學習到先後端分離管理系統的搭建過程,這裏我寫了詳細的開發過程的文檔,使用的是springsecurity + jwt + vue的技術棧組合,若是有幫助,別忘了點個贊和關注個人公衆號哈!前端

線上預覽:https://markerhub.com/vueadminvue

效果圖:java


首發公衆號:MarkerHubmysql

做者:呂一明ios

項目源碼:關注公衆號 MarkerHub 回覆【 234 】獲取nginx

線上預覽:https://markerhub.com/vueadmingit

項目視頻:https://www.bilibili.com/video/BV1af4y1s7Wh/程序員

轉載請保留此聲明,感謝!github

另外我還有另一個先後端博客項目博客VueBlog,若是有須要能夠關注公衆號MarkerHub,回覆【VueBlog】獲取哈!!web

1. 前言

從零開始搭建一個項目骨架,最好選擇合適熟悉的技術,而且在將來易拓展,適合微服務化體系等。因此通常以Springboot做爲咱們的框架基礎,這是離不開的了。

而後數據層,咱們經常使用的是Mybatis,易上手,方便維護。可是單表操做比較困難,特別是添加字段或減小字段的時候,比較繁瑣,因此這裏我推薦使用Mybatis Plus(https://mp.baomidou.com/),... CRUD 操做,從而節省大量時間。

做爲一個項目骨架,權限也是咱們不能忽略的,上一個項目vueblog咱們使用了shiro,可是有些同窗想學學SpringSecurity,因此這一期咱們使用security做爲咱們的權限控制和會話控制的框架。

考慮到項目可能須要部署多臺,一些須要共享的信息就保存在中間件中,Redis是如今主流的緩存中間件,也適合咱們的項目。

而後由於先後端分離,因此咱們使用jwt做爲咱們用戶身份憑證,而且session咱們會禁用,這樣之前傳統項目使用的方式咱們可能就再也不適合使用,這點須要注意了。

ok,咱們如今就開始搭建咱們的項目腳手架!

技術棧:

  • SpringBoot
  • mybatis plus
  • spring security
  • lombok
  • redis
  • hibernate validatior
  • jwt

2. 新建springboot項目,注意版本

這裏,咱們使用IDEA來開發咱們項目,新建步驟比較簡單,咱們就不截圖了。

開發工具與環境:

  • idea
  • mysql
  • jdk 8
  • maven3.3.9

新建好的項目結構以下,SpringBoot版本使用的目前最新的2.4.0版本

圖片

圖片

pom的jar包導入以下:

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.4.0</version>
    <relativePath/>
</parent>
<groupId>com.markerhub</groupId>
<artifactId>vueadmin-java</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>vueadmin-java</name>
<description>公衆號:MarkerHub</description>
<properties>
    <java.version>1.8</java.version>
</properties>
<dependencies>
    <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>
</dependencies>
  • devtools:項目的熱加載重啓插件
  • lombok:簡化代碼的工具

3. 整合mybatis plus,生成代碼

接下來,咱們來整合mybatis plus,讓項目能完成基本的增刪改查操做。步驟很簡單:能夠去官網看看:https://mp.baomidou.com/guide/

第一步:導入jar包

pom中導入mybatis plus的jar包,由於後面會涉及到代碼生成,因此咱們還須要導入頁面模板引擎,這裏咱們用的是freemarker。

<!--整合mybatis plus https://baomidou.com/-->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.4.1</version>
</dependency>
<!--mp代碼生成器-->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-generator</artifactId>
    <version>3.4.1</version>
</dependency>
<dependency>
    <groupId>org.freemarker</groupId>
    <artifactId>freemarker</artifactId>
    <version>2.3.30</version>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>runtime</scope>
</dependency>

第二步:而後去寫配置文件

server:
  port: 8081
# DataSource Config
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/vueadmin?useUnicode=true&useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai
    username: root
    password: admin
mybatis-plus:
  mapper-locations: classpath*:/mapper/**Mapper.xml

上面除了配置數據庫的信息,還配置了myabtis plus的mapper的xml文件的掃描路徑,這一步不要忘記了。而後由於前段默認是8080端口了,因此後端咱們設置爲8081端口,防止端口衝突。

第三步:開啓mapper接口掃描,添加分頁、防全表更新插件

新建一個包:經過@mapperScan註解指定要變成實現類的接口所在的包,而後包下面的全部接口在編譯以後都會生成相應的實現類。

  • com.markerhub.config.MybatisPlusConfig

    @Configuration
    @MapperScan("com.markerhub.mapper")
    public class MybatisPlusConfig {
     /**
      * 新的分頁插件,一緩和二緩遵循mybatis的規則,
      * 須要設置 MybatisConfiguration#useDeprecatedExecutor = false
      * 避免緩存出現問題(該屬性會在舊插件移除後一同移除)
      */
     @Bean
     public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        // 防止全表更新和刪除
        interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor());
        return interceptor;
     }
     @Bean
     public ConfigurationCustomizer configurationCustomizer() {
        return configuration -> configuration.setUseDeprecatedExecutor(false);
     }
    }

    上面代碼中,咱們給Mybatis plus添加了2個攔截器,這是根據mp官網配置的:

  • PaginationInnerInterceptor:新的分頁插件
  • BlockAttackInnerInterceptor:防止全表更新和刪除

    第四步:建立數據庫和表

由於是後臺管理系統的權限模塊,因此咱們須要考慮的表主要就幾個:用戶表、角色表、菜單權限表、以及關聯的用戶角色中間表、菜單角色中間表。就5個表,至於什麼字段其實都聽隨意的,用戶表裏面除了用戶名、密碼字段必要,其餘其實都聽隨意,而後角色和菜單咱們能夠參考一下其餘的系統、或者本身在作項目的過程當中須要的時候在添加也行,反正從新生成代碼也是很是簡便的事情,綜合考慮,數據庫名稱爲vueadmin,咱們建表語句以下:

  • vueadmin.sql

    /*
    Navicat MySQL Data Transfer
    Source Server         : localhost
    Source Server Version : 50717
    Source Host           : localhost:3306
    Source Database       : vueadmin
    Target Server Type    : MYSQL
    Target Server Version : 50717
    File Encoding         : 65001
    Date: 2021-01-23 09:41:50
    */
    SET FOREIGN_KEY_CHECKS=0;
    -- ----------------------------
    -- Table structure for sys_menu
    -- ----------------------------
    DROP TABLE IF EXISTS `sys_menu`;
    CREATE TABLE `sys_menu` (
      `id` bigint(20) NOT NULL AUTO_INCREMENT,
      `parent_id` bigint(20) DEFAULT NULL COMMENT '父菜單ID,一級菜單爲0',
      `name` varchar(64) NOT NULL,
      `path` varchar(255) DEFAULT NULL COMMENT '菜單URL',
      `perms` varchar(255) DEFAULT NULL COMMENT '受權(多個用逗號分隔,如:user:list,user:create)',
      `component` varchar(255) DEFAULT NULL,
      `type` int(5) NOT NULL COMMENT '類型     0:目錄   1:菜單   2:按鈕',
      `icon` varchar(32) DEFAULT NULL COMMENT '菜單圖標',
      `orderNum` int(11) DEFAULT NULL COMMENT '排序',
      `created` datetime NOT NULL,
      `updated` datetime DEFAULT NULL,
      `statu` int(5) NOT NULL,
      PRIMARY KEY (`id`),
      UNIQUE KEY `name` (`name`) USING BTREE
    ) ENGINE=InnoDB AUTO_INCREMENT=21 DEFAULT CHARSET=utf8;
    -- ----------------------------
    -- Table structure for sys_role
    -- ----------------------------
    DROP TABLE IF EXISTS `sys_role`;
    CREATE TABLE `sys_role` (
      `id` bigint(20) NOT NULL AUTO_INCREMENT,
      `name` varchar(64) NOT NULL,
      `code` varchar(64) NOT NULL,
      `remark` varchar(64) DEFAULT NULL COMMENT '備註',
      `created` datetime DEFAULT NULL,
      `updated` datetime DEFAULT NULL,
      `statu` int(5) NOT NULL,
      PRIMARY KEY (`id`),
      UNIQUE KEY `name` (`name`) USING BTREE,
      UNIQUE KEY `code` (`code`) USING BTREE
    ) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8;
    -- ----------------------------
    -- Table structure for sys_role_menu
    -- ----------------------------
    DROP TABLE IF EXISTS `sys_role_menu`;
    CREATE TABLE `sys_role_menu` (
      `id` bigint(20) NOT NULL AUTO_INCREMENT,
      `role_id` bigint(20) NOT NULL,
      `menu_id` bigint(20) NOT NULL,
      PRIMARY KEY (`id`)
    ) ENGINE=InnoDB AUTO_INCREMENT=102 DEFAULT CHARSET=utf8mb4;
    -- ----------------------------
    -- Table structure for sys_user
    -- ----------------------------
    DROP TABLE IF EXISTS `sys_user`;
    CREATE TABLE `sys_user` (
      `id` bigint(20) NOT NULL AUTO_INCREMENT,
      `username` varchar(64) DEFAULT NULL,
      `password` varchar(64) DEFAULT NULL,
      `avatar` varchar(255) DEFAULT NULL,
      `email` varchar(64) DEFAULT NULL,
      `city` varchar(64) DEFAULT NULL,
      `created` datetime DEFAULT NULL,
      `updated` datetime DEFAULT NULL,
      `last_login` datetime DEFAULT NULL,
      `statu` int(5) NOT NULL,
      PRIMARY KEY (`id`),
      UNIQUE KEY `UK_USERNAME` (`username`) USING BTREE
    ) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8;
    -- ----------------------------
    -- Table structure for sys_user_role
    -- ----------------------------
    DROP TABLE IF EXISTS `sys_user_role`;
    CREATE TABLE `sys_user_role` (
      `id` bigint(20) NOT NULL AUTO_INCREMENT,
      `user_id` bigint(20) NOT NULL,
      `role_id` bigint(20) NOT NULL,
      PRIMARY KEY (`id`)
    ) ENGINE=InnoDB AUTO_INCREMENT=15 DEFAULT CHARSET=utf8mb4;

第五步:代碼生成

  1. 獲取項目數據庫所對應表和字段的信息
  2. 新建一個freemarker的頁面模板 - SysUser.java.ftl - ${baseEntity}
  3. 提供相關須要進行渲染的動態數據 - BaseEntity、表字段、註釋、baseEntity=SuperEntity
  4. 使用freemarker模板引擎進行渲染! - SysUser.java

    # 獲取表
    SELECT
     *
    FROM
     information_schema. TABLES
    WHERE
     TABLE_SCHEMA = (SELECT DATABASE());
    # 獲取字段
    SELECT
     *
    FROM
     information_schema. COLUMNS
    WHERE
     TABLE_SCHEMA = (SELECT DATABASE())
    AND TABLE_NAME = "sys_user";

    有了數據庫以後,那麼如今就已經可使用mybatis plus了,官方給咱們提供了一個代碼生成器,而後我寫上本身的參數以後,就能夠直接根據數據庫表信息生成entity、service、mapper等接口和實現類。
    由於代碼比較長,就不貼出來了,說明一下重點:

  • com.markerhub.CodeGenerator

圖片

上面代碼生成的過程當中,我默認全部的實體類都繼承BaseEntity,控制器都繼承BaseController,因此在代碼生成以前,最好先編寫這兩個基類:

  • com.markerhub.entity.BaseEntity

    @Data
    public class BaseEntity implements Serializable {
     @TableId(value = "id", type = IdType.AUTO)
     private Long id;
     private LocalDateTime created;
     private LocalDateTime updated;
     private Integer statu;
    }
  • com.markerhub.controller.BaseController

    public class BaseController {
     @Autowired
     HttpServletRequest req;
    }

    而後咱們單獨運行CodeGenerator的main方法,注意調整CodeGenerator的數據庫鏈接、帳號密碼啥的,而後咱們輸入表名稱,經過逗號隔開:sys_menu,sys_role,sys_role_menu,sys_user,sys_user_role
    執行結果成功:

圖片

而後咱們生成了一些代碼以下:

圖片

這裏有點須要注意,由於關聯的用戶角色中間表、菜單角色中間表咱們是沒有created等幾個公共字段的,因此咱們把這兩個實體繼承BaseEntity去掉:

圖片

最後這樣子的:

@Data
public class SysRoleMenu {
   ...
}

簡潔!方便!通過上面的步驟,基本上咱們已經把mybatis plus框架集成到項目中了,而且也生成了基本的代碼,省了好多功夫。而後咱們作個簡單測試:

  • com.markerhub.controller.TestController

    @RestController
    public class TestController {
     @Autowired
     SysUserService userService;
     @GetMapping("/test")
     public Object test() {
        return userService.list();
     }
    }

    而後sys_user隨意添加幾條數據,結果以下:
    圖片

ok,毛什麼問題,你們不用在乎密碼是怎麼生成的,後面咱們會說到,你如今隨意填寫就行了。對了,好多人問個人瀏覽器的json數據怎麼顯示這麼好看,這是由於我用了JSONView這個插件:

圖片

4. 結果封裝

由於是先後端分離的項目,因此咱們有必要統一一個結果返回封裝類,這樣先後端交互的時候有個統一的標準,約定結果返回的數據是正常的或者遇到異常了。

這裏咱們用到了一個Result的類,這個用於咱們的異步統一返回的結果封裝。通常來講,結果裏面有幾個要素必要的

  • 是否成功,可用code表示(如200表示成功,400表示異常)
  • 結果消息
  • 結果數據

因此可獲得封裝以下:

  • com.markerhub.common.lang.Result

    @Data
    public class Result implements Serializable {
      private int code; // 200是正常,非200表示異常
      private String msg;
      private Object data;
      
      public static Result succ(Object data) {
          return succ(200, "操做成功", data);
      }
      public static Result succ(int code, String msg, Object data) {
          Result r = new Result();
          r.setCode(code);
          r.setMsg(msg);
          r.setData(data);
          return r;
      }
      public static Result fail(String msg) {
          return fail(400, 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 r = new Result();
          r.setCode(code);
          r.setMsg(msg);
          r.setData(data);
          return r;
      }
    }

    另外出了在結果封裝類上的code能夠提現數據是否正常,咱們還能夠經過http的狀態碼來提現訪問是否遇到了異常,好比401表示五權限拒絕訪問等,注意靈活使用。

    5. 全局異常處理

有時候不可避免服務器報錯的狀況,若是不配置異常處理機制,就會默認返回tomcat或者nginx的5XX頁面,對普通用戶來講,不太友好,用戶也不懂什麼狀況。這時候須要咱們程序員設計返回一個友好簡單的格式給前端。

處理辦法以下:經過使用@ControllerAdvice來進行統一異常處理,@ExceptionHandler(value = RuntimeException.class)來指定捕獲的Exception各個類型異常 ,這個異常的處理,是全局的,全部相似的異常,都會跑到這個地方處理。

步驟2、定義全局異常處理,@ControllerAdvice表示定義全局控制器異常處理,@ExceptionHandler表示針對性異常處理,可對每種異常針對性處理。

  • com.markerhub.common.exception.GlobalExceptionHandler

    /**
     * 全局異常處理
     */
    @Slf4j
    @RestControllerAdvice
    public class GlobalExceptionHandler {
      @ResponseStatus(HttpStatus.FORBIDDEN)
      @ExceptionHandler(value = AccessDeniedException.class)
      public Result handler(AccessDeniedException e) {
          log.info("security權限不足:----------------{}", e.getMessage());
          return Result.fail("權限不足");
      }
      
      @ResponseStatus(HttpStatus.BAD_REQUEST)
      @ExceptionHandler(value = MethodArgumentNotValidException.class)
      public Result handler(MethodArgumentNotValidException e) {
          log.info("實體校驗異常:----------------{}", e.getMessage());
          BindingResult bindingResult = e.getBindingResult();
          ObjectError objectError = bindingResult.getAllErrors().stream().findFirst().get();
          return Result.fail(objectError.getDefaultMessage());
      }
      
      @ResponseStatus(HttpStatus.BAD_REQUEST)
      @ExceptionHandler(value = IllegalArgumentException.class)
      public Result handler(IllegalArgumentException e) {
          log.error("Assert異常:----------------{}", e.getMessage());
          return Result.fail(e.getMessage());
      }
      
      @ResponseStatus(HttpStatus.BAD_REQUEST)
      @ExceptionHandler(value = RuntimeException.class)
      public Result handler(RuntimeException e) {
          log.error("運行時異常:----------------{}", e);
          return Result.fail(e.getMessage());
      }
    }

    上面咱們捕捉了幾個異常:

  • ShiroException:shiro拋出的異常,好比沒有權限,用戶登陸異常
  • IllegalArgumentException:處理Assert的異常
  • MethodArgumentNotValidException:處理實體校驗的異常
  • RuntimeException:捕捉其餘異常

6. 整合Spring Security

不少人不懂spring security,以爲這個框架比shiro要難,的確,security更加複雜一點,同時功能也更增強大,咱們首先來看一下security的原理,這裏咱們引用一張來自江南一點雨大佬畫的一張原理圖(https://blog.csdn.net/u012702547/article/details/89629415):

圖片

(引自江南一點雨的博客)

上面這張圖必定要好好看,特別清晰,畢竟security是責任鏈的設計模式,是一堆過濾器鏈的組合,若是對於這個流程都不清楚,那麼你就談不上理解security。那麼針對咱們如今的這個系統,咱們能夠本身設計一個security的認證方案,結合江南一點雨大佬的博客,咱們獲得這樣一套流程:

https://www.processon.com/view/link/606b0b5307912932d09adcb3

圖片

流程說明:

  1. 客戶端發起一個請求,進入 Security 過濾器鏈。
  2. 當到 LogoutFilter 的時候判斷是不是登出路徑,若是是登出路徑則到 logoutHandler ,若是登出成功則到 logoutSuccessHandler 登出成功處理。若是不是登出路徑則直接進入下一個過濾器。
  3. 當到 UsernamePasswordAuthenticationFilter 的時候判斷是否爲登陸路徑,若是是,則進入該過濾器進行登陸操做,若是登陸失敗則到 AuthenticationFailureHandler ,登陸失敗處理器處理,若是登陸成功則到 AuthenticationSuccessHandler 登陸成功處理器處理,若是不是登陸請求則不進入該過濾器。
  4. 進入認證BasicAuthenticationFilter進行用戶認證,成功的話會把認證了的結果寫入到SecurityContextHolder中SecurityContext的屬性authentication上面。若是認證失敗就會交給AuthenticationEntryPoint認證失敗處理類,或者拋出異常被後續ExceptionTranslationFilter過濾器處理異常,若是是AuthenticationException就交給AuthenticationEntryPoint處理,若是是AccessDeniedException異常則交給AccessDeniedHandler處理。
  5. 當到 FilterSecurityInterceptor 的時候會拿到 uri ,根據 uri 去找對應的鑑權管理器,鑑權管理器作鑑權工做,鑑權成功則到 Controller 層,不然到 AccessDeniedHandler 鑑權失敗處理器處理。

Spring Security 實戰乾貨:必須掌握的一些內置 Filter:https://blog.csdn.net/qq_35067322/article/details/102690579

ok,上面咱們說的流程中涉及到幾個組件,有些是咱們須要根據實際狀況來重寫的。由於咱們是使用json數據進行先後端數據交互,而且咱們返回結果也是特定封裝的。咱們先再總結一下咱們須要瞭解的幾個組件:

  • LogoutFilter - 登出過濾器
  • logoutSuccessHandler - 登出成功以後的操做類
  • UsernamePasswordAuthenticationFilter - from提交用戶名密碼登陸認證過濾器
  • AuthenticationFailureHandler - 登陸失敗操做類
  • AuthenticationSuccessHandler - 登陸成功操做類
  • BasicAuthenticationFilter - Basic身份認證過濾器
  • SecurityContextHolder - 安全上下文靜態工具類
  • AuthenticationEntryPoint - 認證失敗入口
  • ExceptionTranslationFilter - 異常處理過濾器
  • AccessDeniedHandler - 權限不足操做類
  • FilterSecurityInterceptor - 權限判斷攔截器、出口

有了上面的組件,那麼認證與受權兩個問題咱們就已經接近啦,咱們如今須要作的就是去重寫咱們的一些關鍵類。

引入Security與jwt

首先咱們導入security包,由於咱們先後端交互用戶憑證用的是JWT,因此咱們也導入jwt的相關包,而後由於驗證碼的存儲須要用到redis,因此引入redis。最後爲了一些工具類,咱們引入hutool。

  • pom.xml

    <!-- springboot security -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <!-- jwt -->
    <dependency>
      <groupId>io.jsonwebtoken</groupId>
      <artifactId>jjwt</artifactId>
      <version>0.9.1</version>
    </dependency>
    <dependency>
      <groupId>com.github.axet</groupId>
      <artifactId>kaptcha</artifactId>
      <version>0.0.9</version>
    </dependency>
    <!-- hutool工具類-->
    <dependency>
      <groupId>cn.hutool</groupId>
      <artifactId>hutool-all</artifactId>
      <version>5.3.3</version>
    </dependency>
    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-lang3</artifactId>
      <version>3.11</version>
    </dependency>

    啓動redis,而後咱們再啓動項目,這時候咱們再去訪問http://localhost:8081/test,會發現系統會先判斷到你未登陸跳轉到http://localhost:8081/login,由於security內置了登陸頁,用戶名爲user,密碼在啓動項目的時候打印在了控制檯。登陸完成以後咱們才能夠正常訪問接口。
    由於每次啓動密碼都會改變,因此咱們經過配置文件來配置一下默認的用戶名和密碼:

  • application.yml

    spring:
    security:
      user:
        name: user
        password: 111111

    用戶認證

首先咱們來解決用戶認證問題,分爲首次登錄,和二次認證。

  • 首次登陸認證:用戶名、密碼和驗證碼完成登陸
  • 二次token認證:請求頭攜帶Jwt進行身份認證

使用用戶名密碼來登陸的,而後咱們還想添加圖片驗證碼,那麼security給咱們提供的UsernamePasswordAuthenticationFilter能使用嗎?

首先security的全部過濾器都是沒有圖片驗證碼這回事的,看起來不適用了。其實這裏咱們能夠靈活點,若是你依然想沿用自帶的UsernamePasswordAuthenticationFilter,那麼咱們就在這過濾器以前添加一個圖片驗證碼過濾器。固然了咱們也能夠經過自定義過濾器繼承UsernamePasswordAuthenticationFilter,而後本身把驗證碼驗證邏輯和認證邏輯寫在一塊兒,這也是一種解決方式。

咱們此次解決方式是在UsernamePasswordAuthenticationFilter以前自定義一個圖片過濾器CaptchaFilter,提早校驗驗證碼是否正確,這樣咱們就可使用UsernamePasswordAuthenticationFilter了,而後登陸正常或失敗咱們均可以經過對應的Handler來返回咱們特定格式的封裝結果數據。

生成驗證碼

首先咱們先生成驗證碼,以前咱們已經引用了google的驗證碼生成器,咱們先來配置一下圖片驗證碼的生成規則:

  • com.markerhub.config.KaptchaConfig

    @Configuration
    public class KaptchaConfig {
     @Bean
     public DefaultKaptcha producer() {
        Properties properties = new Properties();
        properties.put("kaptcha.border", "no");
        properties.put("kaptcha.textproducer.font.color", "black");
        properties.put("kaptcha.textproducer.char.space", "4");
        properties.put("kaptcha.image.height", "40");
        properties.put("kaptcha.image.width", "120");
        properties.put("kaptcha.textproducer.font.size", "30");
        Config config = new Config(properties);
        DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
        defaultKaptcha.setConfig(config);
        return defaultKaptcha;
     }
    }

    上面我定義了圖片驗證碼的長寬字體顏色等,本身能夠調整哈。
    而後咱們經過控制器提供生成驗證碼的方法:

  • com.markerhub.controller.AuthController

    @Slf4j
    @RestController
    public class AuthController extends BaseController{
     @Autowired
     private Producer producer;
     /**
      * 圖片驗證碼
      */
     @GetMapping("/captcha")
     public Result captcha(HttpServletRequest request, HttpServletResponse response) throws IOException {
        String code = producer.createText();
        String key = UUID.randomUUID().toString();
        BufferedImage image = producer.createImage(code);
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        ImageIO.write(image, "jpg", outputStream);
        BASE64Encoder encoder = new BASE64Encoder();
        String str = "data:image/jpeg;base64,";
        String base64Img = str + encoder.encode(outputStream.toByteArray());
        
        // 存儲到redis中
        redisUtil.hset(Const.captcha_KEY, key, code, 120);
        log.info("驗證碼 -- {} - {}", key, code);
        return Result.succ(
              MapUtil.builder()
              .put("token", key)
              .put("base64Img", base64Img)
              .build()
        );
     }
    }

    由於先後端分離,咱們禁用了session,因此咱們把驗證碼放在了redis中,使用一個隨機字符串做爲key,並傳送到前端,前端再把隨機字符串和用戶輸入的驗證碼提交上來,這樣咱們就能夠經過隨機字符串獲取到保存的驗證碼和用戶的驗證碼進行比較了是否正確了。
    而後由於圖片驗證碼的方式,因此咱們進行了encode,把圖片進行了base64編碼,這樣前端就能夠顯示圖片了。

而前端的處理,咱們以前是使用了mockjs進行隨機生成數據的,如今後端有接口以後,咱們只須要在main.js中去掉mockjs的引入便可,這樣前端就能夠訪問後端的接口而不被mock攔截了。

驗證碼認證過濾器

圖片驗證碼進行認證驗證碼是否正確。

  • CaptchaFilter

    /**
     * 圖片驗證碼校驗過濾器,在登陸過濾器前
     */
    @Slf4j
    @Component
    public class CaptchaFilter extends OncePerRequestFilter {
     private final String loginUrl = "/login";
     @Autowired
     RedisUtil redisUtil;
     @Autowired
     LoginFailureHandler loginFailureHandler;
     @Override
     protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
           throws ServletException, IOException {
        String url = request.getRequestURI();
        if (loginUrl.equals(url) && request.getMethod().equals("POST")) {
           log.info("獲取到login連接,正在校驗驗證碼 -- " + url);
           try {
              validate(request);
           } catch (CaptchaException e) {
              log.info(e.getMessage());
              // 交給登陸失敗處理器處理
              loginFailureHandler.onAuthenticationFailure(request, response, e);
           }
        }
        filterChain.doFilter(request, response);
     }
     private void validate(HttpServletRequest request) {
        String code = request.getParameter("code");
        String token = request.getParameter("token");
        if (StringUtils.isBlank(code) || StringUtils.isBlank(token)) {
           throw new CaptchaException("驗證碼不能爲空");
        }
        if(!code.equals(redisUtil.hget(Const.captcha_KEY, token))) {
           throw new CaptchaException("驗證碼不正確");
        }
        // 一次性使用
        redisUtil.hdel(Const.captcha_KEY, token);
     }
    }

    上面代碼中,由於驗證碼須要存儲,因此添加了RedisUtil工具類,這個工具類代碼咱們就不貼出來了。

  • com.markerhub.util.RedisUtil

而後驗證碼出錯的時候咱們返回異常信息,這是一個認證異常,因此咱們自定了一個CaptchaException:

  • com.javacat.common.exception.CaptchaException

    public class CaptchaException extends AuthenticationException {
     public CaptchaException(String msg) {
        super(msg);
     }
    }
  • com.markerhub.common.lang.Const

    public class Const {
     public static final String captcha_KEY = "captcha";
    }

    而後認證失敗的話,咱們以前說過,登陸失敗的時候交給AuthenticationFailureHandler,因此咱們自定義了LoginFailureHandler

  • com.markerhub.security.LoginFailureHandler

    @Component
    public class LoginFailureHandler implements AuthenticationFailureHandler {
     @Override
     public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        response.setContentType("application/json;charset=UTF-8");
        ServletOutputStream outputStream = response.getOutputStream();
        Result result = Result.fail(
              "Bad credentials".equals(exception.getMessage()) ? "用戶名或密碼不正確" : exception.getMessage()
        );
        outputStream.write(JSONUtil.toJsonStr(result).getBytes("UTF-8"));
        outputStream.flush();
        outputStream.close();
     }
    }

    其實主要就是獲取異常的消息,而後封裝到Result,最後轉成json返回給前端而已哈。
    而後咱們配置SecurityConfig

  • com.markerhub.config.SecurityConfig

    @Configuration
    @EnableGlobalMethodSecurity(prePostEnabled = true)
    @EnableWebSecurity
    public class SecurityConfig extends WebSecurityConfigurerAdapter {
     @Autowired
     LoginFailureHandler loginFailureHandler;
     
     @Autowired
     CaptchaFilter captchaFilter;
     
     public static final String[] URL_WHITELIST = {
           "/webjars/**",
           "/favicon.ico",
           
    "/captcha",
           "/login",
           "/logout",
     };
     
     @Override
     protected void configure(HttpSecurity http) throws Exception {
        http.cors().and().csrf().disable()
              .formLogin()
              .failureHandler(loginFailureHandler)
              
              .and()
              .authorizeRequests()
              .antMatchers(URL_WHITELIST).permitAll() //白名單
              .anyRequest().authenticated()
              // 不會建立 session
              .and()
              .sessionManagement()
              .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
              
              .and()
              .addFilterBefore(captchaFilter, UsernamePasswordAuthenticationFilter.class) // 登陸驗證碼校驗過濾器
        ;
     }
    }

    首先formLogin咱們定義了表單登陸提交的方式以及定義了登陸失敗的處理器,後面咱們還要定義登陸成功的處理器的。而後authorizeRequests咱們除了白名單的連接以外其餘請求都會被攔截。再而後就是禁用session,最後是設定驗證碼過濾器在登陸過濾器以前。
    而後咱們打開前端的/login,發現出現了跨域的問題,後面我處理,咱們先用postman調試接口。

圖片

能夠看到,咱們的隨機碼token和base64Img編碼都是正常的。控制檯上看到咱們的驗證是2yyxm:

圖片

而後咱們嘗試登陸,由於以前咱們已經設置了用戶名密碼爲user/111111,因此咱們提交表單的時候再帶上咱們的token和驗證碼。

這時候咱們就能夠去提交表單了嗎,其實還不能夠,爲啥?由於就算咱們登陸成功,security默認跳轉到/連接,可是又會由於沒有權限訪問/,全部又會教你去登陸,因此咱們必須取消原先默認的登陸成功以後的操做,根據咱們以前分析的流程,登陸成功以後會走AuthenticationSuccessHandler,所以在登陸以前,咱們先去自定義這個登陸成功操做類:

  • com.markerhub.security.LoginSuccessHandler

    @Component
    public class LoginSuccessHandler implements AuthenticationSuccessHandler {
     @Autowired
     JwtUtils jwtUtils;
     
     @Override
     public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        response.setContentType("application/json;charset=UTF-8");
        ServletOutputStream outputStream = response.getOutputStream();
        
        // 生成jwt返回
        String jwt = jwtUtils.generateToken(authentication.getName());
        response.setHeader(jwtUtils.getHeader(), jwt);
        
        Result result = Result.succ("");
        outputStream.write(JSONUtil.toJsonStr(result).getBytes("UTF-8"));
        outputStream.flush();
        outputStream.close();
     }
    }

    登陸成功以後咱們利用用戶名生成jwt,jwtUtils這個工具類我就不貼代碼了哈,去看咱們項目源碼,而後把jwt做爲請求頭返回回去,名稱就叫Authorization哈。咱們須要在配置文件中配置一些jwt的一些密鑰信息:

  • application.yml

    markerhub:
    jwt:
      # 加密祕鑰
      secret: f4e2e52034348f86b67cde581c0f9eb5
      # token有效時長,7天,單位秒
      expire: 604800
      header: Authorization

    而後咱們再security配置中添加上登陸成功以後的操做類:

  • com.markerhub.config.SecurityConfig

    @Autowired
    LoginSuccessHandler loginSuccessHandler;
    ...
    # configure代碼:
    http.cors().and().csrf().disable()
        .formLogin()
        .failureHandler(loginFailureHandler)
        .successHandler(loginSuccessHandler)

    而後咱們去postman的進行咱們的登陸測試:
    圖片

上面咱們能夠看到,咱們已經能夠登陸成功了。而後去結果的請求頭中查看jwt:

圖片

搞定,登陸成功啦,驗證碼也正常驗證了。

身份認證 - 1

登陸成功以後前端就能夠獲取到了jwt的信息,前端中咱們是保存在了store中,同時也保存在了localStorage中,而後每次axios請求以前,咱們都會添加上咱們的請求頭信息,能夠回顧一下:

  • 前端項目的axios.js

圖片

因此後端進行用戶身份識別的時候,咱們須要經過請求頭中獲取jwt,而後解析出咱們的用戶名,這樣咱們就能夠知道是誰在訪問咱們的接口啦,而後判斷用戶是否有權限等操做。

那麼咱們自定義一個過濾器用來進行識別jwt。

  • JWTAuthenticationFilter

    @Slf4j
    public class JWTAuthenticationFilter extends BasicAuthenticationFilter {
     @Autowired
     JwtUtils jwtUtils;
     @Autowired
     RedisUtil redisUtil;
     @Autowired
     SysUserService sysUserService;
     public JWTAuthenticationFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);
     }
     @Override
     protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        log.info("jwt 校驗 filter");
        String jwt = request.getHeader(jwtUtils.getHeader());
        if (StrUtil.isBlankOrUndefined(jwt)) {
           chain.doFilter(request, response);
           return;
        }
        Claims claim = jwtUtils.getClaimByToken(jwt);
        if (claim == null) {
           throw new JwtException("token異常!");
        }
        if (jwtUtils.isTokenExpired(claim.getExpiration())) {
           throw new JwtException("token已過時");
        }
        String username = claim.getSubject();
        log.info("用戶-{},正在登錄!", username);
        UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken
              = new UsernamePasswordAuthenticationToken(username, null, new TreeSet<>());
        SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
        chain.doFilter(request, response);
     }
    }

    上面的邏輯也很簡單,正如我前面說到的,獲取到用戶名以後咱們直接把封裝成UsernamePasswordAuthenticationToken,以後交給SecurityContextHolder參數傳遞authentication對象,這樣後續security就能獲取到當前登陸的用戶信息了,也就完成了用戶認證。
    當認證失敗的時候會進入AuthenticationEntryPoint,因而咱們自定義認證失敗返回的數據:

  • com.markerhub.security.JwtAuthenticationEntryPoint

    /**
     * 定義認證失敗處理類
     */
    @Slf4j
    @Component
    public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
     @Override
     public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
           throws IOException {
        log.info("認證失敗!未登陸!");
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        ServletOutputStream outputStream = response.getOutputStream();
        
        Result result = Result.fail("請先登陸!");
        outputStream.write(JSONUtil.toJsonStr(result).getBytes("UTF-8"));
        outputStream.flush();
        outputStream.close();
     }
    }

    不過是啥緣由,認證失敗,咱們就要求從新登陸,因此返回的信息直接明瞭「請先登陸!」哈哈。
    而後咱們把認證過濾器和認證失敗入口配置到SecurityConfig中:

  • com.markerhub.config.SecurityConfig

    @Bean
    JWTAuthenticationFilter jwtAuthenticationFilter() throws Exception {
     JWTAuthenticationFilter filter = new JWTAuthenticationFilter(authenticationManager());
     return filter;
    }
    .and()
    .exceptionHandling()
    .authenticationEntryPoint(jwtAuthenticationEntryPoint)
    .and()
    .addFilter(jwtAuthenticationFilter())
    .addFilterBefore(captchaFilter, UsernamePasswordAuthenticationFilter.class) // 登陸驗證碼校驗過濾器

    這樣攜帶jwt請求頭咱們就能夠正常訪問咱們的接口了。

    身份認證 - 2

以前咱們的用戶名密碼配置在配置文件中的,並且密碼也用的是明文,這明顯不符合咱們的要求,咱們的用戶必須是存儲在數據庫中,密碼也是得通過加密的。因此咱們先來解決這個問題,而後再去弄受權。

圖片

首先來插入一條用戶數據,但這裏有個問題,就是咱們的密碼怎麼生成?密碼怎麼來的?這裏咱們使用Security內置了的BCryptPasswordEncoder,裏面就有生成和匹配密碼是否正確的方法,也就是加密和驗證策略。所以咱們再SecurityConfig中進行配置:

  • com.markerhub.config.SecurityConfig

    @Bean
    BCryptPasswordEncoder bCryptPasswordEncoder() {
     return new BCryptPasswordEncoder();
    }

    這樣系統就會使用咱們找個新的密碼策略進行匹配密碼是否正常了。以前咱們配置文件配置的用戶名密碼去掉:

  • application.yml

    #  security:
    #    user:
    #      name: user
    #      password: 111111

    ok,咱們先使用BCryptPasswordEncoder給咱們生成一個密碼,給數據庫添加一條數據先,咱們再TestController中注入BCryptPasswordEncoder,而後使用encode進行密碼加密,對了,記得在SecurityConfig中吧/test/**添加白名單哈,否則訪問會提示你登陸!!

  • com.markerhub.controller.TestController

    @Autowired
    BCryptPasswordEncoder bCryptPasswordEncoder;
    @GetMapping("/test/pass")
    public Result passEncode() {
     // 密碼加密
     String pass = bCryptPasswordEncoder.encode("111111");
     
     // 密碼驗證
     boolean matches = bCryptPasswordEncoder.matches("111111", pass);
     
     return Result.succ(MapUtil.builder()
           .put("pass", pass)
           .put("marches", matches)
           .build()
     );
    }

    能夠看到我密碼是111111,加密以及驗證的結果以下:$2a$10$R7zegeWzOXPw871CmNuJ6upC0v8D373GuLuTw8jn6NET4BkPRZfgK
    圖片

data中的那一串字符串就是咱們的密碼了,能夠看到marches也是true,說明密碼驗證也是正確的,咱們添加到咱們數據庫sys_user表中:

INSERT INTO `vueadmin`.`sys_user` (`id`, `username`, `password`, `avatar`, `email`, `city`, `created`, `updated`, `last_login`, `statu`) VALUES ('1', 'admin', '$2a$10$R7zegeWzOXPw871CmNuJ6upC0v8D373GuLuTw8jn6NET4BkPRZfgK', 'https://image-1300566513.cos.ap-guangzhou.myqcloud.com/upload/images/5a9f48118166308daba8b6da7e466aab.jpg', '123@qq.com', '廣州', '2021-01-12 22:13:53', '2021-01-16 16:57:32', '2020-12-30 08:38:37', '1');

後面咱們就可使用admin/111111登陸咱們的系統哈。
可是先咱們登陸過程系統不是從咱們數據庫中獲取數據的,所以,咱們須要從新定義這個查用戶數據的過程,咱們須要重寫UserDetailsService接口。

  • com.markerhub.security.UserDetailsServiceImpl

    @Slf4j
    @Service
    public class UserDetailsServiceImpl implements UserDetailsService {
     @Autowired
     SysUserService sysUserService;
     
     @Override
     public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        SysUser sysUser = sysUserService.getByUsername(username);
        if (sysUser == null) {
           throw new UsernameNotFoundException("用戶名或密碼不正確!");
        }
        return new AccountUser(sysUser.getId(), sysUser.getUsername(), sysUser.getPassword(), new TreeSet<>());
     }
    }

    由於security在認證用戶身份的時候會調用UserDetailsService.loadUserByUsername()方法,所以咱們重寫了以後security就能夠根據咱們的流程去查庫獲取用戶了。而後咱們把UserDetailsServiceImpl配置到SecurityConfig中:

  • com.markerhub.config.SecurityConfig

    @Autowired
    UserDetailsServiceImpl userDetailsService;
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
     auth.userDetailsService(userDetailsService);
    }

    而後上面UserDetailsService.loadUserByUsername()默認返回的UserDetails,咱們自定義了AccountUser去重寫了UserDetails,這也是爲了後面咱們可能會調整用戶的一些數據等。

  • com.markerhub.security.AccountUser

    public class AccountUser implements UserDetails {
     private Long userId;
     private String password;
     private final String username;
     private final Collection<? extends GrantedAuthority> authorities;
     private final boolean accountNonExpired;
     private final boolean accountNonLocked;
     private final boolean credentialsNonExpired;
     private final boolean enabled;
     public AccountUser(Long userId, String username, String password, Collection<? extends GrantedAuthority> authorities) {
        this(userId, username, password, true, true, true, true, authorities);
     }
     public AccountUser(Long userId, String username, String password, boolean enabled,
                        boolean accountNonExpired, boolean credentialsNonExpired, boolean accountNonLocked,
                        Collection<? extends GrantedAuthority> authorities) {
        Assert.isTrue(username != null && !"".equals(username) && password != null, "Cannot pass null or empty values to constructor");
        this.userId = userId;
        this.username = username;
        this.password = password;
        this.enabled = enabled;
        this.accountNonExpired = accountNonExpired;
        this.credentialsNonExpired = credentialsNonExpired;
        this.accountNonLocked = accountNonLocked;
        this.authorities = authorities;
     }
     public Long getUserId() {
        return userId;
     }
     ...  
    }

    其實數據基本沒變,我就添加多了一個用戶的id而已。
    ok,萬事俱備,咱們再次嘗試去登陸,看能不能登陸成功。

一、獲取驗證碼:

圖片

二、從控制檯獲取到對應的驗證碼

圖片

三、提交登陸表單

圖片

四、登陸成功,並在請求頭中獲取到了Authorization,也就是JWT。完美!!

解決受權

而後關於權限部分,也是security的重要功能,當用戶認證成功以後,咱們就知道誰在訪問系統接口,這是又有一個問題,就是這個用戶有沒有權限來訪問咱們這個接口呢,要解決這個問題,咱們須要知道用戶有哪些權限,哪些角色,這樣security才能咱們作權限判斷。

以前咱們已經定義及幾張表,用戶、角色、菜單、以及一些關聯表,通常當權限粒度比較細的時候,咱們都經過判斷用戶有沒有此菜單或操做的權限,而不是經過角色判斷,而用戶和菜單是不直接作關聯的,是經過用戶擁有哪些角色,而後角色擁有哪些菜單權限這樣來得到的。

問題1:咱們是在哪裏賦予用戶權限的?有兩個地方:

  • 一、用戶登陸,調用調用UserDetailsService.loadUserByUsername()方法時候能夠返回用戶的權限信息。
  • 二、接口調用進行身份認證過濾器時候JWTAuthenticationFilter,須要返回用戶權限信息

問題2:在哪裏決定什麼接口須要什麼權限?

Security內置的權限註解:

  • @PreAuthorize:方法執行前進行權限檢查
  • @PostAuthorize:方法執行後進行權限檢查
  • @Secured:相似於 @PreAuthorize

能夠在Controller的方法前添加這些註解表示接口須要什麼權限。

好比須要Admin角色權限:

@PreAuthorize("hasRole('admin')")

好比須要添加管理員的操做權限

@PreAuthorize("hasAuthority('sys:user:save')")

ok,咱們再來總體梳理一下受權、驗證權限的流程:

  1. 用戶登陸或者調用接口時候識別到用戶,並獲取到用戶的權限信息
  2. 註解標識Controller中的方法須要的權限或角色
  3. Security經過FilterSecurityInterceptor匹配URI和權限是否匹配
  4. 有權限則能夠訪問接口,當無權限的時候返回異常交給AccessDeniedHandler操做類處理

ok,流程清晰以後咱們就開始咱們的編碼:

  • UserDetailsServiceImpl

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
     ...   
     return new AccountUser(sysUser.getId(), sysUser.getUsername(), sysUser.getPassword(), getUserAuthority(sysUser.getId()));
    }
    public List<GrantedAuthority> getUserAuthority(Long userId) {
     // 經過內置的工具類,把權限字符串封裝成GrantedAuthority列表
     return  AuthorityUtils.commaSeparatedStringToAuthorityList(
           sysUserService.getUserAuthorityInfo(userId)
     );
    }
  • com.markerhub.security.JWTAuthenticationFilter

    SysUser sysUser = sysUserService.getByUsername(username);
    List<GrantedAuthority> grantedAuthorities = userDetailsService.getUserAuthority(sysUser.getId());
    UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken
        = new UsernamePasswordAuthenticationToken(username, null, grantedAuthorities);

    代碼中的com.markerhub.service.impl.SysUserServiceImpl#getUserAuthorityInfo是重點:

    @Slf4j
    @Service
    public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> implements SysUserService {
     ... 
     @Override
     public String getUserAuthorityInfo(Long userId) {
        SysUser sysUser = this.getById(userId);
        String authority = null;
        
        if (redisUtil.hasKey("GrantedAuthority:" + sysUser.getUsername())) {
           // 優先從緩存獲取
           authority = (String)redisUtil.get("GrantedAuthority:" + sysUser.getUsername());
           
        } else {
        
           List<SysRole> roles = sysRoleService.list(new QueryWrapper<SysRole>()
                 .inSql("id", "select role_id from sys_user_role where user_id = " + userId));
           List<Long> menuIds = sysUserMapper.getNavMenuIds(userId);
           List<SysMenu> menus = sysMenuService.listByIds(menuIds);
           
           String roleNames = roles.stream().map(r -> "ROLE_" + r.getCode()).collect(Collectors.joining(","));
           String permNames = menus.stream().map(m -> m.getPerms()).collect(Collectors.joining(","));
           
           authority = roleNames.concat(",").concat(permNames);
           log.info("用戶ID - {} ---擁有的權限:{}", userId, authority);
           
           redisUtil.set("GrantedAuthority:" + sysUser.getUsername(), authority, 60*60);
           
        }
        return authority;
     }
    }

    能夠看到,我經過用戶id分別獲取到用戶的角色信息和菜單信息,而後經過逗號連接起來,由於角色信息咱們須要這樣「ROLE_」+角色,因此纔有了上面的寫法:
    好比用戶擁有Admin角色和添加用戶權限,則最後的字符串是:ROLE_admin,sys:user:save

同時爲了不屢次查庫,我作了一層緩存,這裏理解應該不難。

而後sysUserMapper.getNavMenuIds(userId)由於要查詢數據庫,具體SQL以下:

  • com.markerhub.mapper.SysUserMapper#getNavMenuIds

    <select id="getNavMenuIds" resultType="java.lang.Long">
      SELECT
          DISTINCT rm.menu_id
      FROM
          sys_user_role ur
      LEFT JOIN `sys_role_menu` rm ON rm.role_id = ur.role_id
      WHERE
          ur.user_id = #{userId};
    </select>

    上面表示經過用戶ID獲取用戶關聯的菜單的id,所以須要用到兩個中間表的關聯了。
    ok,這樣咱們就賦予了用戶角色和操做權限了。後面咱們只須要在Controller添加上具體註解表示須要的權限,Security就會自動幫咱們自動完成權限校驗了。

權限緩存

由於上面我在獲取用戶權限那裏添加了個緩存,這時候問題來了,就是權限緩存的實時更新問題,好比當後臺更新某個管理員的權限角色信息的時候若是權限緩存信息沒有實時更新,就會出現操做無效的問題,那麼咱們如今點定義幾個方法,用於清除某個用戶或角色或者某個菜單的權限的方法:

  • com.markerhub.service.impl.SysUserServiceImpl

    // 刪除某個用戶的權限信息
    @Override
    public void clearUserAuthorityInfo(String username) {
     redisUtil.del("GrantedAuthority:" + username);
    }
    // 刪除全部與該角色關聯的用戶的權限信息
    @Override
    public void clearUserAuthorityInfoByRoleId(Long roleId) {
     List<SysUser> sysUsers = this.list(new QueryWrapper<SysUser>()
           .inSql("id", "select user_id from sys_user_role where role_id = " + roleId)
     );
     sysUsers.forEach(u -> {
        this.clearUserAuthorityInfo(u.getUsername());
     });
    }
    // 刪除全部與該菜單關聯的全部用戶的權限信息
    @Override
    public void clearUserAuthorityInfoByMenuId(Long menuId) {
     List<SysUser> sysUsers = sysUserMapper.listByMenuId(menuId);
     sysUsers.forEach(u -> {
        this.clearUserAuthorityInfo(u.getUsername());
     });
    }

    上面最後一個方法查到了與菜單關聯的全部用戶的,具體sql以下:

  • com.markerhub.mapper.SysUserMapper#listByMenuId

    <select id="listByMenuId" resultType="com.javacat.entity.SysUser">
      SELECT
      DISTINCT
          su.*
      FROM
          sys_user_role ur
      LEFT JOIN `sys_role_menu` rm ON rm.role_id = ur.role_id
      LEFT JOIN `sys_user` su ON su.id = ur.user_id
      WHERE
          rm.menu_id = #{menuId};
    </select>

    有了這幾個方法以後,在哪裏調用?這就簡單了,在更新、刪除角色權限、更新、刪除菜單的時候調用,雖然咱們如今還沒寫到這幾個方法,後續咱們再寫增刪改查的時候記得加上就行啦。

    退出數據返回

jwt -username

token - 隨機碼 - redis

  • com.markerhub.security.JwtLogoutSuccessHandler

    @Component
    public class JwtLogoutSuccessHandler implements LogoutSuccessHandler {
     @Autowired
     JwtUtils jwtUtils;
     @Override
     public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
           throws IOException, ServletException {
        if (authentication != null) {
           new SecurityContextLogoutHandler().logout(request, response, authentication);
        }
        response.setContentType("application/json;charset=UTF-8");
        response.setHeader(jwtUtils.getHeader(), "");
        ServletOutputStream out = response.getOutputStream();
        Result result = Result.succ("");
        out.write(JSONUtil.toJsonStr(result).getBytes("UTF-8"));
        out.flush();
        out.close();
     }

無權限數據返回

  • com.markerhub.security.JwtAccessDeniedHandler

    @Slf4j
    @Component
    public class JwtAccessDeniedHandler implements AccessDeniedHandler {
     @Override
     public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException)
           throws IOException, ServletException {
    //    response.sendError(HttpServletResponse.SC_FORBIDDEN, accessDeniedException.getMessage());
        log.info("權限不夠!!");
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        ServletOutputStream outputStream = response.getOutputStream();
        Result result = Result.fail(accessDeniedException.getMessage());
        outputStream.write(JSONUtil.toJsonStr(result).getBytes("UTF-8"));
        outputStream.flush();
        outputStream.close();
     }
    }

    致此,SpringSecurity就已經完美整合到了咱們的項目中來了。

7. 解決跨域問題

上面的調試咱們都是使用的postman,若是咱們和前端進行對接的時候,會出現跨域的問題,如何解決?

  • com.markerhub.config.CorsConfig

    @Configuration
    public class CorsConfig implements WebMvcConfigurer {
     private CorsConfiguration buildConfig() {
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        corsConfiguration.addAllowedOrigin("*");
        corsConfiguration.addAllowedHeader("*");
        corsConfiguration.addAllowedMethod("*");
        corsConfiguration.addExposedHeader("Authorization");
        return corsConfiguration;
     }
     
     @Bean
     public CorsFilter corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", buildConfig());
        return new CorsFilter(source);
     }
     
     @Override
     public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
              .allowedOrigins("*")
    //          .allowCredentials(true)
              .allowedMethods("GET", "POST", "DELETE", "PUT")
              .maxAge(3600);
     }
    }

    8. 先後端對接的問題

由於咱們以前開發前端的時候,咱們都是使用mockjs返回隨機數據的,通常來講問題不會很大,我就怕有些同窗再去掉mock以後,和後端對接卻顯示不出數據,這就尷尬了。這時候我建議你去看個人開發視頻哈。

後面由於都是接口的增刪改查,難度其實不是特別大,因此大部分時候我都會直接貼代碼,若是想看手把手教程,仍是去看個人教學視頻哈,B站搜索MarkerHub就能夠啦,公衆號也是叫MarkerHub。

9. 菜單接口開發

咱們先來開發菜單的接口,由於這3個表:用戶表、角色表、菜單表,纔有菜單表是不須要經過其餘表來獲取信息的。好比用戶須要關聯角色,角色須要關聯菜單,而菜單不須要主動關聯其餘表。所以菜單表的增刪改查是最簡單的。

再回到咱們的前端項目,登陸完成以後咱們經過JWT獲取項目的導航菜單和權限,那麼接下來咱們就先編寫這個接口。

獲取菜單導航和權限的連接是/sys/menu/nav,而後咱們的菜單導航的json數據應該是這樣的:

{
   title: '角色管理',
   icon: 'el-icon-rank',
   path: '/sys/roles',
   name: 'SysRoles',
   component: 'sys/Role',
   children: []
}

而後返回的權限數據應該是個數組:

["sys:menu:list","sys:menu:save","sys:user:list"...]

注意導航菜單那裏有個children,也就是子菜單,是個樹形結構,由於咱們的菜單可能這樣:

系統管理 - 菜單管理 - 添加菜單

能夠看到這就已經有3級了菜單了。
因此在打代碼時候要注意這個關係的關聯。咱們的SysMenu實體類中有個parentId,可是沒有children,所以咱們能夠在SysMenu中添加一個children,固然了其實不添加也能夠,由於咱們也須要一個dto,這樣咱們才能按照上面json數據格式返回。

咱們仍是來添加一個children吧:

  • com.markerhub.entity.SysMenu

    @Data
    @EqualsAndHashCode(callSuper = true)
    public class SysMenu extends BaseEntity {
     ...
     @TableField(exist = false)
     private List<SysMenu> children = new ArrayList<>();
    }

    而後咱們也先來定義一個SysMenuDto吧,知道要返回什麼樣的數據,咱們就只須要去填充數據就行了

  • com.markerhub.common.dto.SysMenuDto

    @Data
    public class SysMenuDto implements Serializable {
     private Long id;
     private String title;
     private String icon;
     private String path;
     private String name;
     private String component;
     List<SysMenuDto> children = new ArrayList<>();
    }

    ok,咱們來開始咱們的編碼

  • com.markerhub.controller.SysMenuController#nav

    /**
     * 獲取當前用戶的菜單欄以及權限
     */
    @GetMapping("/nav")
    public Result nav(Principal principal) {
     String username = principal.getName();
     SysUser sysUser = sysUserService.getByUsername(username);
     // ROLE_Admin,sys:user:save
     String[] authoritys = StringUtils.tokenizeToStringArray(
           sysUserService.getUserAuthorityInfo(sysUser.getId())
           , ",");
     return Result.succ(
           MapUtil.builder()
                 .put("nav", sysMenuService.getcurrentUserNav())
                 .put("authoritys", authoritys)
                 .map()
     );
    }

    方法中Principal principal表示注入當前用戶的信息,getName就能夠獲取噹噹前用戶的用戶名了。sysUserService.getUserAuthorityInfo方法咱們以前已經說過了,就在咱們登陸完成或者身份認證時候須要返回用戶權限時候編寫的。而後經過StringUtils.tokenizeToStringArray把字符串經過逗號分開組成數組形式。
    重點在與sysMenuService.getcurrentUserNav,獲取當前用戶的菜單導航,

@Service
public class SysMenuServiceImpl extends ServiceImpl<SysMenuMapper, SysMenu> implements SysMenuService {
   ...
   
   /**
    * 獲取當前用戶菜單導航
    */
   @Override
   public List<SysMenuDto> getcurrentUserNav() {
      String username = (String) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
      
      SysUser sysUser = sysUserService.getByUsername(username);
      
      // 獲取用戶的全部菜單
      List<Long> menuIds = sysUserMapper.getNavMenuIds(sysUser.getId());
      
      List<SysMenu> menus = buildTreeMenu(this.listByIds(menuIds));
      return convert(menus);
   }
   
   /**
    * 把list轉成樹形結構的數據
    */
   private List<SysMenu> buildTreeMenu(List<SysMenu> menus){
      List<SysMenu> finalMenus = new ArrayList<>();
      for (SysMenu menu : menus) {
      
         // 先尋找各自的孩子
         for (SysMenu e : menus) {
            if (e.getParentId() == menu.getId()) {
               menu.getChildren().add(e);
            }
         }
         // 提取出父節點
         if (menu.getParentId() == 0L) {
            finalMenus.add(menu);
         }
      }
      return finalMenus;
   }
   
   /**
    * menu轉menuDto
    */
   private List<SysMenuDto> convert(List<SysMenu> menus) {
      List<SysMenuDto> menuDtos = new ArrayList<>();
      menus.forEach(m -> {
         SysMenuDto dto = new SysMenuDto();
         dto.setId(m.getId());
         dto.setName(m.getPerms());
         dto.setTitle(m.getName());
         dto.setComponent(m.getComponent());
         dto.setIcon(m.getIcon());
         dto.setPath(m.getPath());
         if (m.getChildren().size() > 0) {
            dto.setChildren(convert(m.getChildren()));
         }
         menuDtos.add(dto);
      });
      return menuDtos;
   }
}

接口中sysUserMapper.getNavMenuIds咱們以前就已經寫過的了,經過用戶id獲取菜單的id,而後後面就是轉成樹形結構,buildTreeMenu方法的思想很簡單,咱們現實把菜單循環,讓全部菜單先找到各自的子節點,而後咱們在把最頂級的菜單獲取出來,這樣頂級下面有二級,二級也有本身的三級。最後就是convert把menu轉成menuDto。這個比較簡單,就不說了。
好了,導航菜單已經開發完畢,咱們來寫菜單管理的增刪改查,由於菜單列表也是個樹形接口,此次咱們就不是獲取當前用戶的菜單列表的,而是全部菜單而後組成樹形結構,同樣的思想,數據不同而已。

  • com.markerhub.controller.SysMenuController

    @GetMapping("/info/{id}")
    @PreAuthorize("hasAuthority('sys:menu:list')")
    public Result info(@PathVariable("id") Long id) {
     return Result.succ(sysMenuService.getById(id));
    }
    @GetMapping("/list")
    @PreAuthorize("hasAuthority('sys:menu:list')")
    public Result list() {
     List<SysMenu> menus = sysMenuService.tree();
     return Result.succ(menus);
    }
    @PostMapping("/save")
    @PreAuthorize("hasAuthority('sys:menu:save')")
    public Result save(@Validated @RequestBody SysMenu sysMenu) {
     sysMenu.setCreated(LocalDateTime.now());
     sysMenu.setStatu(Const.STATUS_ON);
     sysMenuService.save(sysMenu);
     return Result.succ(sysMenu);
    }
    @PostMapping("/update")
    @PreAuthorize("hasAuthority('sys:menu:update')")
    public Result update(@Validated @RequestBody SysMenu sysMenu) {
     sysMenu.setUpdated(LocalDateTime.now());
     sysMenuService.updateById(sysMenu);
     // 清除全部與該菜單相關的權限緩存
     sysUserService.clearUserAuthorityInfoByMenuId(sysMenu.getId());
     return Result.succ(sysMenu);
    }
    @Transactional
    @PostMapping("/delete/{id}")
    @PreAuthorize("hasAuthority('sys:menu:delete')")
    public Result delete(@PathVariable Long id) {
     int count = sysMenuService.count(new QueryWrapper<SysMenu>().eq("parent_id", id));
     if (count > 0) {
        return Result.fail("請先刪除子菜單");
     }
     
     // 先清除全部與該菜單相關的權限緩存
     sysUserService.clearUserAuthorityInfoByMenuId(id);
     sysMenuService.removeById(id);
     
     // 同步刪除
     sysRoleMenuService.remove(new QueryWrapper<SysRoleMenu>().eq("menu_id", id));
     return Result.succ("");
    }

    刪除、更新菜單的時候記得調用根據菜單id清楚用戶權限緩存信息的方法哈。而後每一個方法前都會帶有權限註解:@PreAuthorize("hasAuthority('sys:menu:delete')"),這就要求用戶有特定的操做權限才能調用這個接口,sys:menu:delete這些數據不是亂寫出來的,咱們必須和數據庫的數據保持一致才行,而後component字段,也是要和前端進行溝通,由於這個是連接到的前端的組件頁面。
    有了增刪改查,咱們就去先添加咱們的全部的菜單權限數據先。效果以下:

圖片

圖片

基本上線填好全部菜單的列表和增刪改查操做權限,就ok。

10. 角色接口開發

角色的增刪改查其實也簡單,並且字段這麼少,基本上吧菜單的增刪改查複製過來,而後把menu改爲role,在調整一下就差很少啦。而後有個角色關聯菜單的操做,這個咱們等下講講,先來看代碼:

@RestController
@RequestMapping("/sys/role")
public class SysRoleController extends BaseController {
   @GetMapping("/info/{id}")
   @PreAuthorize("hasAuthority('sys:role:list')")
   public Result info(@PathVariable Long id) {
      SysRole role = sysRoleService.getById(id);
      List<SysRoleMenu> roleMenus = sysRoleMenuService.list(new QueryWrapper<SysRoleMenu>().eq("role_id", id));
      List<Long> menuIds = roleMenus.stream().map(p -> p.getMenuId()).collect(Collectors.toList());
      role.setMenuIds(menuIds);
      return Result.succ(role);
   }
   
   @GetMapping("/list")
   @PreAuthorize("hasAuthority('sys:role:list')")
   public Result list(String name) {
      Page<SysRole> roles = sysRoleService.page(getPage(),
            new QueryWrapper<SysRole>()
                  .like(StrUtil.isNotBlank(name), "name", name)
      );
      return Result.succ(roles);
   }
   
   @PostMapping("/save")
   @PreAuthorize("hasAuthority('sys:role:save')")
   public Result save(@Validated @RequestBody SysRole sysRole) {
      sysRole.setCreated(LocalDateTime.now());
      sysRole.setStatu(Const.STATUS_ON);
      sysRoleService.save(sysRole);
      return Result.succ(sysRole);
   }
   
   @PostMapping("/update")
   @PreAuthorize("hasAuthority('sys:role:update')")
   public Result update(@Validated @RequestBody SysRole sysRole) {
      sysRole.setUpdated(LocalDateTime.now());
      sysRoleService.updateById(sysRole);
      return Result.succ(sysRole);
   }
   
   @Transactional
   @PostMapping("/delete")
   @PreAuthorize("hasAuthority('sys:role:delete')")
   public Result delete(@RequestBody Long[] ids){
      sysRoleService.removeByIds(Arrays.asList(ids));
      // 同步刪除
      sysRoleMenuService.remove(new QueryWrapper<SysRoleMenu>().in("role_id", ids));
      sysUserRoleService.remove(new QueryWrapper<SysUserRole>().in("role_id", ids));
      return Result.succ("");
   }
   
   @Transactional
   @PostMapping("/perm/{roleId}")
   @PreAuthorize("hasAuthority('sys:role:perm')")
   public Result perm(@PathVariable Long roleId, @RequestBody Long[] menuIds) {
      List<SysRoleMenu> sysRoleMenus = new ArrayList<>();
      Arrays.stream(menuIds).forEach(menuId -> {
         SysRoleMenu roleMenu = new SysRoleMenu();
         roleMenu.setMenuId(menuId);
         roleMenu.setRoleId(roleId);
         sysRoleMenus.add(roleMenu);
      });
      
      sysRoleMenuService.remove(new QueryWrapper<SysRoleMenu>().eq("role_id", roleId));
      
      sysRoleMenuService.saveBatch(sysRoleMenus);
      
      // 清除全部用戶的權限緩存信息
      sysUserService.clearUserAuthorityInfoByRoleId(roleId);
      return Result.succ(menuIds);
   }
}

上面方法中:

  • info方法

獲取角色信息的方法,由於咱們不只僅在編輯角色時候會用到這個方法,在回顯角色關聯菜單的時候也須要被調用,所以咱們須要把角色關聯的全部的菜單的id也一併查詢出來,也就是分配權限的操做。對應到前端就是這樣的,點擊分配權限,會彈出出全部的菜單列表,而後根據角色已經關聯的菜單的id回顯勾選上已經關聯過的。效果以下:

圖片

而後點擊保存分配權限的時候,咱們須要把角色的id和全部勾選上的菜單id的數組一塊兒傳過來,因此纔有了controller中的這樣的寫法:

@PreAuthorize("hasAuthority('sys:role:perm')")
public Result perm(@PathVariable Long roleId, @RequestBody Long[] menuIds) {
    ...代碼上面貼出來
}

能夠看到,由於@RequestBody,咱們知道menuIds是否安裝body裏面的,這個須要注意,對應到的前端寫法就是這樣:
圖片

ok,角色管理就講到這裏了,其餘增刪改查本身看下代碼,不難哈。

圖片

圖片

11. 用戶接口開發

用戶管理裏面有個用戶關聯角色的分配角色操做,和角色關聯菜單的寫法差很少的,其餘增刪改查也複製黏貼改改就好,哈哈。

  • com.markerhub.controller.SysUserController

    /**
     * 公衆號:MarkerHub
     */
    @RestController
    @RequestMapping("/sys/user")
    public class SysUserController extends BaseController {
     @Autowired
     PasswordEncoder passwordEncoder;
     
     @GetMapping("/info/{id}")
     @PreAuthorize("hasAuthority('sys:user:list')")
     public Result info(@PathVariable Long id) {
        SysUser user = sysUserService.getById(id);
        Assert.notNull(user, "找不到該管理員!");
        List<SysRole> roles = sysRoleService.listRolesByUserId(user.getId());
        user.setRoles(roles);
        return Result.succ(user);
     }
     
     /**
      * 用戶本身修改密碼
      *
      */
     @PostMapping("/updataPass")
     public Result updataPass(@Validated @RequestBody PassDto passDto, Principal principal) {
        SysUser sysUser = sysUserService.getByUsername(principal.getName());
        boolean matches = passwordEncoder.matches(passDto.getCurrentPass(), sysUser.getPassword());
        if (!matches) {
           return Result.fail("舊密碼不正確!");
        }
        sysUser.setPassword(passwordEncoder.encode(passDto.getPassword()));
        sysUser.setUpdated(LocalDateTime.now());
        sysUserService.updateById(sysUser);
        return Result.succ(null);
     }
     
     /**
      * 超級管理員重置密碼
      */
     @PostMapping("/repass")
     @PreAuthorize("hasAuthority('sys:user:repass')")
     public Result repass(@RequestBody Long userId) {
        SysUser sysUser = sysUserService.getById(userId);
        sysUser.setPassword(passwordEncoder.encode(Const.DEFAULT_PASSWORD));
        sysUser.setUpdated(LocalDateTime.now());
        sysUserService.updateById(sysUser);
        return Result.succ(null);
     }
     
     @GetMapping("/list")
     @PreAuthorize("hasAuthority('sys:user:list')")
     public Result page(String username) {
        Page<SysUser> users = sysUserService.page(getPage(),
              new QueryWrapper<SysUser>()
                    .like(StrUtil.isNotBlank(username), "username", username)
        );
        users.getRecords().forEach(u -> {
           u.setRoles(sysRoleService.listRolesByUserId(u.getId()));
        });
        return Result.succ(users);
     }
     
     @PostMapping("/save")
     @PreAuthorize("hasAuthority('sys:user:save')")
     public Result save(@Validated @RequestBody SysUser sysUser) {
        sysUser.setCreated(LocalDateTime.now());
        sysUser.setStatu(Const.STATUS_ON);
        // 初始默認密碼
        sysUser.setPassword(Const.DEFAULT_PASSWORD);
        if (StrUtil.isBlank(sysUser.getPassword())) {
           return Result.fail("密碼不能爲空");
        }
        String password = passwordEncoder.encode(sysUser.getPassword());
        sysUser.setPassword(password);
        // 默認頭像
        sysUser.setAvatar(Const.DEFAULT_AVATAR);
        sysUserService.save(sysUser);
        return Result.succ(sysUser);
     }
     
     @PostMapping("/update")
     @PreAuthorize("hasAuthority('sys:user:update')")
     public Result update(@Validated @RequestBody SysUser sysUser) {
        sysUser.setUpdated(LocalDateTime.now());
        if (StrUtil.isNotBlank(sysUser.getPassword())) {
           String password = passwordEncoder.encode(sysUser.getPassword());
           sysUser.setPassword(password);
        }
        sysUserService.updateById(sysUser);
        return Result.succ(sysUser);
     }
     
     @PostMapping("/delete")
     @PreAuthorize("hasAuthority('sys:user:delete')")
     public Result delete(@RequestBody Long[] ids){
        sysUserService.removeByIds(Arrays.asList(ids));
        sysUserRoleService.remove(new QueryWrapper<SysUserRole>().in("user_id", ids));
        return Result.succ("");
     }
     
     /**
      * 分配角色
      * @return
      */
     @Transactional
     @PostMapping("/role/{userId}")
     @PreAuthorize("hasAuthority('sys:user:role')")
     public Result perm(@PathVariable Long userId, @RequestBody Long[] roleIds) {
        System.out.println(roleIds);
        List<SysUserRole> userRoles = new ArrayList<>();
        Arrays.stream(roleIds).forEach(roleId -> {
           SysUserRole userRole = new SysUserRole();
           userRole.setRoleId(roleId);
           userRole.setUserId(userId);
           userRoles.add(userRole);
        });
        sysUserRoleService.remove(new QueryWrapper<SysUserRole>().eq("user_id", userId));
        sysUserRoleService.saveBatch(userRoles);
        // 清除權限信息
        SysUser sysUser = sysUserService.getById(userId);
        sysUserService.clearUserAuthorityInfo(sysUser.getUsername());
        return Result.succ(roleIds);
     }
    }

    上面用到一個sysRoleService.listRolesByUserId,經過用戶id獲取全部關聯的角色,用到了中間表,能夠寫sql,這裏我這樣寫的:

  • com.markerhub.service.impl.SysRoleServiceImpl#listRolesByUserId

    @Override
    public List<SysRole> listRolesByUserId(Long userId) {
     return this.list(
           new QueryWrapper<SysRole>()
                 .inSql("id", "select role_id from sys_user_role where user_id = " + userId));
    }

    userId必定要是本身數據庫查出來的,千萬別讓前端傳過來啥就直接調用這個方法,否則會可能會被攻擊,嘿嘿嘿~最委託就是寫完整的sql,而不是這樣半個sql的寫法。
    最後效果以下:

圖片

圖片

圖片

12. 項目部署

部署項目其實和vueblog的部署是同樣的,本身調整一下吧少年,我有寫了視頻和文檔的:

https://www.bilibili.com/video/BV17A411E7aE/

13. 項目總結

好了,咱們終於又寫完了一個項目,但願能讓大家學到點東西,此次寫的文檔有點亂,多多擔待,太長了,寫着寫着就不知道寫哪了,哈哈。

另外我還有另一個先後端博客項目博客,若是有須要能夠關注公衆號MarkerHub,回覆【VueBlog】獲取哈!!

相關文章
相關標籤/搜索