# education **Repository Path**: lixu0811/education ## Basic Information - **Project Name**: education - **Description**: 基于 SpringBoot + Mybatis Plus + Shiro + mysql + redis构建的智慧云智能教育平台。架构上使用完全前后端分离。 支持多种题型:选择题、多选题、判断题、填空题、综合题以及数学公式。支持在线考试,教师在线批改试卷。 - **Primary Language**: Java - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 2 - **Created**: 2023-03-10 - **Last Updated**: 2023-03-10 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 智慧云智慧教育平台实战项目笔记 ## 一、项目简介 课程内容:智慧云教育平台管理后台、智慧云教育平台学生端、项目的正式部署 ### 1、技术说明 - 后端技术:JDK1.8 + SpringBoot + MyBatis + Shiro - 缓存框架:Redis - 数据库:MySQL 5.7 - 前端技术:Element-UI + Vue - 开发工具:IDEA 2019.3.3 - 项目管理工具:Maven、Git 使用最主流的框架 SpringBoot + Vue 实现完全前后端分离 ### 2、核心功能介绍 - 管理后台核心功能:RBAC权限管理、试题管理、试卷批改 - 学生端核心功能:考试中心、我的错题本 ### 3、学习前提 - 后端技术:掌握SpringBoot + MyBatis + Shiro + MySQL的基本使用 - 前端技术:掌握Vue、Element-UI、CS6语法的基本使用 - 热爱Java编程,喜欢研究新技术 ### 4、课程收获 - 加强对Java程序员基础知识的掌握 - 掌握企业级项目编码规范,提升代码优化的能力 - 掌握企业级 SpringBoot + Vue + Element-UI 全栈开发技能,增加项目经验,提升职场竞争力 - 掌握项目从零搭建到项目正式部署的完整流程 ## 二、Maven介绍及其配置 ### 1、Maven是什么? ​ Maven是Apache下的一一个纯Java 开发的开源项目。它主要用来帮助实现项目的构建、测试、打包和部署。Maven提供了标准的软件生命周期模型和构建模型,通过配置就能对项目进行全面的管理。[想了解更多点击这里](https://www.runoob.com/maven/maven-tutorial.html) ### 2、Maven的优势 - Maven能够帮助我们快速构建和发布项目,提高工作效率 - Maven能够非常方便的帮助我们管理jar包和解决jar包冲突 - Maven对于目录结构有要求,约定优于配置,开发者在项目间切换就省去了学习成本 - Maven有助于项目的多模块开发 ### 3、Maven项目结构 | 目录 | 目的 | | :------------------------------------ | :------------------------------------------------------------ | | `${basedir}` | 存放pom.xml和所有的子目录 | | `${basedir}/src/main/java` | 项目的java源代码 | | `${basedir}/src/main/resources` | 项目的资源,比如说property文件,springmvc.xml | | `${basedir}/src/test/java` | 项目的测试类,比如说Junit代码 | | `${basedir}/src/test/resources` | 测试用的资源 | | `${basedir}/src/main/webapp/WEB-INF` | web应用文件目录,web项目的信息,比如存放web.xml、本地图片、jsp视图页面 | | `${basedir}/target` | 打包输出目录 | | `${basedir}/target/classes` | 编译输出目录 | | `${basedir}/target/test-classes` | 测试编译输出目录 | | `Test.java` | Maven只会自动运行符合该命名规则的测试类 | | `~/.m2/repository` | Maven默认的本地仓库目录位置 | ### 4、Maven下载 Maven 下载地址:[http://maven.apache.org/download.cgi](http://maven.apache.org/download.cgi) ![在这里插入图片描述](https://img-blog.csdnimg.cn/2c717ee9cbf14093b69a2cb770d858c8.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5ZOI5biM55av,size_19,color_FFFFFF,t_70,g_se,x_16) 不同平台下载对应的包: | 系统 | 包名 | | :------- | :----------------------------- | | Windows | apache-maven-3.3.9-bin.zip | | Linux | apache-maven-3.3.9-bin.tar.gz | | Mac | apache-maven-3.3.9-bin.tar.gz | 下载包后解压到对应目录: | 系统 | 存储位置 (可根据自己情况配置) | | :------- | :----------------------------- | | Windows | `D:\Maven\apache-maven-3.3.9` | | Linux | `/usr/local/apache-maven-3.3.9` | | Mac | `/usr/local/apache-maven-3.3.9` | ### 5、设置Maven环境变量 ​ 右键 "计算机",选择 "属性",之后点击 "高级系统设置",点击"环境变量",来设置环境变量,有以下系统变量需要配置: 新建系统变量 `MAVEN_HOME`,变量值:`E:\Maven\apache-maven-3.3.9` ![系统变量MAVEN_HOME](https://img-blog.csdnimg.cn/a4f8c7621ed14f00a20289a0a9065f76.png) 编辑系统变量 `Path`,添加变量值:`;%MAVEN_HOME%\bin` ![编辑系统变量Path](https://img-blog.csdnimg.cn/178f5d19b6594413b65e26aa0a72fcbf.png) **注意:**注意多个值之间需要有分号隔开,然后点击确定。 ### 6、修改Maven配置文件 打开`E:\Maven\apache-maven-3.3.9\conf`目录下的`setting.xml`文件 修改本地仓库路径: ```xml D:\Apps\Maven-3.8.1\repository ``` 修改镜像源为阿里云 ```xml nexus-aliyun Nexus aliyun http://maven.aliyun.com/nexus/content/groups/public central ``` 修改编译器JDK版本为1.8 ```xml jdk-1.8 true 1.8 1.8 1.8 1.8 ``` ### 7、pom.xml文件中常见的标签使用 ```xml 4.0.0 cn.jasondom.springboot Template 1.0.0 jar SpringBoot SpringBoot is a lightweight Java Web Framework http://maven.apache.org 1.8 2.2.3 org.apache.velocity velocity ${velocity.version} ... ... ... ... ... ... ... 子项目1目录路径 子项目2目录路径 ... ``` ### 8、多模块开发的好处 - 降低项目复杂性,提升我们的开发效率 - 有利于项目遵从**高内聚,低耦合**的设计模式,保证了代码的质量和健壮性 - 避免重复造轮子,减少工作量 ### 9、IDEA上配置Maven 在`File`中打开`Settings...`,搜索`Maven`,根据安装目录修改配置 ![IDEA配置Maven](https://img-blog.csdnimg.cn/bb8bc14c7772407bab5bbb2990ae3e4a.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5ZOI5biM55av,size_20,color_FFFFFF,t_70,g_se,x_16) **注意**:建议使用Maven-3.5,如果版本过高可能与IDEA不兼容 为了避免所有新建项目都要修改Maven配置,可以打开`File` -> `Other Settings` -> `Settings for New Project...`重新设置一次Maven ![修改新建项目的Maven配置](https://img-blog.csdnimg.cn/fa4f0e24778e4aa1ab7b4996add8a9bd.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5ZOI5biM55av,size_20,color_FFFFFF,t_70,g_se,x_16) ## 三、后端项目环境的搭建 ### 1、创建Maven类型的父工程 ![在这里插入图片描述](https://img-blog.csdnimg.cn/4778717bb31e4a52bf58b1949975264d.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5ZOI5biM55av,size_20,color_FFFFFF,t_70,g_se,x_16) ![在这里插入图片描述](https://img-blog.csdnimg.cn/f0f0bc37148245c8b573b651c8440e23.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5ZOI5biM55av,size_20,color_FFFFFF,t_70,g_se,x_16) 接下来点击`Next` -> `Finish`完成创建,等项目构建完成后删除`src`加粗样式目录 ### 2、创建SpringBoot类型的管理模块 (1)、在工程目录上右键单击,选择`New` -> `Module...` ![在这里插入图片描述](https://img-blog.csdnimg.cn/cf73d85592e4428abc489753cd3e9237.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5ZOI5biM55av,size_20,color_FFFFFF,t_70,g_se,x_16) (2)、设置模块名称 ![在这里插入图片描述](https://img-blog.csdnimg.cn/0a1c3b8229674d0fab259c71479e2409.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5ZOI5biM55av,size_20,color_FFFFFF,t_70,g_se,x_16) (3)、选择基本依赖 ![在这里插入图片描述](https://img-blog.csdnimg.cn/fe794913d5ac4a3fb17141375a571be6.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5ZOI5biM55av,size_20,color_FFFFFF,t_70,g_se,x_16) (4)、点击`Next` -> `Finish`完成创建,将管理模块的`SpringBoot`父依赖剪切至父工程中,并将版本改为`2.1.9.RELEASE` ![在这里插入图片描述](https://img-blog.csdnimg.cn/d5bb76b204624900b1ebaadb0dfe2b87.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5ZOI5biM55av,size_20,color_FFFFFF,t_70,g_se,x_16) ```xml org.springframework.boot spring-boot-starter-parent 2.1.9.RELEASE ``` (5)、将管理模块的`parent`标签修改成以下内容,使其依赖于父工程 ```xml com.education education 1.0-SNAPSHOT ``` **(6)、剪切管理模块的依赖列表至父工程** ```xml junit junit 4.11 test org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-devtools runtime true org.springframework.boot spring-boot-starter-test test ``` (7)、将JDK版本改为1.8 ```xml UTF-8 1.8 1.8 ``` ### 3、创建Maven类型的service模块 (1)、在工程目录上右键单击,选择`New` -> `Module...` ![创建模块](https://img-blog.csdnimg.cn/c20deb62e796499f80dac8360e961f86.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5ZOI5biM55av,size_20,color_FFFFFF,t_70,g_se,x_16) (2)、设置模块名称(将`GroupId`设置为`com.education.service`) ![在这里插入图片描述](https://img-blog.csdnimg.cn/cc7dbbd2e7b64672bce874444eb0ac2c.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5ZOI5biM55av,size_20,color_FFFFFF,t_70,g_se,x_16) (3)、点击`Next` -> `Finish`完成创建,打开`service`模块下的`pom.xml`文件,修改`groupId`和父工程为如下内容 ```xml com.education education 1.0-SNAPSHOT com.education.service education-service 1.0-SNAPSHOT ``` ### 4、创建common工具模块 (1)、在工程目录上右键单击,选择`New` -> `Module...` ![创建模块](https://img-blog.csdnimg.cn/c20deb62e796499f80dac8360e961f86.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5ZOI5biM55av,size_20,color_FFFFFF,t_70,g_se,x_16) (2)、设置模块名称(将`GroupId`设置为`com.education.common`) ![在这里插入图片描述](https://img-blog.csdnimg.cn/2b20642022684322b31020c6e4795043.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5ZOI5biM55av,size_20,color_FFFFFF,t_70,g_se,x_16) (3)、点击`Next` -> `Finish`完成创建,打开`common`模块下的`pom.xml`文件,修改`groupId`和父工程为如下内容 ```xml com.education education 1.0-SNAPSHOT com.education.common education-common 1.0-SNAPSHOT ``` ### 5、修改依赖关系 (1)、添加模块到父工程,在父工程的`pom.xml`文件中添加如下内容 ```xml education-admin-api education-service education-common ``` (2)、在`admin-api`模块中添加依赖 ```xml com.education.common education-common 1.0-SNAPSHOT com.education.service education-service 1.0-SNAPSHOT ``` (3)在`service`模块中添加依赖 ```xml com.education.common education-common 1.0-SNAPSHOT ``` ### 6、测试环境 (1)、在管理模块`src/main/resources/`目录下创建如下三个文件(删除默认的`application.properties`文件) | 配置文件名 | 说明 | |:--|:--| | `application.yml` | 默认环境配置文件 | | `application-dev.yml` | 开发环境配置文件 | | `application-prod.yml` | 生产环境配置文件 | (2)、在`application.yml`中添加如下配置,激活开发环境配置文件 ```yaml spring: profiles: active: dev ``` (3)、在`application-dev.yml`中添加如下配置,设置服务器访问端口号 ```yaml server: port: 80 ``` (4)、在`com/education/admin/api/EducationAdminApiApplication.java`入口类中编写测试方法test ```java @SpringBootApplication @RestController public class EducationAdminApiApplication { @GetMapping public String test() { return "success"; } public static void main(String[] args) { SpringApplication.run(EducationAdminApiApplication.class, args); } } ``` (5)、删除`education-admin-api/src/test/java/com/education/admin/api`包下的test类后,启动main函数 (6)、在浏览器中访问`localhost`,如果提示`success`表示环境搭建成功 ### 7、Maven中常用的命令 | 指令 | 功能 | | :--------- | :-------------------------------- | | `clean` | 清除编译后的class文件 | | `compile` | 编译项目的源代码 | | `test` | 对项目进行单元测试 | | `package` | 对项目进行打包 | | `install` | 对项目进行打包,并安装到本地仓库 | ## 四、Git简介及基本使用 ### 1、Git简介 #### 1.1 Git是什么? Git是一个开源的分布式版本控制系统,可以有效、高速地处理从很小到非常大的项目版本管理。 #### 1.2 为什么要使用Git? - 基于分布式的设计,有利于项目的多人合作开发,提高工作效率 - 方便开发者解决代码冲突 - 可以从当前版本回退到任意版本,防止误操作导致代码丢失 #### 1.3 Git的工作流程 ![在这里插入图片描述](https://img-blog.csdnimg.cn/0bb4b6fb062c4c46a66df36c4ac75215.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5ZOI5biM55av,size_20,color_FFFFFF,t_70,g_se,x_16) #### 1.4 Git分支的概念 可以理解成一条条的**河流**,最终都要流入**大海**(master) #### 1.5 Git分支结构 ![在这里插入图片描述](https://img-blog.csdnimg.cn/7a02f37c4bc44afa9b50a0c5755cee8b.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5ZOI5biM55av,size_20,color_FFFFFF,t_70,g_se,x_16) ### 2、下载并配置Git #### 2.1 下载Git Git 各平台安装包下载地址为:[http://git-scm.com/downloads](http://git-scm.com/downloads) #### 2.2 在IDEA上配置Git 在`File`中打开`Settings...`,搜索`Git`,根据安装目录修改配置 ![在这里插入图片描述](https://img-blog.csdnimg.cn/0971cce894704d78930bcbe183ce695d.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5ZOI5biM55av,size_20,color_FFFFFF,t_70,g_se,x_16) ### 3、将代码上传至码云仓库 #### 3.2 项目码云步骤 - 注册码云账号 -> [https://gitee.com/](https://gitee.com/) - 使用码云[创建一个远程仓库](https://gitee.com/help/articles/4120) - 在IDEA中将代码上传至码云仓库 #### 3.1 IDEA中将代码上传至码云仓库 1)选择`Create Git Repository...`,在弹出的对话框中选择项目路径 ![在这里插入图片描述](https://img-blog.csdnimg.cn/0bafde8c77ac4e9ca7c0d83c2b306711.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5ZOI5biM55av,size_20,color_FFFFFF,t_70,g_se,x_16) 2)将代码提交至本地仓库 ![提交代码到本地仓库](https://img-blog.csdnimg.cn/aa278178f60a45339f7222555ca75ac0.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5ZOI5biM55av,size_20,color_FFFFFF,t_70,g_se,x_16) 选中要提交的文件,输入提交信息,点击`Commit`即可提交到本地仓库(只提交源码和配置文件,`.idea`、`.mvn`和编译等文件不用提交) ![在这里插入图片描述](https://img-blog.csdnimg.cn/c304f24cd8d54e25bd593be4e056d8f1.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5ZOI5biM55av,size_20,color_FFFFFF,t_70,g_se,x_16) 3)将项目提交至远程仓库 ![将项目提交至远程仓库](https://img-blog.csdnimg.cn/c760408159254de2aa805f21facbadeb.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5ZOI5biM55av,size_20,color_FFFFFF,t_70,g_se,x_16) 点击`Define remote`,输入远程仓库的地址点击`OK`,选择`Push`即可提交代码至远程仓库 ![在这里插入图片描述](https://img-blog.csdnimg.cn/619b9aeb68cc48258e68e75d2868f2fc.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5ZOI5biM55av,size_20,color_FFFFFF,t_70,g_se,x_16) ### 4、代码的更新 (1)、可以直接在码云线上修改代码 ![在这里插入图片描述](https://img-blog.csdnimg.cn/3bcb0955d0114ee9b24c42cb630768fb.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5ZOI5biM55av,size_20,color_FFFFFF,t_70,g_se,x_16) (2)添加一个测试方法,并提交 ![提交测试](https://img-blog.csdnimg.cn/f2ac91b1e9814740a280d346ba213722.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5ZOI5biM55av,size_20,color_FFFFFF,t_70,g_se,x_16) (3)按图示操作,即可更新代码到本地(有的IDEA有多个选项,可以选择`Merge`或`Branch Default`,但**推荐选择`Merge`**,因为`Merge`不仅可以更新代码,在一般情况下,它还会自动帮忙合并代码) ![在这里插入图片描述](https://img-blog.csdnimg.cn/84daa36649c34f978f2c547a1a1b0980.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5ZOI5biM55av,size_20,color_FFFFFF,t_70,g_se,x_16) (4)测试完成后将test测试方法删除,重新push到码云(图示中的`Commit and Push`可以同时将代码提交到本地和远程仓库,而`Commit`只能提交到本地) ![在这里插入图片描述](https://img-blog.csdnimg.cn/2ea8fc05da9b4398b37530df54eed297.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5ZOI5biM55av,size_20,color_FFFFFF,t_70,g_se,x_16) ### 5、使用Git解决代码冲突 当多个人修改了同一方法,就会产生**代码冲突**,下面通过线上线下同时修改代码模拟多人修改同一方法的场景 (1)、线上修改代码并提交 ![线上修改代码](https://img-blog.csdnimg.cn/46a17050a9594c4abefc08ff16803e7a.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5ZOI5biM55av,size_20,color_FFFFFF,t_70,g_se,x_16) (2)、线下修改代码并提交 ![线下修改代码并提交](https://img-blog.csdnimg.cn/e3c680bdedd949b3aa64c9c9ace39b2f.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5ZOI5biM55av,size_20,color_FFFFFF,t_70,g_se,x_16) 提交代码,选择`Commit and Push`,弹出对话框后继续选择`Commit and Push`,再选择`Push` ![在这里插入图片描述](https://img-blog.csdnimg.cn/d31e7b97efe54762bc8607d97a109464.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5ZOI5biM55av,size_20,color_FFFFFF,t_70,g_se,x_16) (3)、此时会弹出拒绝提交,需要合并代码的提示,选择`Merge` ![合并代码提示](https://img-blog.csdnimg.cn/7fef207bdaae42d6bc083e7bc4b5566f.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5ZOI5biM55av,size_20,color_FFFFFF,t_70,g_se,x_16) (4)、弹出冲突提示,继续选择`Merge` ![在这里插入图片描述](https://img-blog.csdnimg.cn/61d0c0195cf646a39964a2e99e1be4bd.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5ZOI5biM55av,size_20,color_FFFFFF,t_70,g_se,x_16) (5)、此时会弹出冲突代码的对比窗口,可以点击第15行代码处指向中间窗口的箭头(`>>`或`<<`)来控制合并代码,选择好后点击`Apply`,再按快捷键Ctrl + Shift + K 重新提交代码 ![代码冲突合并](https://img-blog.csdnimg.cn/9eb60e29fde34fe09e089115554e61a1.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5ZOI5biM55av,size_20,color_FFFFFF,t_70,g_se,x_16) ### 6、将远程仓库代码导入到本地 (1)、从远程仓库新建项目 ![在这里插入图片描述](https://img-blog.csdnimg.cn/3eaaf4cb264744e485ed78081ada056c.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5ZOI5biM55av,size_20,color_FFFFFF,t_70,g_se,x_16) (2)、输入远程仓库地址,选择项目的存放路径,点击`Clone`,弹出提示后点击`Yes` ![在这里插入图片描述](https://img-blog.csdnimg.cn/fcee8f0bf0a6422ebd5ea78f6664b48e.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5ZOI5biM55av,size_20,color_FFFFFF,t_70,g_se,x_16) (3)、你会发现IDEA没有自动打开项目,可以通过`File` -> `Open` -> 选择项目的`pom.xml`文件打开,此时会多次弹出提示框,按提示操作即可,右下角弹出提示后选择`Add as Maven Project`,至此,项目就成功导入了 ### 7、Git分支的使用 可通过右下角的`Git xxx`按钮查看当前分支 ![在这里插入图片描述](https://img-blog.csdnimg.cn/5803f77e499743489a5b40970b1492f3.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5ZOI5biM55av,size_20,color_FFFFFF,t_70,g_se,x_16) > `Local Branches`表示本地分支;`Remote Branches`表示远程仓库分支;按钮中的`xxx`表示当前分支名称;可以通过`New Branch`创建新分支;点击相应分支后会弹出二级菜单:`Checkout`表示切换到此分支;`Merge into Current` 表示合并此分支到当前分支 #### 7.1 创建`dev`分支 (1)点击 `New Branch` -> 在弹出的对话框中输入分支名`dev`,再按快捷键Ctrl + Shift + K 直接提交分支 ![image-20211115192412145](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211115192412145.png) ![image-20211115191254754](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211115191254754.png) (2)接下来就可以在码云平台看到新添加的`dev`分支了 ![image-20211115191658804](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211115191658804.png) #### 7.2 切换分支 点击右下角的 `Git dev` 按钮,点击要切换的分支,在弹出的二级菜单中点击`Checkout`即可切换到指定分支 ![image-20211115192221846](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211115192221846.png) #### 7.3 分支代码的合并 (1)先切换到`dev`分支 ![](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211115193213161.png) (2)在SpringBoot入口类中添加 `test` 方法 ![](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211115193557316.png) (3)按快捷键 Ctrl + K ,按照图示操作之后会弹出提交窗口,选择`Push`提交即可 ![image-20211115195758404](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211115195758404.png) (4)切换到`master`分支 ![image-20211115192221846](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211115192221846.png) (5)将`dev`分支合并到`master`分支,此时`dev`上修改的代码就合并到`master`上了 ![](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211115194407149.png) (7)按快捷键Ctrl + Shift + K 直接提交 #### 7.4 在`dev`上检出一个`bug`分支 (1)先切换到`dev`分支 ![](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211115193213161.png) (2)新建一个`bug`分支 ![image-20211115192412145](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211115192412145.png) ![image-20211115200719766](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211115200719766.png) (3)在`SpringBoot`入口类中添加`test1`方法 ![image-20211115201211364](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211115201211364.png) (4)提交(Ctrl + K ) ![image-20211115195758404](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211115195758404.png) (5)切换到`dev`分支,会发现没有`test1`方法 ![](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211115193213161.png) (6)将`bug`分支合并到`dev`分支,再按快捷键`Ctrl + Shift + K` 提交 ![](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211115201932193.png) (7)最后切换到master分支,按快捷键Ctrl + Shift + K 提交 #### 7.5 使用码云创建分支 (1)登录码云,进入要创建分支的仓库,打开分支管理 ![image-20211115204158820](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211115204158820.png) (2)创建`feature`分支 ![image-20211115204957733](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211115204957733.png) (3)在IDEA中点击Fetch可以更新分支到本地,更新完成后,在右下角点击`git master`即可看到线上新建的`feature`分支了 ![image-20211115205303952](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211115205303952.png) ### 8、码云平台相关功能 可以为项目添加合作伙伴,按不同的职责分配不同的角色权限 ![image-20211115205916668](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211115205916668.png) ## 五、项目的前期准备 ### 1、Map和传统JavaBean技术选型 #### 1.1 Map的优缺点 > **优点:** > > - 灵活性强于JavaBean,易扩展,耦合度低。 > - 写起来简单,代码量少 > - MyBatis查询的返回结果本身就是Map > > **缺点:** > > - 不能一眼看出Map中有哪些参数 #### 1.2 JavaBean的优缺点 > **优点:** > > - 符合Java语言面向对象设计的原则 > - 数据结构清晰,便于团队开发和后期维护 > > **缺点:** > > - 需要不断地去维护实体类 #### 1.3 如何选型呢? - 团队人数少,追求开发效率,建议使用Map代替实体类 - 项目庞大,需要持续维护,团队人数多,建议使用实体类 ### 2、响应结果封装及全局异常处理 #### 2.1 MVC概念 ![image-20211115215325947](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211115215325947.png) #### 2.2 MVC流程 ![image-20211115215427938](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211115215427938.png) #### 2.3 前后端分离 ![image-20211115215507386](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211115215507386.png) #### 2.4 前后端分离的优缺点 **优点** - 分工更明确,提升各自领域专注度 - 前后端代码完全分离,有利于项目维护 **缺点:** - 需要不断地维护接口文档 - 沟通成本更高 - 部署相比较MVC架构要复杂点 #### 2.5 后端接口统一json数据格式 接口返回成功示例: ```json { "code": 1, "message": "请求成功", "data": { "user_info": { "name": "张三", "address": "北京市xx区" } } } ``` 接口请求失败示例: ```json { "code": 0, "message": "系统异常" } ``` #### 2.6 响应结果封装 (1)给父工程添加以下两个依赖 ```xml org.slf4j slf4j-api ch.qos.logback logback-core ``` (2)在`common`模块下创建`utils`包,用于放置系统中的工具类 ![image-20211115221535456](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211115221535456.png) (3)创建`ResultCode`类和`Result`类 ```java package com.education.common.utils; /** * http 响应状态码 * SUCCESS: 响应成功状态码 * FAIL: 响应失败状态码 */ public class ResultCode { public static final int SUCCESS = 1; public static final int FAIL = 0; public static final String DEFAULT_SUCCESS_MESSAGE = "操作成功"; public static final String DEFAULT_FAIL_MESSAGE = "操作失败"; private int code = SUCCESS; private String message; public ResultCode() {} public ResultCode(int code, String message) { this.code = code; this.message = message; } public int getCode() { return code; } public void setCode(int code) { this.code = code; } public String getMessage() { return message; } public void setMessage(String message) { this.message = message; } } ``` ```java package com.education.common.utils; /** * 对请求结果的封装 */ public class Result { private Object data; private ResultCode resultCode; public Result() {} public Result(ResultCode resultCode) { this.resultCode = resultCode; } public Result(Object data) { this.resultCode = new ResultCode(ResultCode.SUCCESS, ResultCode.DEFAULT_SUCCESS_MESSAGE); this.data = data; } public Result(ResultCode resultCode,Object data) { this(resultCode); this.data = data; } /** * 响应成功 * @param data 响应的数据 * @return 封装的请求 */ public static Result success(Object data) { return new Result(data); } /** * 响应成功 * @param resultCode 响应的状态码 * @param data 响应的数据 * @return 封装的请求 */ public static Result success(ResultCode resultCode, Object data) { return new Result(resultCode, data); } /** * 响应成功 * @param resultCode 响应的状态码 * @return 封装的请求 */ public static Result success(ResultCode resultCode) { return new Result(resultCode); } /** * 响应失败 * @param resultCode 状态码 * @return 封装的请求 */ public static Result fail(ResultCode resultCode) { return new Result(resultCode); } /** * 响应失败 * @return 封装的请求 */ public static Result fail() { return new Result(new ResultCode(ResultCode.FAIL,ResultCode.DEFAULT_FAIL_MESSAGE)); } /** * 判断请求是否成功 * @return 请求的状态:成功 true 失败 false */ public boolean isSuccess() { return ResultCode.SUCCESS == this.resultCode.getCode(); } public T getData() { return (T) data; } public void setData(Object data) { this.data = data; } public ResultCode getResultCode() { return resultCode; } public void setResultCode(ResultCode resultCode) { this.resultCode = resultCode; } } ``` #### 2.7 全局异常处理 (1)在`com.education.common`下创建`exception`包 ![image-20211116000153974](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211116000153974.png) (2)在`exception`包下创建系统业务异常类`BusinessException`和全局异常处理类`SystemExceptionHandler` ```java package com.education.common.exception; import com.education.common.utils.ResultCode; /** * 系统业务异常类 */ public class BusinessException extends RuntimeException { private ResultCode resultCode; public BusinessException(ResultCode resultCode) { this.resultCode = resultCode; } public BusinessException(String message) { super(message); } public BusinessException(Throwable throwable) { super(throwable); } public BusinessException(String message, Throwable throwable) { super(message, throwable); } public ResultCode getResultCode() { return resultCode; } public void setResultCode(ResultCode resultCode) { this.resultCode = resultCode; } } ``` ```java package com.education.common.exception; import com.education.common.utils.Result; import com.education.common.utils.ResultCode; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseBody; /** * 处理全局异常 */ @ControllerAdvice public class SystemExceptionHandler { private static final Logger logger = LoggerFactory.getLogger(SystemExceptionHandler.class); @ExceptionHandler(Exception.class) @ResponseBody public Result resolveException(Exception e) { Result result = new Result(new ResultCode(ResultCode.FAIL,ResultCode.DEFAULT_FAIL_MESSAGE)); // 判断是否为业务异常 if(e instanceof BusinessException) { BusinessException businessException = (BusinessException) e; if (businessException.getResultCode() != null) { result.setResultCode(businessException.getResultCode()); } } logger.error("系统异常",e); return result; } } ``` ### 3、Java线程池技术 #### 3.1 为什么要使用线程池? 减少了创建和销毁线程的次数,每个工作线程都可以被重复利用,可执行多个任务,降低了系统资源的消耗 #### 3.2 Web系统中使用多线程的场景 主业务程序与子业务耦合度低:**发短信或发送邮件**、**请求第三方接口** #### 3.3 线程池的执行原理 ![image-20211116003204352](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211116003204352.png) #### 3.4 线程池的销毁 ```java //等待所有正在执行的任务全试图停止所有正在执行 .shutdoen(); // 部执行完毕之后才会销毁的线程 .shutdownNow(); ``` #### 3.5 线程池的创建方法 `Executors`创建线程池 | 方法名 | 功能 | | :--------------------------------- | :--------------------------------------------------------- | | `newFixedThreadPool(int nThreads)` | 创建固定大小的线程池 | | `newSingleThreadExcutor()` | 创建只有一个线程的线程池 | | `newCachedThreadPool()` | 创建一个不限线程数上限的线程池,任何提交的任务都将立即执行 | ThreadPoolExecutor ```java // Java 线程池的完整构造函数 public ThreadPoolExecutor ( int corePoolSize, // 线程池长期维持的线程数,即使线程处于idle状态,也不会回收 int maximumPoolSize, // 线程数的上限 long keepAliveTiem, TimeUnit unit, // 超过corePoolSize的线程的idle时长 // 超过这个时间,多余的线程会被回收 BlookingQueue workQueue, // 任务的排队队列 ThreadFactory threadFactory, // 新线程的产生方式 RejectedExecutionHandler handler) // 拒绝策略 ``` #### 3.6 Spring与线程池的整合 ```java private static final int COUNT = Runtime.getRuntime().availableProcessors(); // cpu个数 private static final int CORE_SIZE = COUNT * 2; private static final int MAX_SIZE = COUNT * 4; @Bean public ThreadPoolTaskExecutor threadPoolTaskExecutor() { ThreadPoolTaskExecutor threadPoolTaskExecutor = new ThreadPoolTaskExecutor(); threadPoolTaskExecutor.setMaxPoolSize(MAX_SIZE); threadPoolTaskExecutor.setCorePoolSize(CORE_SIZE); threadPoolTaskExecutor.setQueueCapacity(20); threadPoolTaskExecutor.setKeepAliveSeconds(200); threadPoolTaskExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); return threadPoolTaskExecutor; } ``` ### 4、线程池的使用 #### 4.1 线程资源复用示例 ```java package com.education.common; import java.util.concurrent.*; /** * Unit test for simple App. */ public class AppTest { private static final int CPU_COUNT = Runtime.getRuntime().availableProcessors(); // 获取cpu数量 private static final int CORE_SIZE = CPU_COUNT * 2; private static final int MAX_CORE_SIZE = CPU_COUNT * 4; private static final int QUEUE_SIZE = 30; static class MyThread implements Runnable { private int number; public MyThread(int number) { this.number = number; } @Override public void run() { System.out.println("正在执行任务" + number + " " + Thread.currentThread()); } } public static void main(String [] args) throws ExecutionException, InterruptedException { // SynchronousQueue; 是一个无界缓存等待队列,不能指定队列容量 // ArrayBlockingQueue; 是一个游街缓存等待队列,可以指定队列容量 ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor( CORE_SIZE, // 线程池长期维持的线程数,即使线程处于idle状态,也不会回收 MAX_CORE_SIZE, // 线程数的上限 60L, TimeUnit.SECONDS, // 超过corePoolSize线程的idle时长,超过这个时间,多余的线程会被回收 // 此处设置了队列容量,当设置了队列容量后,最大并发量不能超过队列容量和线程数的总和 new LinkedBlockingQueue<>(QUEUE_SIZE)); // System.out.println("并发上限:" + (MAX_CORE_SIZE + QUEUE_SIZE)); for (int i = 0; i < 200; i++) { // 将任务添加到线程池 Future future = threadPoolExecutor.submit(new MyThread(i)); if (future.get() == null) { System.out.println(Thread.currentThread() + "任务已完成"); } // threadPoolExecutor.execute(new MyThread(i)); } } } ``` > 通过运行结果会发现线程资源被多次复用。 #### 4.2 线程池的拒绝策略 (1)修改for循环体代码: ```java for (int i = 0; i < 200; i++) { // 将任务添加到线程池 /* Future future = threadPoolExecutor.submit(new MyThread(i)); if (future.get() == null) { System.out.println(Thread.currentThread() + "任务已完成"); } */ threadPoolExecutor.execute(new MyThread(i)); } ``` (2)重新运行,会发现报错(没报错可以增大循环次数),这是因为设置了队列容量`QUEUE_SIZE`,当并发量超过线程数和队列容量的总和时,就会抛出异常 ![image-20211116134058883](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211116134058883.png) (3)可以通过`setRejectedExecutionHandler()`方法设置**拒绝策略**,在for循环上方添加以下语句: ```java threadPoolExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); ``` (4)重新运行,会发现所有任务全部执行完成 ![image-20211116140235522](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211116140235522.png) (5)修改拒绝策略 ``` threadPoolExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardOldestPolicy()); ``` (6)重新运行,会发现有部分任务没有执行,并且也没有抛出异常 ![image-20211116140507909](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211116140507909.png) #### 4.3 线程池在实际项目中的使用 (1) 在`com.education.common.utils`包下创建`SpringBeanManager`类 ```java package com.education.common.utils; import org.springframework.beans.BeansException; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Component; /** * bean 实例工具类 */ @Component @Lazy(false) public class SpringBeanManager implements ApplicationContextAware { private static ApplicationContext applicationContext = null; @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { SpringBeanManager.applicationContext = applicationContext; } public static T getBean(String name) { return (T) applicationContext.getBean(name); } public static T getBean(Class clazz) { return (T) applicationContext.getBean(clazz); } } ``` (2)创建任务监听器 ```java package com.education.service.task; /** * 任务监听器 */ public interface TaskListener { void onMessage(TaskParam taskParam); } ``` (3)创建任务管理器 ```java package com.education.service.task; import com.education.common.utils.SpringBeanManager; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; /** * 任务管理器 * @author Jason */ public class TaskManager { private ThreadPoolTaskExecutor threadPoolTaskExecutor; // private final Map taskListenerMap = new ConcurrentHashMap<>(); public TaskManager(ThreadPoolTaskExecutor threadPoolTaskExecutor) { this.threadPoolTaskExecutor = threadPoolTaskExecutor; } public void pushTask(TaskParam taskParam) { TaskListener taskListener = SpringBeanManager.getBean(taskParam.getTaskListenerClass()); if (taskListener != null) { threadPoolTaskExecutor.execute(() -> { taskListener.onMessage(taskParam); }); } } } ``` (4)封装任务参数 ```java package com.education.service.task; import java.util.HashMap; /** * 封装任务参数 * @author Jason */ public class TaskParam extends HashMap { private Class taskListenerClass; private final long timestamp; private Object data; public Class getTaskListenerClass() { return taskListenerClass; } public void setTaskListenerClass(Class taskListenerClass) { this.taskListenerClass = taskListenerClass; } public long getTimestamp() { return timestamp; } public void setData(Object data) { this.data = data; } public T getData() { return (T) data; } public TaskParam() { this.timestamp = 0L; } public TaskParam(Class taskListenerClass, long timestamp, Object data) { this.taskListenerClass = taskListenerClass; this.timestamp = timestamp; this.data = data; } public TaskParam(Class taskListenerClass, Object data) { this(taskListenerClass, System.currentTimeMillis(), data); } public TaskParam(Class taskListenerClass) { this(taskListenerClass, System.currentTimeMillis(), null); } } ``` (4)配置线程池 ```java package com.education.service.task; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import java.util.concurrent.ThreadPoolExecutor; /** * 配置线程池 */ @Configuration public class TaskBeanConfig { private static final int CPU_COUNT = Runtime.getRuntime().availableProcessors(); private static final int CORE_SIZE = CPU_COUNT * 2; private static final int MAX_CORE_SIZE = CPU_COUNT * 4; @Bean public ThreadPoolTaskExecutor threadPoolTaskExecutor() { ThreadPoolTaskExecutor threadPoolTaskExecutor = new ThreadPoolTaskExecutor(); threadPoolTaskExecutor.setCorePoolSize(CORE_SIZE); threadPoolTaskExecutor.setMaxPoolSize(MAX_CORE_SIZE); threadPoolTaskExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); return threadPoolTaskExecutor; } @Bean public TaskManager taskManager (ThreadPoolTaskExecutor threadPoolTaskExecutor) { return new TaskManager(threadPoolTaskExecutor); } } ``` (5)实现`TaskListener`接口 ```java package com.education.service.task; import org.springframework.stereotype.Component; /** */ @Component public class LogTaskListener implements TaskListener{ @Override public void onMessage(TaskParam taskParam) { System.out.println("执行LogTaskListener:" + taskParam.getData()); } } ``` (6)编写测试代码 ```java package com.education.admin.api; import com.education.service.task.TaskManager; import com.education.service.task.TaskParam; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.ApplicationContext; /** * 注解@SpringBootApplication默认扫描当前包,这样会导致无法扫描 * 到com.education.service和com.education.common下的注解, * 此时就需要设置扫描包路径为com.education */ @SpringBootApplication(scanBasePackages = "com.education") public class App { public static void main(String[] args) { ApplicationContext applicationContext = SpringApplication.run(App.class, args); // 获取TaskManager的实例 TaskManager taskManager = applicationContext.getBean(TaskManager.class); // 定义任务参数 TaskParam taskParam = new TaskParam(); taskParam.setData("test"); // 设置监听器bean实例名称 taskParam.setTaskListenerBeanName("logTaskListener"); taskManager.pushTask(taskParam); System.out.println(Thread.currentThread().getName()); } } ``` ### 5、整合Quartz #### 5.1 Quartz介绍 ##### 5.1.1 Quartz是什么? Quartz是一个功能丰富的开源任务调度框架,它可以创建简单或者复杂的几十、几百、甚至成千上万的job。此外,quartz调度器还支持JTA事务和集群 ##### 5.1.2 Quartz与Spring Task的区别 - Quartz默认多线程异步执行,而Spring Task默认单线程同步执行 - Spring Task属于轻量级任务调度框架,使用更简单 - Quartz在执行任务过程中如果抛出异常,不影响下一次任务的执行,当下一次执行时间到来时,定时器会再次执行任务。而使用Spring Task一旦某个任务在执行过程中抛出异常,则整个定时器生命周期就结束,以后永远不会再执行定时器任务。 - Quartz每次执行都创建一个新的任务类对象,Spring Task则每次使用同一个任务类对象。 ##### 5.1.3 Quartz三要素 - **Scheduler:**任务调度器,它是quartz的核心所在,所有的任务都是通过scheduler开始 - **Trigger:**任务触发器 - **JobDetail和Job:**定义任务具体执行的逻辑 ##### 5.1.4 Trigger类中常用的方法 | 方法名 | 功能 | | :-------------------------------------------- | :----------------------------- | | `withIdentity(String name, String gtoup)` | 给触发器一些属性比如名字,组名 | | `startNow()` | 立刻启动 | | `withSchedule(ScheduleBuilder schedBuilder)` | 以某种触发器触发 | | `usingJobData(String dataKey, Boolean value)` | 给具体job传递参数 | ##### 5.1.5 Cron表达式 Cron表达式用于配置`CronTrigger`的实例,由七个子表达式组成,用来描述详细的时间信息 ```apl 格式 [秒] [分] [时] [日] [月] [周] [年] ``` ##### 5.1.6 配置Cron表达式 Cron表达式的格式:秒 分 时 日 月 周 年(可选) | 字段名 | 允许的值 | 允许的特殊字符 | | :----------- | :-------------- | :----------------------------------------------------------- | | 秒 | 0—59 | , - * / | | 分 | 0—59 | , - * / | | 时 | 0—23 | , - * / | | 日 | 1—31 | , - * ?/LWC | | 月 | 1—12 或 JAN—DEC | , - * / | | 周 | 1—7 或 SUN—SAT | , - * ?/LC# | | 年(可选字段) | empty | 1970—2099, - * / | ##### 5.1.7 Cron表达式示例 | 表达式 | 含义 | | :------------------- | :----------------------------- | | `0 * * * ?` | 每一分钟触发一次 | | `0 0 10,14,16 * * ?` | 每天上午10点,下午2点,4点触发 | | `0 0 5-15 * * ?` | 每天5-15点整点触发 | | `0 0 10 * * ?` | 每天10点触发一次 | | `*/5 * * * * ?` | 每隔5秒执行一次 | #### 5.2 Quartz定时任务实例 (1)在父类工程中添加Quartz起步依赖 ```xml org.springframework.boot spring-boot-starter-quartz ``` (1)创建任务测试类 ![image-20211116171704588](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211116171704588.png) ```java package com.education.admin.api; import org.quartz.*; import org.quartz.impl.StdSchedulerFactory; import java.text.SimpleDateFormat; import java.util.Date; public class TestJob implements Job { /** * 执行具体的业务逻辑 * @param jobExecutionContext * @throws JobExecutionException */ @Override public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException { Date date = new Date(); SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); System.out.println(simpleDateFormat.format(date)); System.out.println("执行任务:" + TestJob.class.getSimpleName()); } private static final String DEFAULT_GROUP = "default_group"; public static void main(String[] args) throws SchedulerException { // 创建一个JobDetail对象 JobDetail jobDetail = JobBuilder.newJob(TestJob.class).withIdentity(TestJob.class.getSimpleName(), DEFAULT_GROUP).build(); // 创建任务触发器 CronTrigger trigger = TriggerBuilder.newTrigger().withIdentity(TestJob.class.getSimpleName(), DEFAULT_GROUP) .startNow().withSchedule(CronScheduleBuilder.cronSchedule("*/5 * * * * ?")).build(); // 创建任务调度器 Scheduler scheduler = StdSchedulerFactory.getDefaultScheduler(); scheduler.scheduleJob(jobDetail, trigger); scheduler.start(); } } ``` > `DailyTimeIntervalScheduleBuiIder`: > > - `SimpIeScheduleBuilder`:简单循环执行,设定执行次数,开始结束时间等 > - `CronScheduleBuiIder`:Cron表达式实现的定时执行 运行调试会发现每5秒钟会运行一次`execute`方法 #### 5.3 SpringBoot整合Quartz (1)在`service`模块下创建`job`包,再创建一个任务基类`BaseJob` ```java package com.education.service.job; import org.springframework.scheduling.quartz.QuartzJobBean; /** * @author Jason * @version 1.0.0 * @date 2021-11-21 00:52 */ public abstract class BaseJob extends QuartzJobBean {} ``` (2)创建系统任务类`SystemJob` ```java package com.education.service.job; import org.quartz.JobExecutionContext; import org.quartz.JobExecutionException; import java.text.SimpleDateFormat; import java.util.Date; /** */ public class SystemJob extends BaseJob { @Override protected void executeInternal(JobExecutionContext jobExecutionContext) throws JobExecutionException { Date date = new Date(); SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); System.out.println(simpleDateFormat.format(date)); System.out.println("执行任务:" + SystemJob.class.getSimpleName()); } } ``` (3)创建任务配置类`JobBeanConfig` ```java package com.education.service.job; import org.quartz.*; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; /** */ @Configuration public class JobBeanConfig { private static final String DEFAULT_GROUP = "default_group"; @Bean public JobDetail systemJob() { return JobBuilder.newJob(SystemJob.class) .withIdentity(SystemJob.class.getSimpleName(), DEFAULT_GROUP).build(); } @Bean public Trigger jobTrigger() { return TriggerBuilder.newTrigger().forJob(systemJob().getKey()) .withIdentity(SystemJob.class.getSimpleName(), DEFAULT_GROUP) .startNow().withSchedule(CronScheduleBuilder.cronSchedule("*/5 * * * * ?")).build(); } } ``` (4)修改`admin-api`入口类 ```java package com.education.admin.api; import com.education.service.task.TaskManager; import com.education.service.task.TaskParam; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.ApplicationContext; /** * 注解@SpringBootApplication默认扫描当前包,这样会 * 导致无法扫描到com.education.service和com.education.common下的注解, * 所以需要设置扫描包路径为com.education */ @SpringBootApplication(scanBasePackages = "com.education") public class App { public static void main(String[] args) { SpringApplication.run(App.class, args); /* ApplicationContext applicationContext = SpringApplication.run(App.class, args); TaskManager taskManager = applicationContext.getBean(TaskManager.class); TaskParam taskParam = new TaskParam(); taskParam.setData("test"); taskParam.setTaskListenerBeanName("logTaskListener"); taskManager.pushTask(taskParam); System.out.println(Thread.currentThread().getName()); */ } } ``` (5)启动main函数,会发现报错 ![image-20211116180616389](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211116180616389.png) (6)在`JobBeanConfig`类`systemJob`方法中添加`storeDurably()`方法 ![image-20211116185709622](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211116185709622.png) (7)重启启动main函数,会发现任务每个隔5秒钟执行一次 ![image-20211116190025780](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211116190025780.png) ## 六、RBAC权限管理 ### 1、RBAC简介 #### 1.1 RBAC权限管理 - 基于角色的权限访问控制(Role-Based Access Control) - 用户和角色关联 - 角色关联权限 #### 1.2 RBAC流程 ![image-20211116200612169](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211116200612169.png) #### 1.3 什么是权限? 权限是资源的集合,主要包括菜单、页面、字段、操作功能(增删改查)等等 - 页面权限 - 操作权限 - 数据权限 #### 1.4 为什么要使用权限? - 使用者的角度 在限制范围内正确的使用权力 - 设计者角度来说 保证系统更加安全:控制不同的角色合理的访问不同的资源 #### 1.5 RBAC中的功能模块 ![image-20211116201025255](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211116201025255.png) ### 2、RBAC权限系统数据库设计 #### 2.1 系统(System) ##### 2.1.1 系统管理员表(system_admin) | 字段名 | 类型 | KEY | 默认值 / 描述 | | :-------------- | :----------- | :---------- | :--------------- | | id | INT | PRIMARY KEY | 主键 | | login_name | VARCHAR(100) | NOT NULL | 登录名 | | name | VARCHAR(100) | | 真实姓名 | | password | VARCHAR(100) | NOT NULL | 密码 | | encrypt | VARCHAR(100) | NOT NULL | MD5盐值 | | mobile | VARCHAR(20) | NOT NULL | 手机号 | | disabled_flag | TINYINT(1) | DEFAULT 0 | 1 是 0 否 | | mail | VARCHAR(50) | | 邮箱 | | last_login_time | DATETIME | | 最后登录的时间 | | login_count | INT(11) | DEFAULT 0 | 登录次数 | | last_login_ip | VARCHAR(100) | | 最后登录的IP | | super_flag | TINYINT(1) | DEFAULT 0 | 是否为超级管理员 | | create_date | DATETIME | | 创建时间 | | update_date | DATETIME | | 更新时间 | ##### 2.1.2 系统角色表(system_role) | 字段名 | 类型 | KEY | 默认值 / 描述 | | :---------- | :----------- | :---------- | :------------ | | id | INT | PRIMARY KEY | 主键 | | role_name | VARCHAR(100) | NOT NULL | 角色名称 | | remark | VARCHAR(100) | NOT NULL | 备注 | | create_date | DATETIME | | 创建时间 | | update_date | DATETIME | | 更新时间 | ##### 2.1.3 系统菜单表(system_menu) | 字段名 | 类型 | KEY | 默认值 / 描述 | | :---------- | :----------- | :---------- | :----------------------------- | | id | INT | PRIMARY KEY | 主键 | | name | VARCHAR(100) | NOT NULL | 菜单名称 | | url | VARCHAR(100) | NOT NULL | 菜单地址 | | permission | VARCHAR(100) | NOT NULL | 权限标识 | | icon | VARCHAR(100) | | 菜单图标 | | parent_id | INT | DEFAULT 0 | 父ID | | sort | INT | DEFAULT 0 | 排序 | | type | TINYINT(2) | | 菜单类型:0 目录 1 菜单 2 按钮 | | create_date | DATETIME | | 创建时间 | | update_date | DATETIME | | 更新时间 | ##### 2.1.4 系统角色关联表(system_admin_role) | 字段名 | 类型 | KEY | 默认值 / 描述 | | :------- | :--- | :---------- | :------------ | | id | INT | PRIMARY KEY | 主键 | | role_id | INT | NOT NULL | 角色ID | | admin_id | INT | NOT NULL | 用户ID | ##### 2.1.5 角色权限关联表(system_role_menu) | 字段名 | 类型 | KEY | 默认值 / 描述 | | :------ | :--- | :---------- | :------------ | | id | INT | PRIMARY KEY | 主键 | | role_id | INT | NOT NULL | 角色ID | | menu_id | INT | NOT NULL | 菜单ID | ##### 2.1.6 系统操作日志表(system_log) | 字段名 | 类型 | KEY | 默认值 / 描述 | | :------------- | :----------- | :---------- | :--------------------------- | | id | INT | PRIMARY KEY | 主键 | | operation_name | VARCHAR(255) | | 操作详情 | | request_url | VARCHAR(255) | NOT NULL | 请求接口 | | method | VARCHAR(100) | NOT NULL | 请求方式 | | request_time | VARCHAR(100) | | 接口请求时间 | | user_id | INT(11) | | 用户ID(前台或后台用户) | | params | text | | 接口请求参数 | | exception | text | | 接口请求异常信息 | | platform_type | TINYINT(2) | DEFAULT 1 | 类型(1 系统后台 2 web前端) | #### 2.2 SQL语句 ```sql DROP DATABASE IF EXISTS `education`; CREATE DATABASE `education` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE `education`; -- ---------------------------- -- 系统管理员表 -- ---------------------------- DROP TABLE IF EXISTS `system_admin`; CREATE TABLE `system_admin` ( `id` int NOT NULL AUTO_INCREMENT COMMENT '主键', `login_name` varchar(100) NOT NULL COMMENT '登录名', `name` varchar(100) NULL COMMENT '真实姓名', `password` varchar(100) NOT NULL COMMENT '密码', `encrypt` varchar(100) NOT NULL COMMENT 'MD5盐值', `mobile` varchar(20) NOT NULL COMMENT '手机号', `disabled_flag` tinyint(1) NOT NULL DEFAULT 0 COMMENT '1 是 0 否', `mail` varchar(50) NULL COMMENT '邮箱', `last_login_time` datetime(0) NULL COMMENT '最后登录的时间', `login_count` int NULL DEFAULT 0 COMMENT '登录次数', `last_login_ip` varchar(100) NULL COMMENT '最后登录的IP', `super_flag` tinyint(1) NULL DEFAULT 0 COMMENT '是否为超级管理员', `create_date` datetime(0) NULL COMMENT '创建时间', `update_date` datetime(0) NULL COMMENT '更新时间', PRIMARY KEY (`id`) USING BTREE ) AUTO_INCREMENT = 1000 COMMENT = '系统管理员表'; -- ---------------------------- -- 系统角色关联表 -- ---------------------------- DROP TABLE IF EXISTS `system_admin_role`; CREATE TABLE `system_admin_role` ( `id` int NOT NULL AUTO_INCREMENT COMMENT '主键', `role_id` int NOT NULL COMMENT '角色ID', `admin_id` int NOT NULL COMMENT '用户ID', PRIMARY KEY (`id`) USING BTREE ) AUTO_INCREMENT = 1000 COMMENT = '系统角色关联表'; -- ---------------------------- -- 系统操作日志表 -- ---------------------------- DROP TABLE IF EXISTS `system_log`; CREATE TABLE `system_log` ( `id` int NOT NULL AUTO_INCREMENT COMMENT '主键', `operation_name` varchar(255) NULL COMMENT '操作详情', `request_url` varchar(255) NOT NULL COMMENT '请求接口', `method` varchar(100) NOT NULL COMMENT '请求方式', `request_time` datetime(0) NULL COMMENT '接口请求时间', `user_id` int NULL COMMENT '用户ID(前台或后台用户)', `params` text NULL COMMENT '接口请求参数', `exception` text NULL COMMENT '接口请求异常信息', `platform_type` tinyint NULL DEFAULT 1 COMMENT '类型(1 系统后台 2 web前端)', PRIMARY KEY (`id`) USING BTREE ) AUTO_INCREMENT = 1000 COMMENT = '系统操作日志表'; -- ---------------------------- -- 系统菜单表 -- ---------------------------- DROP TABLE IF EXISTS `system_menu`; CREATE TABLE `system_menu` ( `id` int NOT NULL AUTO_INCREMENT COMMENT '主键', `name` varchar(100) NOT NULL COMMENT '菜单名称', `url` varchar(100) NOT NULL COMMENT '菜单地址', `permission` varchar(100) NOT NULL COMMENT '权限标识', `icon` varchar(100) NULL COMMENT '菜单图标', `parent_id` int NULL DEFAULT 0 COMMENT '父ID', `sort` int NULL DEFAULT 0 COMMENT '排序', `type` tinyint NULL COMMENT '菜单类型:0 目录 1 菜单 2 按钮', `create_date` datetime(0) NULL COMMENT '创建时间', `update_date` datetime(0) NULL COMMENT '更新时间', PRIMARY KEY (`id`) USING BTREE ) AUTO_INCREMENT = 1000 COMMENT = '系统菜单表'; -- ---------------------------- -- 系统角色表 -- ---------------------------- DROP TABLE IF EXISTS `system_role`; CREATE TABLE `system_role` ( `id` int NOT NULL AUTO_INCREMENT COMMENT '主键', `role_name` varchar(100) NOT NULL COMMENT '角色名称', `remark` varchar(100) NOT NULL COMMENT '备注', `create_date` datetime(0) NULL COMMENT '创建时间', `update_date` datetime(0) NULL COMMENT '更新时间', PRIMARY KEY (`id`) USING BTREE ) AUTO_INCREMENT = 1000 COMMENT = '系统角色表'; -- ---------------------------- -- 角色权限关联表 -- ---------------------------- DROP TABLE IF EXISTS `system_role_menu`; CREATE TABLE `system_role_menu` ( `id` int NOT NULL AUTO_INCREMENT COMMENT '主键', `role_id` int NOT NULL COMMENT '角色ID', `menu_id` int NOT NULL COMMENT '菜单ID', PRIMARY KEY (`id`) USING BTREE ) AUTO_INCREMENT = 1000 COMMENT = '角色权限关联表'; ``` #### 2.3 RBAC权限系统登录流程 ##### 2.3.1 前后端分离用户登录流程 ![image-20211116210638582](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211116210638582.png) #### 2.4 JWT介绍及其使用 ##### 2.4.1 JWT简介 JWT:Json Web Token,是基于json的一个公开规范,这个规范允许我们使用JWT在用户和服务器之间传递安全可靠的信息。 ##### 2.4.2 Json Web Token的组成 **(1)Header** 头信息通常包含两部分,type:代表token的类型,这里使用的是JWT类型。alg:使用的Hash算法,例如HMAC SHA256或RSA。 例如:`{: "alg HS256", : "typ JWT" }` **(2)Payload** 荷载信息,它包含一些声明Claim(实体的描述,通常是一个User信息,还包括一些其他的元数据) **(3)signature** 签证信息,需要使用编码后的`header`和`payload`以及我们提供的一个 密钥,然后使用`header`中指定的签名算法(HS256)进行签名。签名的作用是保证JWT没有被篡改过。 ##### 2.4.3 项目实践 (1)在父工程中引入依赖 ```xml io.jsonwebtoken jjwt 0.9.1 com.alibaba fastjson 1.2.53 ``` (2)在`common`模块下创建包`model`,然后在包下创建`JwtToken`类 ```java package com.education.common.model; import com.alibaba.fastjson.JSONObject; import com.fasterxml.jackson.databind.util.JSONPObject; import io.jsonwebtoken.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.crypto.SecretKey; import javax.crypto.spec.SecretKeySpec; import javax.xml.bind.DatatypeConverter; import java.util.Date; public class JwtToken { private final String secret; private static final Logger logger = LoggerFactory.getLogger(JwtToken.class); public JwtToken(String secret) { if (secret == null) { throw new NullPointerException("secret value cant not ben null"); } this.secret = secret; } /** * 生成JWT token * @param value * @param expirationTime * @return */ public String createToken(Object value, long expirationTime) { // 生成SecretKey对象 SecretKey secretKey = this.createSecretKey(); String jsonString = JSONObject.toJSONString(value); SignatureAlgorithm hs256 = SignatureAlgorithm.HS256; long nowMillis = System.currentTimeMillis(); Date now = new Date(); JwtBuilder jwtBuilder = Jwts.builder().setIssuedAt(now).setSubject(jsonString).signWith(hs256, secretKey); if (expirationTime > 0L) { long expMills = nowMillis + expirationTime; Date exp = new Date(expMills); // 设置token过期时间 jwtBuilder.setExpiration(exp); } return jwtBuilder.compact(); } private SecretKey createSecretKey() { byte[] bytes = DatatypeConverter.parseBase64Binary(secret); return new SecretKeySpec(bytes, 0, secret.length(), "AES"); } public T parserToken(String token, Class clazz) { SecretKey secretKey = this.createSecretKey(); try { Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJwt(token).getBody(); String subject = claims.getSubject(); return (T) JSONObject.parseObject(subject); } catch (SignatureException | MalformedJwtException e) { // token签名失败 logger.error("token 签名失败", e); } catch (ExpiredJwtException e) { // token已过期 logger.error("token 已过期", e); } catch (Exception e) { logger.error("token 验证异常", e); } return null; } } ``` (3)编写单元测试方法 ```java @Test public void testToken() throws InterruptedException { JwtToken jwtToken = new JwtToken("education"); Map params = new HashMap<>(); params.put("id", 1); params.put("name", "我要自学网"); String token = jwtToken.createToken(params, 2000); System.out.println(token); Thread.sleep(6000); Map map = jwtToken.parserToken(token, Map.class); System.out.println(map); } ``` (4)测试运行,发现报错 ![image-20211116221621832](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211116221621832.png) (5)检查修正 ![image-20211116221820917](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211116221820917.png) (6)测试运行,发现能正常生成token,但解析时抛出了一个异常 ![image-20211116222239019](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211116222239019.png) (7)检查修正 ![image-20211116230853047](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211116230853047.png) (8)测试运行,运行正常 ![image-20211116231037725](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211116231037725.png) (9)修改有效时间为3秒,模拟token过期 ![image-20211116231300086](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211116231300086.png) (10)测试运行 ![image-20211116231405866](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211116231405866.png) ### 3、SpringBoot整合MyBatis #### 3.1 引入依赖 在父工程中引入以下依赖 ```xml org.mybatis.spring.boot mybatis-spring-boot-starter 2.1.1 mysql mysql-connector-java 8.0.13 ``` #### 3.2 配置数据库连接池 在`admin-api`模块的配置文件中配置数据库连接池 ```yaml spring: datasource: url: jdbc:mysql://192.168.155.128:3306/education?serverTimezone=GMT%2B8&useSSL=true&useUnicode=true&characterEncoding=utf8 driver-class-name: com.mysql.cj.jdbc.Driver username: root password: root hikari: # 数据库连接超时时间 connection-timeout: 30000 # 连接池最大连接数,默认是10 maximum-pool-size: 30 # 最小空闲连接数量 minimum-idle: 10 # 此属性控制池中连接的最长生命周期,值0表示无限生命周期 max-lifetime: 6000 ``` #### 3.3 MyBatis相关配置 ##### 3.3.1 创建`mapper`模块 (1)创建`mapper`模块 ![image-20211117001827204](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211117001827204.png) (2)修改模块`pom.xml` ![image-20211117002336930](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211117002336930.png) (3)在`service`模块的`pom`文件中加入`mapper`依赖 ```xml com.education.mapper education-mapper 1.0-SNAPSHOT ``` ##### 3.3.2 创建Mapper接口 (1)在`mapper`模块中创建基类接口`BaseMapper` ```java package com.education.mapper; import java.util.List; import java.util.Map; public interface BaseMapper { /** * 添加单条数据 * @param params * @return */ int save(Map params); /** * 修改数据 * @param params * @return */ int update(Map params); /** * 批量添加数据 * @param params * @return */ int batchSave(Map params); /** * 批量添加 * @param params * @return */ int batchUpdate(Map params); /** * 批量修改 * @param params * @return */ List> queryList(Map params); } ``` (2)创建用于操作`admin`模块数据的接口`system.SystemAdminMapper` ```java package com.education.mapper.system; public interface SystemAdminMapper extends BaseMapper { } ``` (3)在`admin-api`模块启动类上添加组件扫描注解,扫描mapper接口所在的包 ```java @MapperScan("com.education.mapper") ``` ##### 3.3.3 配置Mapper映射文件 (1)在`mapper`模块`main`目录下创建`resources/mapper`目录,并将`resources`目录设置为资源目录 ![image-20211117004057505](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211117004057505.png) (2)在`mapper`目录下创建`SystemAdminMapper.xml`文件 ```xml ``` (3)在`admin-api`模块的核心配置文件中配置MyBatis映射文件路径 ```yaml mybatis: mapper-locations: mapper/*.xml ``` ##### 3.3.4 单元测试 (1)在`admin`模块编写单元测试方法(此方法的包路径必须与启动类相同) ```java package com.education.admin.api; import com.education.mapper.system.SystemAdminMapper; import org.junit.jupiter.api.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.junit4.SpringRunner; import java.util.List; import java.util.Map; @SpringBootTest() @RunWith(SpringRunner.class) class ApplicationTests { @Autowired private SystemAdminMapper systemAdminMapper; @Test public void contextLoads() { List maps = systemAdminMapper.queryList(null); System.out.println(maps); } } ``` (8)启动测试,成功查询到记录![image-20211117175401140](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211117175401140.png) (9)在核心配置文件中开启日志功能 ```yaml mybatis: mapper-locations: mapper/*.xml configuration: # 开启MyBatis日志功能 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl ``` (10)再次启动测试,成功显示SQL语言和查询结果![image-20211117180550723](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211117180550723.png) ### 4、Shiro简介 Shiro是一个强大且易用的Java安全框架,它提供了身份验证、授权、密码和会话管理的功能。 #### 4.1 Shiro三大核心组件 | 组件名 | 功能 | | :-------------------- | :------------------------- | | **`Subject`** | 代表当前用户的安全操作 | | **`SecurityManager`** | 用来管理所有用户的安全操作 | | **`Realms`** | 身份认证器 | #### 4.2 Shiro中的重要组件 | 组件名 | 功能 | | ---------------- | ------------------------------------------------------------ | | `Authenticator` | 身份认证/登录,验证用户是不是拥有相应的身份 | | `Authorizer` | 授权,即权限验证,验证某个已认证的用户是否拥有某个权限 | | `SessionManager` | 会话管理,即用户登录后就是一-次会话,在没有退出之前,它的所有信息都在会话中 | | `CacheManager` | 缓存控制器,来管理如用户、角色、权限等的缓存的 | | `SessionDAO` | DAO大家都用过,数据访问对象,用于会话的CRUD | #### 4.3 Shiro过滤器 | 过滤器名称 | 类名 | 含义 | | ---------- | ------------------------------------------------------------ | ------------------------------ | | `anon` | `org.apache.shiro.web.filter.authc.AnonymousFilter` | 表示接口不需要登录即可访问 | | `authc` | `org.apache.shiro.web.filter.authc.FormAuthenticationFilter` | 表示需要身份认证通过后才能访问 | | `roles` | `org.apache.shiro.web.filter.authz.RolesAuthorizationFilter` | 拥有某个角色权限才能访问 | | `user` | `org.apache.shiro.web.filter.authz.UserFilter` | 表示需要身份认证通过后才能访问 | | `perms` | `org.apache.shiro.web.filter.authz.PermissionsAuthorizationFilter` | 表示需要指定权限才能访问 | #### 4.4 URL匹配模式 | 匹配符 | 含义 | | ------ | ------------------------------------------------------------ | | `?` | 匹配一个字符,如`/admin?`将匹配`/admin1`,但不匹配`/admin`或`/admin/` | | `*` | 匹配零个或多个字符串,如`/admin*`将匹配`/admin`、`/admin123`,但不匹配`/admin/1` | | `/**` | 匹配路径中的零个或多个路径,如`/admin/**`将匹配`/admin/a`或`/admin/a/b` | #### 4.5 URL匹配顺序 URL权限采取第一次匹配优先的方式,即从头开始使用第一个匹配的url模式对应的拦截器链。 如: - `/admin/ **=anon` - `/**=authc` - 如果请求的url是`/admin/getRoleList` 因为按照声明顺序进行匹配,那么将使用`anon`进行拦截。 #### 4.6 Shiro中的接口权限注解 | 注释 | 功能 | | :----------------------------------------------------------- | :----------------------------------------------------------- | | `@RequiresAuthentication` | 表示当前`Subject`已经通过`login`进行了身份验证 | | `@RequiresUser` | 表示当前`Subject`已经身份验证或者通过记住我登录的 | | `@RequiresGuest` | 表示当前`Subject`没有身份验证或通过记住我登录过,即是游客身份 | | `@RequiresRoles(value={"admin","user"},logical=Logical.AND)` | 表示当前`Subject`需要角色`admin`和`user` | | `@RequiresPermissions(value={"user:a","user:b"},logical=Logical.OR)` | 表示当前`Subject`需要权限`user:a`或`user:b` | ### 5、SpringBoot整合Shiro #### 5.1 添加依赖 ```xml org.apache.shiro shiro-core ${shiro.version} org.apache.shiro shiro-spring ${shiro.version} org.apache.shiro shiro-ehcache ${shiro.version} 1.4.0 ``` #### 5.2 配置Shiro ##### 5.2.1 创建用户身份认证器 ```java package com.education.admin.api.shiro; import org.apache.shiro.authc.AuthenticationException; import org.apache.shiro.authc.AuthenticationInfo; import org.apache.shiro.authc.AuthenticationToken; import org.apache.shiro.authz.AuthorizationInfo; import org.apache.shiro.realm.AuthorizingRealm; import org.apache.shiro.subject.PrincipalCollection; import org.springframework.stereotype.Component; /** * 用户身份认证器 */ @Component public class SystemRealm extends AuthorizingRealm { /** * 用户授权 * @param principalCollection * @return */ @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) { return null; } /** * 用户认证 * @param authenticationToken * @return * @throws AuthenticationException */ @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException { return null; } } ``` ##### 5.2.2 创建`Shiro`配置类 ```java package com.education.admin.api.shiro; import org.apache.shiro.cache.CacheManager; import org.apache.shiro.cache.ehcache.EhCacheManager; import org.apache.shiro.mgt.SecurityManager; import org.apache.shiro.realm.Realm; import org.apache.shiro.session.mgt.DefaultSessionManager; import org.apache.shiro.session.mgt.SessionManager; import org.apache.shiro.spring.web.ShiroFilterFactoryBean; import org.apache.shiro.web.mgt.DefaultWebSecurityManager; import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator; import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.io.InputStream; import java.util.LinkedHashMap; import java.util.Map; /** * Shiro bean 实例配置 */ @Configuration @AutoConfigureAfter(ShiroLifecycleBeanPostProcessorConfig.class) public class ShiroBeanConfig { private static final long INVALID_TIME = 3600 * 6 * 1000; /** * @param securityManager 参数名必须与下面的方法名一致 * @return */ @Bean public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) { ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean(); shiroFilterFactoryBean.setSecurityManager(securityManager); // 配置URL的拦截方式 Map filterChainDefinitionMap = new LinkedHashMap(); filterChainDefinitionMap.put("/test", "anon"); filterChainDefinitionMap.put("/getUser", "authc"); shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap); shiroFilterFactoryBean.setFilters(shiroFilterFactoryBean.getFilters()); return shiroFilterFactoryBean; } /** * * @param systemRealm 参数名要与同路径下的SystemRealm类名一致, * 并且SystemRealm类需要加@Component注解 * @param ehCacheManager 参数名必须与下面的方法名一致 * @return */ @Bean public SecurityManager securityManager(Realm systemRealm, CacheManager ehCacheManager) { DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(); securityManager.setRealm(systemRealm); // 设置缓存管理器 securityManager.setCacheManager(ehCacheManager); return securityManager; } @Bean public SessionManager sessionManager() { DefaultSessionManager sessionManager = new DefaultSessionManager(); // 设置session有效期为6小时 sessionManager.setGlobalSessionTimeout(INVALID_TIME); return sessionManager; } /** * 初始化缓存管理器 * @param cacheManager net.sf.ehcache.CacheManager * @return org.apache.shiro.cache.CacheManager */ @Bean public CacheManager ehCacheManager(net.sf.ehcache.CacheManager cacheManager) { EhCacheManager ehCacheManager = new EhCacheManager(); ehCacheManager.setCacheManager(cacheManager); return ehCacheManager; } @Bean public net.sf.ehcache.CacheManager cacheManager() { InputStream inputStream = this.getClass().getClassLoader().getResourceAsStream("ehcache.xml"); return net.sf.ehcache.CacheManager.create(inputStream); } /** * 开启AOP权限注解 * @return */ @Bean public DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator() { DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator(); advisorAutoProxyCreator.setProxyTargetClass(true); return advisorAutoProxyCreator; } } ``` ##### 5.2.3 缓存管理器配置文件 放在`resources`目录下命名为`ehcache.xml` ```xml ``` ##### 5.2.4 创建Shiro生命周期实例 ```java package com.education.admin.api.shiro; import org.apache.shiro.spring.LifecycleBeanPostProcessor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; /** * Shiro生命周期实例 */ @Configuration public class ShiroLifecycleBeanPostProcessorConfig { /** * Shiro生命周期处理器 * 之所以把这个类单独提取出来,是为了方便Shiro * Bean configuration中进行Autowired实例注入 * @return */ @Bean public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() { return new LifecycleBeanPostProcessor(); } } ``` #### 5.3 编写测试类 (1)在启动类中添加两个方法 ```java @GetMapping("/test") public String test() { return "test"; } @GetMapping("/getUser") public String getUser() { return "getUser"; } ``` (2)类上面添加`@RestController`注解 ```java @SpringBootApplication(scanBasePackages = "com.education") @MapperScan("com.education.mapper") @RestController public class App { ... } ``` (3)启动运行,在浏览器分别测试`localhost/test`和`localhost/getUser`,前者返回`test`,后者会跳转页面 ### 6、常用工具类 #### 6.1 基础环境 ##### 6.1.1 引入依赖 ```xml commons-codec commons-codec 1.10 commons-logging commons-logging 1.2 commons-io commons-io 2.5 org.apache.commons commons-pool2 2.3 org.springframework.boot spring-boot-starter-data-redis ``` ##### 6.1.2 在核心配置文件中添加Redis配置 ```yaml spring: # 配置Redis redis: password: redis host: 192.168.155.128 port: 6379 ``` #### 6.2 `Constants` ```java public final class Constants { // 验证码key public static final String IMAGE_CODE = "image_code"; public static int FIVE_DAY_TIME_OUT = 5 * 24 * 60 * 60 * 1000; public static final String JSON_CONTENT_TYPE = "application/json"; } ``` #### 6.3 `ObjectUtils` ```java package com.education.common.utils; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.Collection; import java.util.Date; import java.util.Map; import java.util.UUID; /** * 字符串处理工具类 */ public class ObjectUtils { /** * 判断数组是否为空 */ public static boolean isEmpty(Object[] target) { return target == null || target.length == 0 ; } public static boolean isNotEmpty(Object[] target) { return target != null && target.length > 0; } /** * 判断对象是否为空 */ public static boolean isEmpty(Object target) { if (target instanceof Collection) { Collection collection = (Collection)target; return collection == null || collection.size() == 0; } else if (target instanceof Map) { Map map = (Map)target; return map == null || map.size() == 0; } return target == null || target.toString().trim().length() == 0; } public static boolean isNotEmpty(Object target) { if (target instanceof Collection) { Collection collection = (Collection) target; return collection != null && collection.size() > 0; } else if (target instanceof Map) { Map map = (Map) target; return map != null && map.size() > 0; } return target != null && target.toString().trim().length() > 0; } /** * 将字符串转换成数组 */ public static String[] spilt(String str) { if (str == null) throw new NullPointerException("str can not be null"); return str.split(","); } public static String[] spilt(String str, String separator) { if (str == null) throw new NullPointerException("str can not be null"); return str.split(separator); } /** * 将首字母变成小写 */ public static String toLowerCaseFirst(String keyName){ return keyName.substring(0, 1).toLowerCase(); } /** * 将首字母变成大写 */ public static String totoUpperCaseFirst(String keyName) { return keyName.substring(0, 1).toUpperCase() + keyName.substring(1, keyName.length()); } /** * 生成年月日字符串 */ public static String generateFileByTime() { DateFormat format = new SimpleDateFormat("yyyy-MM-dd"); return format.format(new Date()).replaceAll("-", "/") + "/"; } /** * 生成时分秒字符串 */ public static String generateFileBySecond() { DateFormat format = new SimpleDateFormat("hh-mm-ss"); return format.format(new Date()).replaceAll("-", ""); } public static String generateUuId() { return UUID.randomUUID().toString().replaceAll("-", ""); } } ``` #### 6.4 `Result` (1)对响应结果进行封装 ```java package com.education.common.utils; /** * 对响应结果进行封装 */ public class Result { private Object data; private int code; private String message; /** * 默认响应成功 */ public Result() { this.code = ResultCode.SUCCESS; this.message = ResultCode.DEFAULT_SUCCESS_MESSAGE; } public Result(int code, String message) { this.code = code; this.message = message; } public Result(int code, String message, Object data) { this.code = code; this.message = message; this.data = data; } public Result(ResultCode resultCode) { this.code = resultCode.getCode(); this.message = resultCode.getMessage(); } public Result(ResultCode resultCode, Object data) { this.code = resultCode.getCode(); this.message = resultCode.getMessage(); this.data = data; } public boolean isSuccess() { return this.code == ResultCode.SUCCESS; } /** * 响应成功 */ public static Result success(int code, String message, T data) { return new Result(code, message, data); } /** * 响应成功 */ public static Result success(int code, String message) { return new Result(code, message); } /** * 响应成功 */ public static Result success(T data) { return new Result(ResultCode.SUCCESS, ResultCode.DEFAULT_SUCCESS_MESSAGE, data); } /** * 响应失败 */ public static Result fail(int code, String message) { return new Result(code, message); } /** * 响应失败 */ public static Result fail() { return new Result(ResultCode.FAIL, ResultCode.DEFAULT_FAIL_MESSAGE); } public T getData() { return (T) data; } public void setData(Object data) { this.data = data; } public int getCode() { return code; } public void setCode(int code) { this.code = code; if (code == ResultCode.FAIL) { this.message = "数据请求失败"; } } public String getMessage() { return message; } public void setMessage(String message) { this.message = message; } } ``` (2)响应状态码 ```java package com.education.common.utils; /** * http 响应状态码 */ public class ResultCode { public static final int SUCCESS = 1; //响应成功状态码 public static final int FAIL = 0; //响应失败状态码 public static final int NOT_AUTH = 1001; //用户未认证 public static final int NO_URL_PERMISSION = 1002; // url无权限访问状态码 public static final String DEFAULT_SUCCESS_MESSAGE = "操作成功"; public static final String DEFAULT_FAIL_MESSAGE = "系统异常"; private int code = SUCCESS; private String message; public ResultCode() { } public int getCode() { return code; } public void setCode(int code) { this.code = code; } public String getMessage() { return message; } public void setMessage(String message) { this.message = message; } public ResultCode(int code, String message) { this.code = code; this.message = message; } } ``` #### 6.5 `EncryptUtil` ```java package com.education.common.utils; import org.apache.commons.codec.binary.Hex; import org.apache.commons.codec.digest.DigestUtils; import sun.misc.BASE64Decoder; import sun.misc.BASE64Encoder; import java.io.UnsupportedEncodingException; import java.security.*; import java.util.UUID; /** * 摘要算法工具类 * @author zengjintao * 2017年3月17号上午11:40 */ public class EncryptUtil { /** * md5加密 * @param Md5key * @param salt 时间戳 * @return * @throws NoSuchAlgorithmException */ public static String getMd5(String Md5key, String salt) { String key = DigestUtils.md5Hex(Md5key) + "&" + salt; return DigestUtils.md5Hex(key); } public static String generatorKey(){ return getMd5(UUID.randomUUID().toString()); } /** * md5密码加密 * @param key * @return */ public static String getMd5(String key) { try { MessageDigest digest = MessageDigest.getInstance("md5"); byte[] bytes = digest.digest(key.getBytes()); return Hex.encodeHexString(bytes); } catch (NoSuchAlgorithmException e) { e.printStackTrace(); } return null; } /** * SHA摘要算法 * @throws NoSuchAlgorithmException */ public static String getSHA(String key) throws NoSuchAlgorithmException{ MessageDigest sha = MessageDigest.getInstance("SHA"); byte[] by = sha.digest(key.getBytes()); return Hex.encodeHexString(by); } /** * * @param salt 可以是时间戳 * @return */ public static String encodeSalt(String salt) { return DigestUtils.sha1Hex(salt); } /** * SHA加密算法 * @param key * @return */ public static String getSHA1(String key) { try { MessageDigest digest = MessageDigest.getInstance("SHA"); byte[] bytes = digest.digest(key.getBytes()); //将字节数组转换成16进制字符串 return Hex.encodeHexString(bytes); } catch (NoSuchAlgorithmException e) { e.printStackTrace(); } return null; } private static String getBase64(String str) { if (ObjectUtils.isEmpty(str)) { return null; } byte[] b = null; try { b = str.getBytes("utf-8"); } catch (UnsupportedEncodingException e) { e.printStackTrace(); } return new BASE64Encoder().encode(b); } // base64解密 public static final String getStrFromBase64(String str) { byte[] b = null; String result = null; if (str != null&&!"".equals(str.trim())) { BASE64Decoder decoder = new BASE64Decoder(); try { b = decoder.decodeBuffer(str); result = new String(b, "utf-8"); } catch (Exception e) { e.printStackTrace(); } } return result; } } ``` #### 6.5 `AdminUserSession` ```java package com.education.common.model; import java.io.Serializable; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; /** * @author zengjintao * @version 1.0 * @create_at 2020/4/27 20:46 */ public class AdminUserSession implements Serializable { private Map userMap; private Set roleIds; // 用户角色集合 private List menuList; // 用户菜单集合 public boolean isSuperAdmin() { return userMap.get("super_flag").equals(1); } public List getMenuList() { return menuList; } public void setMenuList(List menuList) { this.menuList = menuList; } public Set getPermissionList() { return permissionList; } public void setPermissionList(Set permissionList) { this.permissionList = permissionList; } public String getSessionId() { return sessionId; } public Map getUserMap() { return userMap; } public void setUserMap(Map userMap) { this.userMap = userMap; } public Set getRoleIds() { return roleIds; } public void setRoleIds(Set roleIds) { this.roleIds = roleIds; } public void setSessionId(String sessionId) { this.sessionId = sessionId; } // 用户权限标识 private Set permissionList = new HashSet<>(); private String sessionId; public AdminUserSession(Map userMap) { this.userMap = userMap; } /** * 添加权限标识 * @param permission */ public void addPermission(String permission) { permissionList.add(permission); } } ``` #### 6.6 `MapTreeUtils` ```java package com.education.common.utils; import java.util.ArrayList; import java.util.List; import java.util.Map; /** * @author zengjintao * @create 2019/7/12 20:12 * @since 1.0 **/ public class MapTreeUtils { /** * 获取子类 的所有父类结合 * @param dataList * @param parentId * @return */ public static List getParentList(List dataList, int parentId) { List parentList = new ArrayList<>(); return getParent(dataList, parentId, parentList); } private static List getParent(List dataList, int parentId, List parentList) { for (Map data : dataList) { Integer id = (Integer) data.get("value"); if (ObjectUtils.isEmpty(id)) { id = (Integer) data.get("id"); } Integer itemParentId = (Integer) data.get("parent_id"); if (parentId == id) { parentList.add(data); if (itemParentId != ResultCode.FAIL) { return getParent(dataList, itemParentId, parentList); } } } return parentList; } public static List buildTreeData(List dataList) { List result = new ArrayList<>(); List parentList = getRoot(dataList); for (Map tree : parentList) { setChildren(tree, dataList); result.add(tree); } return result; } public static List getChildrenTree(List dataList, int parentId) { List childrenTree = new ArrayList(); dataList.forEach(data -> { if ((Integer)data.get("parent_id") == parentId) { childrenTree.add(data); } }); childrenTree.forEach(data -> { setChildren(data, dataList); }); return childrenTree; } private static void setChildren(Map data, List dataList) { Integer id = (Integer) data.get("value"); if (ObjectUtils.isEmpty(id)) { id = (Integer) data.get("id"); } List children = getChildrenTree(dataList, id); if (ObjectUtils.isNotEmpty(children)) { data.put("children", children); } } /** * 获取顶级父类数据 * @param dataList * @return */ private static List getRoot(List dataList) { List rootList = new ArrayList<>(); dataList.forEach(root -> { if ((Integer)root.get("parent_id") == 0) { rootList.add(root); } }); return rootList; } } ``` #### 6.7 `CacheBean` (1)缓存接口`CacheBean` ```java package com.education.common.cache; import java.util.Collection; import java.util.concurrent.TimeUnit; public interface CacheBean { T get(String cacheName, Object key); T get(Object key); void put(String cacheName, Object key, Object value); void put(Object key, Object value); void put(Object key, Object value, int liveSeconds); void put(String cacheName, Object key, Object value, int liveSeconds); void put(String cacheName, Object key, Object value, int liveSeconds, TimeUnit timeUnit); void put(Object key, Object value, int liveSeconds, TimeUnit timeUnit); Collection getKeys(String cacheName); Collection getKeys(); void remove(Object key); void remove(); void remove(String cacheName, Object key); void removeAll(String cacheName); } ``` (2)基于Redis的缓存类 ```java package com.education.common.cache; import com.education.common.utils.ObjectUtils; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ValueOperations; import java.util.Collection; import java.util.concurrent.TimeUnit; /** * 基于 redis 缓存 */ public class RedisCacheBean implements CacheBean { private final RedisTemplate redisTemplate; private final ValueOperations valueOperations; public RedisTemplate getRedisTemplate() { return redisTemplate; } public RedisCacheBean(RedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; this.valueOperations = this.redisTemplate.opsForValue(); } @Override public T get(String cacheName, Object key) { return (T) this.valueOperations.get(this.createNewKey(cacheName, key)); } @Override public T get(Object key) { return (T) this.valueOperations.get(key); } @Override public void put(String cacheName, Object key, Object value) { this.valueOperations.set(this.createNewKey(cacheName, key), value); } private String createNewKey(String cacheName, Object key) { return String.format("%s:%s", cacheName, key); } @Override public void put(Object key, Object value) { this.valueOperations.set(key, value); } @Override public void put(Object key, Object value, int liveSeconds) { this.valueOperations.set(key, value, liveSeconds); } @Override public void put(String cacheName, Object key, Object value, int liveSeconds) { this.valueOperations.set(this.createNewKey(cacheName, key), value, liveSeconds); } @Override public void put(String cacheName, Object key, Object value, int liveSeconds, TimeUnit timeUnit) { this.valueOperations.set(this.createNewKey(cacheName, key), value, liveSeconds, timeUnit); } @Override public void put(Object key, Object value, int liveSeconds, TimeUnit timeUnit) { this.valueOperations.set(key, value, liveSeconds, timeUnit); } @Override public Collection getKeys(String cacheName) { return this.redisTemplate.keys(cacheName + "*"); } /** * 调用此方法需要设置 redisTemplate.setKeySerializer(new StringRedisSerializer()); * 否则导致返回key 值为空集合 * @return */ @Override public Collection getKeys() { return this.redisTemplate.keys("*"); } @Override public void remove(Object key) { this.redisTemplate.delete(key); } @Override public void remove() { Collection collection = redisTemplate.keys("*"); if (ObjectUtils.isNotEmpty(collection)) { collection.forEach(key -> { redisTemplate.delete(key); }); } } @Override public void remove(String cacheName, Object key) { this.redisTemplate.delete(createNewKey(cacheName, key)); } @Override public void removeAll(String cacheName) { Collection keys = getKeys(cacheName); this.redisTemplate.delete(keys); } } ``` #### 6.8 `BaseController` ```java package com.education.common.base; import com.education.common.cache.CacheBean; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; /** * controller 父类 */ public abstract class BaseController { } ``` #### 6.9 `Captcha` ```java package com.education.common.model; import com.education.common.cache.CacheBean; import javax.imageio.ImageIO; import javax.servlet.http.HttpServletResponse; import java.awt.*; import java.awt.image.BufferedImage; import java.io.IOException; import java.util.Random; import java.util.concurrent.TimeUnit; /** * 生成图片验证码 */ public class Captcha { private CacheBean redisCacheBean; private String key; public Captcha(CacheBean redisCacheBean, String key) { this.redisCacheBean = redisCacheBean; this.key = key; } private static final int WIDTH = 114; private static final int HEIGHT = 35; protected static final Random random = new Random(System.nanoTime()); // 验证码随机字符数组 protected static final char[] charArray = "3456789ABCDEFGHJKMNPQRSTUVWXY".toCharArray(); // 验证码字体 protected static final Font[] RANDOM_FONT = new Font[] { new Font(Font.DIALOG, Font.BOLD, 33), new Font(Font.DIALOG_INPUT, Font.BOLD, 34), new Font(Font.SERIF, Font.BOLD, 33), new Font(Font.SANS_SERIF, Font.BOLD, 34), new Font(Font.MONOSPACED, Font.BOLD, 34) }; public void render(HttpServletResponse response) { BufferedImage image = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB); Graphics graphics = image.getGraphics(); // 设置背景 setBackground(graphics); setBorder(graphics); setRandomLine(graphics); String num = serRandomNum(graphics); // 将按证明保存到Redis缓存中,有效时间为60秒 redisCacheBean.put(this.key, num, 60, TimeUnit.SECONDS); response.setHeader("Pragma","no-cache"); response.setHeader("Cache-Control","no-cache"); response.setDateHeader("Expires", 0); response.setContentType("image/jpeg"); try { ImageIO.write(image, "jpg", response.getOutputStream()); } catch (IOException e) { e.printStackTrace(); } } /** * 生成随机数 * @param graphics * @return */ private String serRandomNum(Graphics graphics) { graphics.setFont(RANDOM_FONT[new Random().nextInt(RANDOM_FONT.length)]); int x = 8; StringBuilder sb = new StringBuilder(); for (int i = 0; i < 4; i++) { graphics.setColor(getRandColor(25, 130)); String value = String.valueOf(charArray[random.nextInt(charArray.length)]); sb.append(value); graphics.drawString(value, x, 28); x += 28; } return sb.toString(); } /** * 设置线条 * @param graphics */ private void setRandomLine(Graphics graphics) { graphics.setColor(getRandColor(20, 250)); for (int i = 0; i < 15; i++) { int x1 = new Random().nextInt(WIDTH); int x2 = new Random().nextInt(WIDTH); int y1 = new Random().nextInt(HEIGHT); int y2 = new Random().nextInt(HEIGHT); graphics.drawLine(x1, y1, x2, y2); } } /* * 给定范围获得随机颜色 */ protected Color getRandColor(int fc, int bc) { Random random = new Random(); if (fc > 255) fc = 255; if (bc > 255) bc = 255; int r = fc + random.nextInt(bc - fc); int g = fc + random.nextInt(bc - fc); int b = fc + random.nextInt(bc - fc); return new Color(r, g, b); } /** * 设置边框 * @param graphics */ private void setBorder(Graphics graphics) { graphics.setColor(getRandColor(210, 250)); graphics.drawRect(1, 1, WIDTH - 2, HEIGHT - 2); } /** * 设置背景颜色 * @param graphics */ private void setBackground(Graphics graphics) { graphics.setColor(getRandColor(210, 250)); graphics.fillRect(0,0, WIDTH, HEIGHT); } } ``` #### 6.10 `@DisabledResubmit` ```java package com.education.common.annotation; import java.lang.annotation.*; /** * 限制表单重复提交注解类 */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface DisabledResubmit { // 如果五秒内重复请求某一接口,则视为表单的重复提交 int timeOut() default 5; int errorCode() default 0; String message() default ""; } ``` #### 6.11 `@SystemLog`注解 ```java package com.education.common.annotation; import java.lang.annotation.*; /** * 日志注解类 */ @Target(ElementType.METHOD) @Documented @Retention(RetentionPolicy.RUNTIME) public @interface SystemLog { /** * 操作描述 * @return */ String describe(); } ``` ### 7、Shiro实现用户登录接口 #### 7.1 编写图片验证码接口 ```java package com.education.admin.api.controller; import com.education.common.annotation.DisabledResubmit; import com.education.common.annotation.SystemLog; import com.education.common.base.BaseController; import com.education.common.model.Captcha; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; /** * 图片验证码接口 */ @RestController public class ImageController extends BaseController { /** * 生成验证码 * @param request * @param response */ @GetMapping("/image") @SystemLog(describe = "生成验证码") public void image(HttpServletRequest request, HttpServletResponse response) { String key = request.getParameter("key"); Captcha captcha = new Captcha(redisCacheBean, key); captcha.render(response); } @GetMapping("test") @DisabledResubmit(message = "fail") public String test() { return "success"; } } ``` #### 7.2 修改Controller父类 ```java public abstract class BaseController { // 注入缓存接口 @Autowired protected CacheBean redisCacheBean; } ``` #### 7.3 编写登录接口 ```java package com.education.admin.api.controller; import com.education.common.base.BaseController; import com.education.common.utils.Result; import com.education.common.utils.ResultCode; import com.education.service.system.SystemAdminService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; import javax.servlet.http.HttpServletRequest; import java.util.Map; /** * @author Jason */ @RestController public class LoginController extends BaseController { @Autowired private SystemAdminService systemAdminService; @PostMapping("/login") public Result login(HttpServletRequest request, @RequestBody Map params) { String userName = (String) params.get("userName"); String password = (String) params.get("password"); String imageCode = (String) params.get("code"); String key = params.get("key").toString(); String cacheCode = redisCacheBean.get(key); if (!cacheCode.equalsIgnoreCase(imageCode)) { return Result.fail(ResultCode.FAIL,"验证码错误"); } return systemAdminService.login(userName, password); } } ``` #### 7.4 创建业务父类 ```java package com.education.service; import com.education.common.cache.EhcacheBean; import com.education.mapper.BaseMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; /** * service 父类 * @author Jason */ public abstract class BaseService { @Autowired protected M mapper; protected static final Logger logger = LoggerFactory.getLogger(EhcacheBean.class); } ``` #### 7.5 编写登录业务 ```java package com.education.service.system; import com.education.common.exception.BusinessException; import com.education.common.utils.ObjectUtils; import com.education.common.utils.Result; import com.education.common.utils.ResultCode; import com.education.mapper.system.SystemAdminMapper; import com.education.service.BaseService; import org.apache.shiro.SecurityUtils; import org.apache.shiro.authc.UnknownAccountException; import org.apache.shiro.authc.UsernamePasswordToken; import org.apache.shiro.subject.Subject; import org.springframework.stereotype.Service; /** * 管理员service * @author Jason */ @Service public class SystemAdminService extends BaseService { public Result login(String userName, String password) { Result result = new Result(new ResultCode(ResultCode.SUCCESS, "登录成功")); Subject subject = SecurityUtils.getSubject(); UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(userName, password); try { // 执行用户登录 subject.login(usernamePasswordToken); } catch (Exception e) { if (e instanceof UnknownAccountException) { result = new Result(ResultCode.FAIL, "用户不存在"); } else { result = new Result(ResultCode.FAIL, "用户名或密码错误"); } // 抓取账号禁用异常 Throwable throwable = e.getCause(); if (ObjectUtils.isEmpty(throwable)) { if (throwable instanceof BusinessException) { result.setMessage(throwable.getMessage()); } } logger.error("登录失败", e); } return result; } } ``` #### 7.6 修改用户认证器 ```java package com.education.admin.api.shiro; import com.education.common.exception.BusinessException; import com.education.common.model.AdminUserSession; import com.education.common.utils.EncryptUtil; import com.education.common.utils.ObjectUtils; import com.education.common.utils.ResultCode; import com.education.common.utils.SpringBeanManager; import com.education.mapper.system.SystemAdminMapper; import org.apache.shiro.authc.*; import org.apache.shiro.authz.AuthorizationInfo; import org.apache.shiro.realm.AuthorizingRealm; import org.apache.shiro.subject.PrincipalCollection; import org.springframework.stereotype.Component; import java.util.Map; /** * 用户身份认证器 */ @Component public class SystemRealm extends AuthorizingRealm { private static final String DISABLED_FLAG = "disabled_flag"; /** * 用户授权 */ @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) { return null; } /** * 用户认证 */ @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken) token; String userName = usernamePasswordToken.getUsername(); SystemAdminMapper systemAdminMapper = SpringBeanManager.getBean(SystemAdminMapper.class); Map userInfoMap = systemAdminMapper.findByLoginName(userName); // 判断用户是否存在 if (ObjectUtils.isEmpty(userInfoMap)) { throw new UnknownAccountException("用户不存在"); } else if (userInfoMap.get(DISABLED_FLAG).equals(1)) { throw new BusinessException(new ResultCode(ResultCode.FAIL, "账号已禁用")); } // 验证用户密码 String password = EncryptUtil.getMd5(new String(usernamePasswordToken.getPassword()), (String) userInfoMap.get("encrypt")); usernamePasswordToken.setPassword(password.toCharArray()); return new SimpleAuthenticationInfo(new AdminUserSession(userInfoMap), userInfoMap.get("password"), getName()); } } ``` #### 7.7 创建Mapper登录接口 ```java package com.education.mapper.system; import com.education.mapper.BaseMapper; import java.util.Map; public interface SystemAdminMapper extends BaseMapper { /** * 按照用户名查找记录 * @param userName 用户名 * @return 用户记录 */ Map findByLoginName(String userName); } ``` #### 7.8 编写MyBatis映射文件 ```xml insert into system_admin ${key} values #{value} ``` #### 7.9 添加超级管理员 使用测试方法添加超级管理员`admin`,密码`123456` ```java @SpringBootTest @RunWith(SpringRunner.class) public class AppTest { @Autowired private SystemAdminMapper systemAdminMapper; @Test public void contextLoads() { String encrypt = EncryptUtil.getMd5(String.valueOf(System.currentTimeMillis())); String password = EncryptUtil.getMd5("123456", String.valueOf(System.currentTimeMillis())); Map map = new HashMap<>(); map.put("login_name", "admin"); map.put("password", password); map.put("encrypt", encrypt); map.put("super_flag", 1); map.put("mobile", "18426729672"); systemAdminMapper.save(map); System.out.println(map); } } ``` #### 7.10 导入前端项目 前端项目仓库地址:[https://gitee.com/jasondom/education-admin-front](https://gitee.com/jasondom/education-admin-front) 基础环境:Node v16.13.0、npm v8.1.3 **注**:如果要在IDEA中开发Vue需要下载Vue插件([环境配置教程](https://editor.csdn.net/md/?articleId=121479294)) ##### 7.10.1 Vue前端项目结构说明 ![image-20211123020500065](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211123020500065.png) ##### 7.10.2 安装项目依赖 在IDEA中导入项目,在项目控制台中输入`npm install`安装项目依赖(可能会报错,可自行百度) #### 7.11 对接图片验证码接口 ##### 7.11.1 使用代理,解决接口跨域问题 ![image-20211123021954827](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211123021954827.png) **注**:若未开启代理,会给所有API的URL请求添加`/proxyApi`上下文,若开启了代理,所有带`/proxyApi`的请求将被替换成`pathRewrite`中的第二个参数 ![image-20211123023354304](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211123023354304.png) ##### 7.11.2 绑定验证码标签 ![image-20211123023936550](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211123023936550.png) ![image-20211123024057485](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211123024057485.png) ![image-20211123024122390](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211123024122390.png) ##### 7.11.3 配置启动参数 ![image-20211122201639362](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211122201639362.png) ##### 7.11.4 启动测试 启动前后端项目,待项目完全启动后,在浏览器中访问:[http://localhost:8001/#/login](http://localhost:8001/#/login),显示验证码就表示与后端接口对接成功(没显示可以尝试点击验证码图片位置刷新验证码) ![image-20211122202637271](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211122202637271.png) #### 7.12 登录接口对接 ##### 7.12.1 绑定登录标签 ![image-20211123025150891](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211123025150891.png) ![image-20211123025218429](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211123025218429.png) ##### 7.12.2 测试登录成功 ![image-20211123025649341](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211123025649341.png) #### 7.13 编写用户菜单和权限业务 ##### 7.13.1 封装Token ```java package com.education.common.model; public class JwtToken { private final String secret; private static final Logger logger = LoggerFactory.getLogger(JwtToken.class); public JwtToken(String secret) { if (secret == null) { throw new NullPointerException("secret value cant not ben null"); } this.secret = secret; } /** * 生成jwt token * @param value * @param expirationTime * @return */ public String createToken(Object value, long expirationTime) { // 生成SecretKey 对象 SecretKey secretKey = this.createSecretKey(); String jsonValue = JSONObject.toJSONString(value); SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256; long nowMillis = System.currentTimeMillis(); Date now = new Date(); JwtBuilder jwtBuilder = Jwts.builder().setIssuedAt(now).setSubject(jsonValue).signWith(signatureAlgorithm, secretKey); if (expirationTime > 0L) { long expMillis = nowMillis + expirationTime; Date exp = new Date(expMillis); // 设置token过期时间 jwtBuilder.setExpiration(exp); } return jwtBuilder.compact(); } private SecretKey createSecretKey() { byte[] bytes = DatatypeConverter.parseBase64Binary(secret); return new SecretKeySpec(bytes, 0, bytes.length, "AES"); } /** * 解析token * @param token * @param clazz * @param * @return */ public T parserToken(String token, Class clazz) { SecretKey secretKey = this.createSecretKey(); try { Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody(); String subject = claims.getSubject(); return (T) JSONObject.parseObject(subject, clazz); } catch (SignatureException | MalformedJwtException e) { // token 签名失败 logger.error("token 签名失败", e); } catch (ExpiredJwtException e) { // token 已过期 logger.error("token 已过期", e); } catch (Exception e) { logger.error("token验证异常", e); } return null; } } ``` ##### 7.13.2 封装用户session ```java package com.education.common.model; public class AdminUserSession implements Serializable { private Map userMap; /** * 用户角色集合 */ private Set roleIds; /** * 用户菜单集合 */ private List menuList; public boolean isSuperAdmin() { return userMap.get("super_flag").equals(1); } public List getMenuList() { return menuList; } public void setMenuList(List menuList) { this.menuList = menuList; } public Set getPermissionList() { return permissionList; } public void setPermissionList(Set permissionList) { this.permissionList = permissionList; } public String getSessionId() { return sessionId; } public Map getUserMap() { return userMap; } public void setUserMap(Map userMap) { this.userMap = userMap; } public Set getRoleIds() { return roleIds; } public void setRoleIds(Set roleIds) { this.roleIds = roleIds; } public void setSessionId(String sessionId) { this.sessionId = sessionId; } // 用户权限标识 private Set permissionList = new HashSet<>(); private String sessionId; public AdminUserSession(Map userMap) { this.userMap = userMap; } /** * 添加权限标识 * @param permission */ public void addPermission(String permission) { permissionList.add(permission); } } ``` ##### 7.13.3 业务处理 ###### (1)系统管理员业务层 ```java package com.education.service.system; @Service public class SystemAdminService extends BaseService { @Autowired private SystemMenuService systemMenuService; @Autowired private SystemAdminRoleService systemAdminRoleService; @Autowired private SystemRoleMenuService systemRoleMenuService; public Result login(String userName, String password) { Result result = new Result(new ResultCode(ResultCode.SUCCESS, "登录成功")); Subject subject = SecurityUtils.getSubject(); UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(userName, password); try { // 执行用户登录 subject.login(usernamePasswordToken); } catch (Exception e) { if (e instanceof UnknownAccountException) { result = new Result(ResultCode.FAIL, "用户不存在"); } else { result = new Result(ResultCode.FAIL, "用户名或密码错误"); } // 判断是否为业务异常 Throwable throwable = e.getCause(); if (ObjectUtils.isNotEmpty(throwable) && throwable instanceof BusinessException) { String message = ((BusinessException) throwable).getResultCode().getMessage(); result.setMessage(message); } logger.error(result.getMessage(), e); } return result; } /** * 获取用户菜单及其权限 */ public void loadUserMenuAndPermission(AdminUserSession adminUserSession) { List menuList = null; // 如果是超级管理员,加载所有菜单及其权限 if (adminUserSession.isSuperAdmin()) { menuList = systemMenuService.queryList(null); } else { Integer id = (Integer) adminUserSession.getUserMap().get("id"); // 先查询用户角色 List roleList = systemAdminRoleService.findRoleListByAdminId(id); if (ObjectUtils.isEmpty(roleList)) { HashSet roleIds = new HashSet<>(); roleList.forEach(roleInfo -> { roleIds.add((Integer) roleInfo.get("role_id")); }); adminUserSession.setRoleIds(roleIds); // 根据角色获取菜单列表 Map params = new HashMap<>(0); params.put("roleIds", roleIds); menuList = systemRoleMenuService.getMenuListByRoleIds(params); } } // 获取权限 menuList.forEach(menu -> { // 用户权限标识 String permission = (String) menu.get("permission"); adminUserSession.addPermission(permission); }); // 将菜单转换成tree结构 List treeMenuList = MapTreeUtils.buildTreeData(menuList); adminUserSession.setMenuList(treeMenuList); } public int update(Map params) { return mapper.update(params); } } ``` ###### (2)菜单业务层 ```java package com.education.service.system; @Service public class SystemMenuService extends BaseService { public List queryList(Map params) { return mapper.queryList(params); } } ``` ###### (3)用户角色业务层 ```java package com.education.service.system; @Service public class SystemAdminRoleService extends BaseService { public List findRoleListByAdminId(Integer adminId) { return mapper.findRoleListByAdminId(adminId); } } ``` ###### (4)角色菜单管理业务层 ```java package com.education.service.system; @Service public class SystemRoleMenuService extends BaseService { public List getMenuListByRoleIds(Map params) { return mapper.getMenuListByRoleIds(params); } } ``` ##### 7.13.4 Mapper接口与映射文件 ###### (1)菜单Mapper ```java package com.education.mapper.system; public interface SystemMenuMapper extends BaseMapper { } ``` ```xml ``` ###### (2)用户角色Mapper ```java package com.education.mapper.system; public interface SystemAdminRoleMapper extends BaseMapper { List findRoleListByAdminId(Integer adminId); } ``` ```xml ``` ###### (3)角色菜单管理Mapper ```java package com.education.mapper.system; public interface SystemRoleMenuMapper extends BaseMapper { List getMenuListByRoleIds(@Param("params") Map params); } ``` ```xml ``` ##### 7.13.5 修改登录处理 ```java package com.education.admin.api.controller; @RestController public class LoginController extends BaseController { @Autowired private SystemAdminService systemAdminService; @Autowired private JwtToken jwtToken; @PostMapping("/login") public Result login(HttpServletRequest request, @RequestBody Map params) { String userName = (String) params.get("userName"); String password = (String) params.get("password"); String imageCode = (String) params.get("code"); String key = params.get("key").toString(); String cacheCode = redisCacheBean.get(key); if (ObjectUtils.isEmpty(cacheCode) || !cacheCode.equalsIgnoreCase(imageCode)) { return Result.fail(ResultCode.FAIL,"验证码错误"); } Result result = systemAdminService.login(userName, password); if (result.isSuccess()) { // 获取用户信息 AdminUserSession adminUserSession = systemAdminService.getAdminUserSession(); // 加载用户菜单及其权限标记 systemAdminService.loadUserMenuAndPermission(adminUserSession); // 清除map复用 params.clear(); // 创建token String token = jwtToken.createToken(adminUserSession.getUserMap().get("id"), Constants.FIVE_DAY_TIME_OUT); params.put("token", token); Map userInfo = new HashMap<>(0); userInfo.put("id", adminUserSession.getUserMap().get("id")); userInfo.put("menuList", adminUserSession.getMenuList()); userInfo.put("permissionList", adminUserSession.getPermissionList()); userInfo.put("login_name", adminUserSession.getUserMap().get("login_name")); params.put("userInfo", userInfo); result.setData(params); } return result; } } ``` ##### 7.13.6 修改前端项目 ###### (1)编写获取菜单session脚本 ![image-20211123141838959](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211123141838959.png) ###### (2)绑定变量到侧边栏标签 ![image-20211123141937268](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211123141937268.png) ##### 7.13.7 启动前后端测试结果 ![image-20211123142750540](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211123142750540.png) #### 7.14 更新用户登录信息 ##### 7.14.1 添加获取IP的工具类 ```java package com.education.common.utils; public class IpUtils { private static final Logger logger = LoggerFactory.getLogger(IpUtils.class); private static final String UNKNOWN = "unknown"; /** * 获取客户端ip地址 * @return */ public static String getAddressIp(HttpServletRequest request) { String ip = request.getHeader("x-forwarded-for"); if (ip == null || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip)) { ip = request.getHeader("Proxy-Client-IP"); } if (ip == null || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip)) { ip = request.getHeader("WL-Proxy-Client-IP"); } if (ip == null || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip)) { ip = request.getHeader("HTTP_CLIENT_IP"); } if (ip == null || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip)) { ip = request.getHeader("HTTP_X_FORWARDED_FOR"); } if (ip == null || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip)) { ip = request.getRemoteAddr(); } return ip; } private static final String IP_SERVICE_URL = "http://ip.taobao.com/service/getIpInfo.php?ip="; /** * 获取ip地址 * @param ip * @return */ public static String getIpAddress(String ip){ final String code = "code"; String address = null; try { // {"code":0,"data":{"country":"中国","country_id":"CN","area":"华北","area_id":"100000","region":"北京市","region_id":"110000","city":"北京市","city_id":"110100","county":"","county_id":"-1","isp":"阿里巴巴","isp_id":"100098","ip":"47.94.12.108"}} String data = HttpKit.get(IP_SERVICE_URL + ip); JSONObject jsonObject = JSONObject.parseObject(data); if (ObjectUtils.isNotEmpty(jsonObject)) { if (jsonObject.getInteger(code) == 0) { JSONObject result = (JSONObject)jsonObject.get("data"); address = (String) result.get("country") + result.get("region") + result.get("city"); } } return address; } catch (Exception e) { logger.error("获取ip地址异常", e); } return null; } private static final String IP_URL = "https://apis.map.qq.com/ws/location/v1/ip"; public static final String LBS_KEY = "MYOBZ-OOEW3-KYC3G-YWDXA-DMQJ6-SPBMH"; public static String getAddressByIp(String ip) { final String china = "中国"; Map params = new HashMap<>(0); params.put("ip", ip); params.put("key", LBS_KEY); try { String content = HttpKit.get(IP_URL, params); JSONObject jsonObject = JSONObject.parseObject(content); Integer status = jsonObject.getInteger("status"); System.out.println(status); // 表示接口请求成功 if (status == 0) { JSONObject result = (JSONObject) jsonObject.get("result"); Map adInfo = (Map) result.get("ad_info"); String nation = (String) adInfo.get("nation"); if (china.equals(nation)) { nation = ""; } // 获取省份 String province = (String) adInfo.get("province"); String city = (String) adInfo.get("city"); return nation + province + city; } } catch (Exception e) { logger.error(e.getMessage(), e); } return null; } } ``` ##### 7.14.2 修改登录处理方法 ```java package com.education.admin.api.controller; @RestController public class LoginController extends BaseController { ... @PostMapping("/login") public Result login(HttpServletRequest request, @RequestBody Map params) { ... if (result.isSuccess()) { ... // 更新用户登录信息 TaskParam taskParam = new TaskParam(LoginSuccessListener.class); // 获取之前的用户登录次数 int login_count = (int) adminUserSession.getUserMap().get("login_count"); taskParam.put("login_count", ++login_count); taskParam.put("login_ip", IpUtils.getAddressIp(request)); taskParam.put("last_login_time", new Date()); taskParam.put("id", adminUserSession.getUserMap().get("id")); taskManager.pushTask(taskParam); } ... } } ``` **注**:采用异步更新用户登录信息 ##### 7.14.3 监听用户登录成功消息 ```java package com.education.service.task; @Component public class LoginSuccessListener implements TaskListener { @Autowired private SystemAdminService systemAdminService; @Override public void onMessage(TaskParam taskParam) { systemAdminService.update(taskParam); } } ``` ##### 7.14.4 修改管理员Mapper映射文件 ```xml ... update system_admin ${key} = #{params[${key}]} where id = #{params.id} ``` ##### 7.14.5 启动测试 登录成功后,数据库中该用户记录的`login_count`,`login_ip`,`last_login_time`字段都会被更新 ![image-20211123144814583](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211123144814583.png) ### 8、参数校验拦截器 #### 8.1 为何要做后台参数校验? 保证用户输入数据的合法性,提高系统的安全性和健壮性 #### 8.2 参数校验框架 - 对字段进行挨个判断 - Validation - 基于拦截器 + 注解形式实现参数校验 #### 8.3 基于拦截器 + 注解形式实现参数校验 ##### 8.3.1 创建参数校验`Param`注解 ```java package com.education.common.annotation; import java.lang.annotation.*; @Target({ElementType.ANNOTATION_TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface Param { // 需要校验字段名称 String name(); String message() default ""; // 正则表达式校验 String regexp() default ""; // 正则校验失败提示 String regexpMessage() default ""; int errorCode() default 0; } ``` ##### 8.3.2 创建`ParamType`枚举类 ```java package com.education.common.annotation; public enum ParamsType { FORM_DATA, JSON_DATA; } ``` ##### 8.3.3 创建参数校验注解 ```java package com.education.common.annotation; import java.lang.annotation.*; @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface ParamsValidate { Param[] params(); ParamsType paramsType() default ParamsType.FORM_DATA; } ``` ##### 8.3.4 添加Request工具类 ```java package com.education.common.utils; public class RequestUtils { static final Set PORT_SET = new HashSet() { { add(80); add(443); } }; public static HttpServletRequest getRequest() { try { return ((ServletRequestAttributes) (RequestContextHolder.currentRequestAttributes())).getRequest(); } catch (Exception e) { return null; } } public static String readData(HttpServletRequest request) { BufferedReader br = null; try { StringBuilder ret; br = request.getReader(); String line = br.readLine(); if (line != null) { ret = new StringBuilder(); ret.append(line); } else { return ""; } while ((line = br.readLine()) != null) { ret.append('\n').append(line); } return ret.toString(); } catch (IOException e) { throw new RuntimeException(e); } } public static String getRequestUrl(HttpServletRequest request) { String contentPath = request.getServletContext().getContextPath(); int contentPathLength = contentPath.length(); String target = request.getRequestURI(); if (contentPathLength != 0){ target = target.substring(contentPathLength); } return target; } /** * 获取地址栏域名 * @return */ public static String getDomain() { HttpServletRequest request = getRequest(); String scheme = request != null ? request.getScheme() : null; //得到协议名 例如:http String serverName = request != null ? request.getServerName() : null; //得到域名 localhost int port = request.getServerPort(); if (!PORT_SET.contains(port)) { return scheme + "://" + serverName + ":" + port; } String domain = scheme + "://" + serverName; if (domain.contains("www.")) { domain = domain.replaceAll("www.", ""); } return domain; } public static String getUploadDomain() { return getDomain() + "/uploads"; } public static HttpServletResponse getResponse() { try { return ((ServletRequestAttributes) (RequestContextHolder.currentRequestAttributes())).getResponse(); } catch (Exception e) { return null; } } public static InputStream getInputStreamFromUrl(String url) { try { URL resource = new URL(url); URLConnection connection = resource.openConnection(); return connection.getInputStream(); } catch (Exception e) { // log.error("获取流异常,请检查url是否正确", e); } return null; } public static String getCookieValue(String name) { Cookie cookie = getCookie(name); return ObjectUtils.isNotEmpty(cookie) ? cookie.getValue() : null; } public static Cookie getCookie(String cookieName) { HttpServletRequest request = getRequest(); Cookie[] cookies = request.getCookies(); if (cookies != null) { for (Cookie cookie : cookies) { if (cookie.getName().equals(cookieName)) { return cookie; } } } return null; } /** * 清空cookie * @param cookieName */ public static void clearCookie(String cookieName) { Cookie cookie = getCookie(cookieName); HttpServletResponse response = getResponse(); if (ObjectUtils.isNotEmpty(cookie)) { cookie.setMaxAge(0); // 清除cookie cookie.setPath("/"); response.addCookie(cookie); } } public static void createCookie(String cookieName, String value, int maxAgeInSeconds) { Cookie cookie = new Cookie(cookieName, value); cookie.setHttpOnly(true); cookie.setPath("/"); cookie.setMaxAge(maxAgeInSeconds); HttpServletResponse response = RequestUtils.getResponse(); response.addCookie(cookie); } } ``` ##### 8.3.5 创建拦截器基类 ```java package com.education.common.interceptor; public abstract class BaseInterceptor implements HandlerInterceptor { private static final String contentType = "application/json; charset=utf-8"; protected void renderJson(HttpServletResponse response, Result result) { String dataJson = JSONObject.toJSONString(result); PrintWriter writer = null; try { response.setHeader("Pragma", "no-cache"); response.setHeader("Cache-Control", "no-cache"); response.setDateHeader("Expires", 0); response.setContentType(contentType); writer = response.getWriter(); writer.write(dataJson); writer.flush(); return; } catch (IOException e) { throw new RuntimeException(e); } } protected String getRequestUrl(HttpServletRequest request) { return RequestUtils.getRequestUrl(request); } /** * 获取json 参数值 * @param request * @return */ protected String readData(HttpServletRequest request) { return RequestUtils.readData(request); } } ``` ##### 8.3.6 创建参数校验拦截器 ```java package com.education.common.interceptor; @Component public class ParamsValidateInterceptor extends BaseInterceptor { /** * controller方法执行前调用 * @param request * @param response * @param handler * @return * @throws Exception */ @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (handler instanceof HandlerMethod) { HandlerMethod handlerMethod = (HandlerMethod) handler; // 获取执行的目标方法 Method method = handlerMethod.getMethod(); ParamsValidate paramsValidate = method.getAnnotation(ParamsValidate.class); if (ObjectUtils.isNotEmpty(paramsValidate)) { return checkParam(request, response, paramsValidate); } } return true; } private boolean checkParam(HttpServletRequest request, HttpServletResponse response, ParamsValidate paramsValidate) { Param[] params = paramsValidate.params(); ParamsType paramsType = paramsValidate.paramsType(); boolean isJsonData = false; Map dataMap = null; if (paramsType == ParamsType.JSON_DATA) { isJsonData = true; String data = readData(request); dataMap = JSONObject.parseObject(data); } for (Param param : params) { Object value = null; String name = param.name(); if (isJsonData) { value = dataMap.get(name); } else { value = request.getParameter(name); } if (ObjectUtils.isEmpty(value)) { renderJson(response, Result.fail(param.errorCode(), param.message())); return false; } else { String regexp = param.regexp(); if (ObjectUtils.isNotEmpty(regexp)) { boolean regexFlag = RegexUtils.compile(regexp, value); if (!regexFlag) { renderJson(response, Result.fail(param.errorCode(), param.regexpMessage())); return false; } } } } return true; } } ``` ##### 8.3.7 添加正则校验工具 ```java package com.education.common.annotation; @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface ParamsValidate { Param[] params(); ParamsType paramsType() default ParamsType.FORM_DATA; } ``` ##### 8.3.8 添加字节流过滤器 ```java package com.education.common.interceptor; /** * 将字节流保存到字节数组中,解决字节流不可重复读的问题 * @author Jason * @version 1.0.0 * @date 2021-11-23 16:14 */ @Component public class StreamFilter implements Filter { @Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { ServletRequest requestWrapper = null; if (servletRequest instanceof HttpServletRequest) { requestWrapper = new BodyReaderHttpServletRequestWrapper((HttpServletRequest) servletRequest); } if (requestWrapper == null) { filterChain.doFilter(servletRequest, servletResponse); } else { filterChain.doFilter(requestWrapper, servletResponse); } } static class BodyReaderHttpServletRequestWrapper extends HttpServletRequestWrapper { private final byte[] body; public BodyReaderHttpServletRequestWrapper(HttpServletRequest request) throws IOException { super(request); this.body = toByteArray(request.getInputStream()); } private byte[] toByteArray(InputStream in) throws IOException { ByteArrayOutputStream out = new ByteArrayOutputStream(); byte[] buffer = new byte[1024 * 4]; int i = 0; while ((i = in.read(buffer)) != -1) { out.write(buffer, 0, i); } return out.toByteArray(); } @Override public ServletInputStream getInputStream() throws IOException { ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(this.body); return new ServletInputStreamImpl(byteArrayInputStream); } @Override public BufferedReader getReader() throws IOException { return new BufferedReader(new InputStreamReader(getInputStream())); } } static class ServletInputStreamImpl extends ServletInputStream { private ByteArrayInputStream byteArrayInputStream; public ServletInputStreamImpl(ByteArrayInputStream byteArrayInputStream) { this.byteArrayInputStream = byteArrayInputStream; } @Override public boolean isFinished() { return false; } @Override public boolean isReady() { return false; } @Override public void setReadListener(ReadListener readListener) { } @Override public int read() throws IOException { return this.byteArrayInputStream.read(); } } } ``` ##### 8.3.9 注册拦截器 ```java package com.education.admin.api.config; /** * 拦截器配置类 * @author Jason * @version 1.0.0 * @date 2021-11-23 16:14 */ @Configuration public class WebAppConfig implements WebMvcConfigurer { @Autowired private ParamsValidateInterceptor paramsValidateInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(paramsValidateInterceptor); } } ``` ##### 8.3.10 编写测试类 ```java package com.education.admin.api; /** * 测试基于拦截器 + 注解形式实现的参数校验功能 * @author Jason * @version 1.0.0 * @date 2021-11-21 03:16 */ @SpringBootApplication(scanBasePackages = "com.education") @MapperScan("com.education.mapper") @RestController public class Test9 { @PostMapping("/loginTest") @ParamsValidate( params = { @Param(name = "userName", message = "请输入用户名"), @Param(name = "password", message = "请输入密码"), @Param(name = "key", message = "请传递一个验证码时间戳"), @Param(name = "imageCode", message = "请输入验证码"), @Param(name = "tel", message = "请输入手机号", regexp = RegexUtils.MOBILE_REGEX, regexpMessage = "非法手机号") }, paramsType = ParamsType.JSON_DATA) public Object loginTest(@RequestBody Map params) { return Result.success(ResultCode.SUCCESS, "校验成功", params); } public static void main(String[] args) { SpringApplication.run(Test9.class, args); } } ``` ##### 8.3.11 启动项目,使用Postman工具测试 ###### (1)设置基本请求参数 ![image-20211124194441368](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211124194441368.png) ###### (2)测试普通参数校验 ![image-20211124195159919](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211124195159919.png) ###### (3)测试正则参数校验 ![image-20211124195339146](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211124195339146.png) ###### (4)测试成功示例 ![image-20211124195824736](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211124195824736.png) ### 9、角色管理 以下截图,详细代码参考码云仓库 #### 9.1 创建角色管理页面 ![image-20211124202431016](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211124202431016.png) #### 9.2 添加路由 ![image-20211124202841880](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211124202841880.png) #### 9.3 实现添加/修改角色功能 ##### 9.3.1 给按钮添加新增事件 ![image-20211124203317398](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211124203317398.png) ##### 9.3.2 编写事件脚本 ![image-20211124203546840](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211124203546840.png) ##### 9.3.3 启动并测试 ![image-20211124203927652](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211124203927652.png) ##### 9.3.4 给确定按钮添加点击事件 ![image-20211124205800888](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211124205800888.png) ##### 9.3.5 编写添加/修改角色脚本 ![image-20211124205426758](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211124205426758.png) ##### 9.3.6 添加后端角色处理接口 ```java package com.education.admin.api.controller.system; @RestController @RequestMapping("/system/role") public class RoleController extends BaseController { @Autowired private SystemRoleService systemRoleService; /** * 获取角色列表 * @param params * @return */ @GetMapping public Result queryList(@RequestParam Map params) { return systemRoleService.queryList(params); } /** * 添加或修改角色 * @param roleMap 要添加或修改角色的数据 * @return */ @PostMapping public Result saveOrUpdate(@RequestBody Map roleMap) { return systemRoleService.saveOrUpdate(roleMap); } } ``` ##### 9.3.7 添加角色管理业务层 ```java package com.education.service.system; @Service public class SystemRoleService extends BaseService { public Result queryList(Map params) { List roleList = mapper.queryList(params); if (ObjectUtils.isNotEmpty(roleList)) { return Result.success(roleList); } return Result.fail(); } } ``` ##### 9.3.8 修改业务层基类 ```java package com.education.service; public abstract class BaseService { ... /** * 添加或修改数据 * @param params * @return */ public Result saveOrUpdate(Map params) { Integer id = (Integer) params.get("id"); boolean updateFlag = ObjectUtils.isNotEmpty(id); return this.saveOrUpdate(updateFlag, params); } /** * 添加或修改 * @param updateFlag * @param modelBeanMap * @return */ public Result saveOrUpdate(boolean updateFlag, Map modelBeanMap) { try { String message = ""; if (updateFlag) { modelBeanMap.put("update_date", new Date()); this.update(modelBeanMap); message = "修改"; } else { modelBeanMap.put("create_date", new Date()); this.save(modelBeanMap); message = "添加"; } return Result.success(ResultCode.SUCCESS, message + "成功"); } catch (Exception e) { logger.error("操作异常", e); } return Result.success(ResultCode.FAIL, "操作异常"); } /** * 添加数据 * @param modelMap * @return */ public int save(Map modelMap) { Integer result = mapper.save(modelMap); Object id = modelMap.get("id"); // 如果主键id不为空的话,直接返回主键id if (ObjectUtils.isNotEmpty(id)) { return TypeUtils.castToInt(id); } return result; } /** * 更新数据 * @param modelMap * @return */ public int update(Map modelMap) { return mapper.update(modelMap); } ... } ``` ##### 9.3.9 创建角色管理Mapper接口和映射文件 ```java package com.education.mapper.system; public interface SystemRoleMapper extends BaseMapper { } ``` ```xml insert into system_role ${key} values #{value} update system_role ${key} = #{params[${key}]} where id = #{params.id} ``` ##### 9.3.10 前端role页面处理后端接口数据 ```vue ... ``` ##### 9.6.3 新增批量删除按钮,并绑定点击事件 ```vue ``` ##### 9.6.4 创建后端接口处理方法 ```java @DeleteMapping("batchDeleteByRoleIds") public Result batchDeleteByRoleIds(@RequestBody List roleIds) { return systemRoleService.batchDeleteByRoleIds(roleIds); } ``` ##### 9.6.5 创建批量删除业务处理 ```java @Transactional public Result batchDeleteByRoleIds(List roleIds) { try { // 批量删除角色 super.batchDeleteByIds(roleIds); // 批量删除角色关联的菜单 systemRoleMenuService.batchDeleteByRoleIds(roleIds); // 批量删除用户角色关联 systemAdminRoleService.batchDeleteByRoleIds(roleIds); return Result.success(ResultCode.SUCCESS, "批量删除角色成功"); } catch (Exception e) { logger.error("批量删除角色异常", e); throw new BusinessException(new ResultCode(ResultCode.FAIL, "批量删除角色异常")); } } ``` ###### 9.6.6 在业务层基类`BaseService`中添加批量删除方法 ```java public Result batchDeleteByIds(List roleIds) { int result = mapper.batchDeleteByIds(roleIds); if (result == roleIds.size()) { Result.success(ResultCode.SUCCESS, "批量删除成功"); } return Result.fail(ResultCode.FAIL, "批量删除数据异常"); } ``` ##### 9.6.7 添加批量删除角色关联信息方法 ```java // 在SystemRoleMenuService和SystemAdminRoleService中都要添加此方法 public int batchDeleteByRoleIds(List roleIds) { return mapper.batchDeleteByRoleIds(roleIds); } ``` ```java // 在SystemRoleMenuMapper和SystemAdminRoleMapper中都要添加此接口 int batchDeleteByRoleIds(List roleIds); ``` ##### 9.6.8 添加批量删除角色关联信息的SQL ###### (1)`SystemRoleMapper.xml` ```xml delete from system_role where id in #{id} ``` ###### (2)`SystemAdminRoleMapper.xml` ```xml delete from system_admin_role where role_id in #{id} ``` ###### (3)`SystemRoleMapper.xml` ```xml delete from system_role_menu where role_id in #{id} ``` ###### 9.6.9 重启项目测试 ###### (1)没有选中记录时,点击批量删除会提示`请选择需要删除的角色` ###### (2)新增三个角色,选中后点击批量删除 ![image-20211126143922733](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211126143922733.png) #### 9.7 模糊查询 ##### 9.7.1 绑定标签 ![image-20211126144514114](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211126144514114.png) ##### 9.7.2 在获取列表脚本中添加查询参数 ![image-20211126144654417](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211126144654417.png) ##### 9.7.3 修改查询列表的SQL语句 ```xml ``` **注**:对应的`Mapper`接口的方法声明参数不能加`@Param`注解 ##### 9.7.4 启动测试 ![image-20211126145214391](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211126145214391.png) ### 10、分页功能 基于`PageHelper`实现数据分页功能 文档地址:[https://pagehelper.github.io/docs/](https://pagehelper.github.io/docs/) #### 10.1 引入依赖 ```xml com.github.pagehelper pagehelper-spring-boot-starter 1.4.0 ``` #### 10.2 分页方法 ```java PageHelper.startPage(int pageNumber, int pageSize) PageHelper.offsetPage(int offset,int pageSize) // PageHelper.startPage方法重要提示:只有紧跟在PageHelper.startPage方法后的第一个Mybatis的查询(Select)方法会被分页。 ``` #### 10.3 配置`PageHelper` ```yaml pagehelper: helper-dialect: mysql #数据库方言,默认为mysql reasonable: true #是否开启分页合理化 support-methods-arguments: true #支持通过 Mapper 接口参数来传递分页参数 params: count=countSql ``` #### 10.4 在`BaseService`中添加公用分页方法 ```java private static final String DEFAULT_PAGE_METHOD = "queryList"; public Result pagination(Map params, Class mapperClass, String methodName) { try { // 默认不分页 Integer offset = RowBounds.NO_ROW_OFFSET; Integer limit = RowBounds.NO_ROW_LIMIT; if (ObjectUtils.isNotEmpty(params.get("offset"))) { offset = Integer.parseInt((String) params.get("offset")); } if (ObjectUtils.isNotEmpty(params.get("limit"))) { limit = Integer.parseInt((String) params.get("limit")); } Page page = PageHelper.offsetPage(offset, limit); Object pageResult = null; if (ObjectUtils.isNotEmpty(mapperClass)) { Method method = mapperClass.getMethod(methodName, Map.class); BaseMapper mapperBean = SpringBeanManager.getBean(mapperClass); method.invoke(mapperBean, params); } else { pageResult = mapper.queryList(params); } Map resultMap = new HashMap<>(0); resultMap.put("total", page.getTotal()); resultMap.put("dataList", pageResult); return Result.success(resultMap); } catch (Exception e) { logger.error("分页异常", e); } return Result.fail(); } public Result pagination(Map params) { return pagination(params, null, DEFAULT_PAGE_METHOD); } ``` #### 10.5 修改获取列表的处理方法 ```java @GetMapping public Result queryList(@RequestParam Map params) { return systemRoleService.pagination(params); } ``` #### 10.6 给前端页面添加分页组件和变量 官方文档:[https://element.eleme.cn/#/zh-CN/component/pagination#dai-you-bei-jing-se-de-fen-ye](https://element.eleme.cn/#/zh-CN/component/pagination#dai-you-bei-jing-se-de-fen-ye) ```vue ... ``` #### 10.6 添加页码点击事件 ```vue ... ``` #### 10.7 启动测试(略) ![image-20211126165729038](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211126165729038.png) #### 10.7 添加每页显示条数菜单 ```vue ... ``` #### 10.8 启动项目测试 ![image-20211126165619205](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211126165619205.png) ### 11、菜单功能 #### 11.1 添加菜单页面 ###### (1)在`src/views/system`下创建`menu.vue` ```vue ``` ###### (2)添加菜单表格组件`src/components/system/menu-form.vue` ```vue ``` #### 11.2 添加后端处理方法 ```java package com.education.admin.api.controller.system; /** * 菜单管理处理层 */ @RestController @RequestMapping("/system/menu") public class SystemMenuController extends BaseController { @Autowired private SystemMenuService systemMenuService; /** * 获取菜单列表 */ @GetMapping("getMenuList") public Result getMenuList() { Result result = systemMenuService.pagination(new HashMap()); Map dataMap = result.getData(); List dataList = MapTreeUtils.buildTreeData((List) dataMap.get("dataList")); dataMap.put("dataList", dataList); return result; } /** * 添加或修改菜单 */ @PostMapping() public Result saveOrUpdateMenu(@RequestBody Map params) { return systemMenuService.saveOrUpdate(params); } /** * 删除单条记录 */ @DeleteMapping("{id}") public Result deleteById(@PathVariable("id")Integer id) { return systemMenuService.deleteById(id); } } ``` #### 11.3 菜单操作SQL ```xml insert into system_menu ${key} values #{value} update system_menu ${key} = #{params[${key}]} where id = #{params.id} delete from system_menu where id = #{id} or parent_id in (select id from system_menu where parent_id = #{id}) ``` ### 12、前后端权限拦截器 #### 12.1 编写管理后台前端全局请求拦截器 ![image-20211128155752097](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211128155752097.png) ##### 12.1.1 在`src/main.js`中编写全局请求拦截器 ```js axios.interceptors.request.use(function (config) { config.headers.Platform = 'educationAdmin' let token = localStorage.getItem('token') if (token) { // 这里将token设置到headers中,header的key是token,这个key值根据你的需要进行修改即可 config.headers.Authorization = token } return config }, function (error) { // 对请求错误做些什么 return Promise.reject(error) }) ``` #### 12.2 编写管理后台前端全局响应拦截器 ##### 12.2.1 在`src/main.js`中编写全局响应拦截器 ```js // 添加全局响应拦截器 axios.interceptors.response.use(function (response) { let authorization = response.data.data ? response.data.data.token : null // let authorization = response.headers.authorization if (authorization) { localStorage.setItem('token', authorization) } // console.log(response) // 对响应数据做点什么 if (response.data.code === 401) { localStorage.clear() router.push('/login') } else if (response.data.code === 406) { ElementUI.Message({ message: '权限不足,无法访问', type: 'error' }) return false } else if (response.data.code === 0) { ElementUI.Message({ message: response.data.message, type: 'error' }) return false } return response }, function (error) { return Promise.reject(error) }) ``` #### 12.3 编写Vue全局路由拦截器 ##### 12.3.1 在`src/router/index.js`中编写全局路由拦截器 让未认证用户在访问系统资源的时候帮助用户重定向到登录页面 ```js // vue 路由全局拦截器 router.beforeEach(function (to, from, next) { let token = localStorage.getItem('token') console.log(token) if (to.path === '/login') { if (token) { location.href = '/' } else { next() } } else { if (!token) { next({ name: 'login' }) // 没有token 跳转登录页面 } next() } }) ``` #### 12.4 后台api接口权限拦截器 ##### 12.4.1 创建api接口权限拦截器 验证token的合法性,从而进一步保证系统的安全性和健壮性 ```java package com.education.admin.api.interceptor; /** * token 拦截器校验 */ @Component public class AuthInterceptor extends BaseInterceptor { @Autowired private JwtToken jwtToken; /** * controller 方法执行前 */ @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String target = getRequestUrl(request); System.out.println(target); String token = request.getHeader("Authorization"); String userId = jwtToken.parserToken(token, String.class); // token 不存在或者token 已失效 if (ObjectUtils.isEmpty(token) || ObjectUtils.isEmpty(userId)) { renderJson(response, Result.fail(ResultCode.NOT_AUTH, "用户未认证")); return false; } return true; } } ``` ##### 12.4.2 在`WebAppConfig`中配置拦截器 ```java @Autowired private AuthInterceptor authInterceptor; /** * 无需认证就可以访问的URL */ private static final List noInterceptorUrl = new ArrayList() { { add("/login"); add("/image"); } }; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(paramsValidateInterceptor); registry.addInterceptor(authInterceptor) .addPathPatterns("/**") // 要拦截的URL .excludePathPatterns(noInterceptorUrl); // 不拦截的URL } ``` #### 12.5 重启项目测试 (1)清空浏览器缓存,访问[http://localhost:8001/#/home](http://localhost:8001/#/home)会自动跳转至登录界面 (2)正常登录成功,访问[http://localhost:8001/#/login](http://localhost:8001/#/login)会自动跳转至首页 ### 13、管理员管理 ### 14、角色权限管理 ### 15、系统操作日志 #### 15.1、创建自定义注解 ```java package com.education.common.annotation; // 日志注解类 @Target(ElementType.METHOD) @Documented @Retention(RetentionPolicy.RUNTIME) public @interface SystemLog { // 操作描述 String describe(); } ``` #### 15.2、创建一个日志切面 ##### 15.2.1 引入Spring AOP依赖 ```xml org.springframework.boot spring-boot-starter-aop ``` ##### 15.2.2 创建日志切面 ```java package com.education.service.task; // 日志切面 @Aspect @Component public class LogAspect { @Autowired private SystemAdminService systemAdminService; @Autowired private TaskManager taskManager; // 拦截com.education.admin.api.controller下面的所有类中的接口 @Around("execution(public * com.education.*.*.controller..*.*(..))") public Object invoke(ProceedingJoinPoint point) throws Throwable { // 获取接口请求的开始时间 long startTime = System.currentTimeMillis(); MethodSignature methodSignature = (MethodSignature) point.getSignature(); // 获取controller目标方法 Method method = methodSignature.getMethod(); SystemLog systemLog = method.getAnnotation(SystemLog.class); TaskParam taskParam = new TaskParam(LogTaskListener.class); try { if (ObjectUtils.isNotEmpty(systemLog)) { taskParam.put("operation_desc", systemLog.describe()); } HttpServletRequest request = RequestUtils.getRequest(); // 获取请求的URL String targetUrl = RequestUtils.getRequestUrl(request); // 获取请求方式 String requestType = request.getMethod(); // 获取请求参数 Object params = null; String contentType = request.getHeader("Content-Type"); if (ObjectUtils.isNotEmpty(contentType) && contentType.contains(Constants.JSON_CONTENT_TYPE)) { params = RequestUtils.readData(request); } else { params = request.getParameterMap(); if (ObjectUtils.isNotEmpty(params)) { StringBuilder content = new StringBuilder(); Map data = (Map)params; // 当参数为树形Map时,一个key可能对应多个value data.keySet().forEach(key -> { String values[] = request.getParameterValues((String) key); if (values.length == 1) { content.append(key + "=" + values[0]); } else { content.append(key + "[]={"); for (String value : values) content.append(value + ", "); content.append("}"); } content.append(", "); }); params = content.toString(); } } params = JSONObject.toJSONString(params); taskParam.put("params", params); taskParam.put("startTime", startTime); taskParam.put("request_url", targetUrl); taskParam.put("method", requestType); // 获取请求的ip地址 taskParam.put("ip", IpUtils.getAddressIp(request)); // 获取发起请求的用户id AdminUserSession adminUserSession = systemAdminService.getAdminUserSession(); if (ObjectUtils.isNotEmpty(adminUserSession)) { Map userMap = adminUserSession.getUserMap(); if (ObjectUtils.isNotEmpty(userMap)) { taskParam.put("user_id", userMap.get("id")); taskParam.put("operation_name", userMap.get("login_name")); } } // 执行目标方法 Object result = point.proceed(); taskManager.pushTask(taskParam); return result; } catch (Throwable e) { // 获取异常信息 StringBuffer error = new StringBuffer().append(e + "\n"); StackTraceElement[] stackTrace = e.getStackTrace(); for (StackTraceElement item : stackTrace) { error.append(item.toString() + "\n"); } taskParam.put("exception", error.toString()); taskManager.pushTask(taskParam); throw e; } } } ``` #### 15.3、异步将http请求日志保存到后台日志表(system_log) ##### 15.3.1 日志任务监听器 ```java package com.education.service.task; // 日志任务监听器 @Component public class LogTaskListener implements TaskListener{ @Autowired private SystemLogService systemLogService; // 用来异步保存日志信息到system_log表 @Override public void onMessage(TaskParam taskParam) { long startTime = (long) taskParam.get("startTime"); long nowTime = System.currentTimeMillis(); // 获取接口响应的时间 long requestTime = nowTime - startTime; taskParam.put("request_time", requestTime + "ms"); taskParam.put("create_date", new Date()); taskParam.remove("startTime"); // 调用save保存日志 systemLogService.save(taskParam); } } ``` ##### 15.3.2 创建日志业务层 ```java package com.education.service.system; // 日志管理业务层 @Service public class SystemLogService extends BaseService { public Result clear() { int number = mapper.clear(); if (number > 0) { return Result.success(ResultCode.SUCCESS, "已清除" + number + "条日志"); } return Result.fail(ResultCode.FAIL, "清空日志异常,或没有日志记录"); } } ``` ##### 15.3.3 创建日志mapper层和映射文件 ```java package com.education.mapper.system; public interface SystemLogMapper extends BaseMapper { // 清空日志 int clear(); } ``` ```xml insert into system_log ${key} values #{value} delete from system_log where id = #{id} delete from system_log where id in #{id} delete from system_log ``` ##### 15.3.4 创建日志处理器 ```java package com.education.admin.api.controller.system; // 日志处理器 @RestController @RequestMapping("/system/log") public class SystemLogController { @Autowired private SystemLogService systemLogService; @GetMapping @RequiresPermissions("system:log:list") @SystemLog(describe = "获取日志列表") public Result queryList(@RequestParam Map params) { return systemLogService.pagination(params); } @DeleteMapping() @ParamsValidate(params = { @Param(name = "id", message = "id参数不能为空", regexp = RegexUtils.NUMBER_REGEX, regexpMessage = "id必须是数字") }, paramsType = ParamsType.FORM_DATA) @RequiresPermissions("system:log:delete") @SystemLog(describe = "删除日志") public Result deleteById(@RequestParam Integer id) { return systemLogService.deleteById(id); } @DeleteMapping("batchDelete") @RequiresPermissions("system:log:delete") @SystemLog(describe = "批量删除日志") public Result batchDeleteByIds(@RequestBody List logIds) { return systemLogService.batchDeleteByIds(logIds); } @DeleteMapping("clear") @RequiresPermissions("system:log:clear") @SystemLog(describe = "清空日志") public Result clear() { return systemLogService.clear(); } } ``` ##### 15.3.5 启动测试 之后每个操作都会记录在system_log表中 ### 16、RBAC权限系统总结 #### 16.1 RBAC权限系统数据库设计 ![image-20211204221814918](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211204221814918.png) #### 16.2 RBAC的执行流程 ![image-20211204221757165](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211204221757165.png) #### 16.3 RBAC权限系统技术点回顾 - 使用了开源权限框架Shiro - Shiro三大核心组件`Subject`、`SecurityManager`、`Realms`以及Shiro过滤器的使用 - Shiro核心权限注解`@RequiresPermission`用来验证用户是否有权访问指定接口 - Spring AOP + 线程池异步记录系统操作日志 #### 16.4 RBAC权限系统思考 如果没有Shiro这个权限框架我们如何实现RBAC权限管理? #### 16.5 总结 - 养成良好的编程规范 - 经常用到的方法,可以想办法写成公共的方法 - 系统中经常用到的字符或数字,可以将其定义为常量,便于系统的后期维护 ## 七、分布式session ### 7.1 分布式session概念 #### 7.1.1 什么是session session称为**会话控制**, Session 对象存储特定用户会话所需的属性及配置信息。对于客户端的第一次请求, 服务器首先检查这个客户端是否包含一个session标识( 即JSESSIONID)。如果不存在的话,服务器会创建session,同时创建一个 会话Cookie。它的有效期默认是在浏览器关闭时失效。 #### 7.1.2 单体应用架构 ![image-20211206133317760](C:/Users/Administrator/AppData/Roaming/Typora/typora-user-images/image-20211206133317760.png) #### 7.1.3 服务集群架构 ![image-20211206133418803](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211206133418803.png) **注**:此架构容易丢失用户session #### 7.1.4 集群架构下的Session共享 ![image-20211206133441374](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211206133441374.png) **注**:中间站可以是数据库,也可以是缓存,但这里的缓存是分布式缓存,常见的分布式缓存有Ehcache、Redis等 #### 7.1.5 分布式和服务集群的区别 - **集群**:每台服务器部署相同的项目 - **分布式**:每台服务器部署不同的服务 - **趋势**:以分布式为主,因为分布式更有利于实现业务的解耦 ### 7.2 session集群环境搭建及演示 #### 7.2.1 Tomcat集群演示 - 下载Nginx服务器用来做负载均衡 - 启动两台tomcat服务器分别部署同一个项目 ###### 1、下载Nginx服务器用来做负载均衡 官方下载:[http://nginx.org/download/nginx-1.21.4.zip](http://nginx.org/download/nginx-1.21.4.zip) 下载完后解压,打开解压目录下的`conf/nginx.conf`文件,修改配置并保存,启动nginx ```conf http { ... # 此处配置两台服务器地址 upstream tomcatService { server localhost:8002; server localhost:8003; } server { # nginx服务器地址和端口号 listen 80; server_name localhost; # 指向两台服务器 location ^~/ { proxy_pass http://tomcatService; } ... } } ``` ###### 2、启动两台tomcat服务器分别部署同一个项目 (1)打包项目 ​ 在核心配置类中,将端口号改为8003,maven插件中点击 -> education -> Lifecycle -> package打包项目(最好先clean一下再打包),我打包时,报了“**依赖不存在的错误**”,可将父工程pom文件中的打包插件剪切到education-admin-api的pom文件中即可解决 (2)打开`cmd`,`cd`到`education-admin-api/target`目录,输入`java -jar education-admin-api-(版本号).jar`启动项目 (3)回到IDEA,将端口号改为8002,启动项目 (4)启动前端项目,登录账号 ​ 点击登录后,会提示登录成功,但又跳转到了登录界面,关闭一个服务器,再进行登录测试,会发现能正常操作,这就是集群架构的session丢失问题 ### 7.3 Shiro整合Redis实现session共享 #### 7.3.1 Shiro + Redis实现session共享 - 创建一个Cache实现类 - 创建一个CacheManager缓存管理器 - 创建一个SessionDao实现类 #### 7.3.2 创建Cache实现类 ##### 1、创建Cache接口 ```java package com.education.common.cache; import java.util.Collection; import java.util.concurrent.TimeUnit; // cache 接口 public interface CacheBean { T get(String cacheName, Object key); T get(Object key); void put(String cacheName, Object key, Object value); void put(Object key, Object value); void put(Object key, Object value, int liveSeconds); void put(String cacheName, Object key, Object value, int liveSeconds); void put(String cacheName, Object key, Object value, int liveSeconds, TimeUnit timeUnit); void put(Object key, Object value, int liveSeconds, TimeUnit timeUnit); Collection getKeys(String cacheName); Collection getKeys(); void remove(Object key); void remove(); void remove(String cacheName, Object key); void removeAll(String cacheName); } ``` ##### 2、创建基于Ehcache的缓存 ```java package com.education.common.cache; import net.sf.ehcache.Cache; import net.sf.ehcache.CacheManager; import net.sf.ehcache.Element; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.InputStream; import java.util.Collection; import java.util.concurrent.TimeUnit; /** * 基于Ehcache 缓存 * @author Jason * @version 1.0.0 * @date 2021-11-20 23:02 */ public class EhcacheBean implements CacheBean { private static final String DEFAULT_CACHE = "default_cache"; private final CacheManager cacheManager; private static final Logger logger = LoggerFactory.getLogger(EhcacheBean.class); private final Object rock = new Object(); public EhcacheBean() { InputStream inputStream = this.getClass() .getClassLoader() .getResourceAsStream("ehcache.xml"); this.cacheManager = CacheManager.create(inputStream); } public EhcacheBean(CacheManager cacheManager) { this.cacheManager = cacheManager; } public CacheManager getCacheManager() { return cacheManager; } private Cache getOrAddCache(String cacheName) { Cache cache = cacheManager.getCache(cacheName); if (cache == null) { synchronized (rock) { cache = cacheManager.getCache(cacheName); if (cache == null) { logger.warn("Could not find cache config [" + cacheName + "], using default."); cacheManager.addCacheIfAbsent(cacheName); cache = cacheManager.getCache(cacheName); logger.debug("Cache [" + cacheName + "] started."); } } } return cache; } @Override public void put(String cacheName, Object key, Object value) { getOrAddCache(cacheName).put(new Element(key, value)); } @Override public void put(String cacheName, Object key, Object value, int liveSeconds) { if (liveSeconds <= 0) { this.put(cacheName, key, value); } else { Element element = new Element(key, value); element.setTimeToLive(liveSeconds); this.getOrAddCache(cacheName).put(element); } } @Override public void put(String cacheName, Object key, Object value, int liveSeconds, TimeUnit timeUnit) { this.put(cacheName, key, value, liveSeconds); } @Override public void put(Object key, Object value, int liveSeconds, TimeUnit timeUnit) { this.put(key, value, liveSeconds); } @Override public void put(Object key, Object value) { put(DEFAULT_CACHE, key, value); } @Override public void put(Object key, Object value, int liveSeconds) { if (liveSeconds <= 0) { this.put(key, value); } else { Element element = new Element(key, value); element.setTimeToLive(liveSeconds); this.getOrAddCache(DEFAULT_CACHE).put(element); } } @Override @SuppressWarnings("unchecked") public T get(String cacheName, Object key) { Element element = getOrAddCache(cacheName).get(key); return element != null ? (T)element.getObjectValue() : null; } @Override public T get(Object key) { return get(DEFAULT_CACHE, key); } @Override @SuppressWarnings("rawtypes") public Collection getKeys(String cacheName) { return getOrAddCache(cacheName).getKeys(); } @Override public Collection getKeys() { return this.getKeys(DEFAULT_CACHE); } @Override public void remove(Object key) { this.remove(DEFAULT_CACHE, key); } @Override public void remove() { this.remove(DEFAULT_CACHE); } @Override public void remove(String cacheName, Object key) { getOrAddCache(cacheName).remove(key); } @Override public void removeAll(String cacheName) { getOrAddCache(cacheName).removeAll(); } } ``` ##### 3、创建创建基于Redis的缓存 ```java package com.education.common.cache; /** * 基于 redis 缓存 * @author Jason * @version 1.0.0 * @date 2021-11-21 00:52 */ public class RedisCacheBean implements CacheBean { private final RedisTemplate redisTemplate; private final ValueOperations valueOperations; public RedisTemplate getRedisTemplate() { return redisTemplate; } public RedisCacheBean(RedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; this.valueOperations = this.redisTemplate.opsForValue(); } @Override public T get(String cacheName, Object key) { return (T) this.valueOperations.get(this.createNewKey(cacheName, key)); } @Override public T get(Object key) { return (T) this.valueOperations.get(key); } @Override public void put(String cacheName, Object key, Object value) { this.valueOperations.set(this.createNewKey(cacheName, key), value); } private String createNewKey(String cacheName, Object key) { return String.format("%s:%s", cacheName, key); } @Override public void put(Object key, Object value) { this.valueOperations.set(key, value); } @Override public void put(Object key, Object value, int liveSeconds) { this.valueOperations.set(key, value, liveSeconds); } @Override public void put(String cacheName, Object key, Object value, int liveSeconds) { this.valueOperations.set(this.createNewKey(cacheName, key), value, liveSeconds); } @Override public void put(String cacheName, Object key, Object value, int liveSeconds, TimeUnit timeUnit) { this.valueOperations.set(this.createNewKey(cacheName, key), value, liveSeconds, timeUnit); } @Override public void put(Object key, Object value, int liveSeconds, TimeUnit timeUnit) { this.valueOperations.set(key, value, liveSeconds, timeUnit); } @Override public Collection getKeys(String cacheName) { return this.redisTemplate.keys(cacheName + "*"); } /** * 调用此方法需要设置 redisTemplate.setKeySerializer(new StringRedisSerializer()); * 否则导致返回key 值为空集合 * @return */ @Override public Collection getKeys() { return this.redisTemplate.keys("*"); } @Override public void remove(Object key) { this.redisTemplate.delete(key); } @Override public void remove() { Collection collection = redisTemplate.keys("*"); if (ObjectUtils.isNotEmpty(collection)) { collection.forEach(key -> { redisTemplate.delete(key); }); } } @Override public void remove(String cacheName, Object key) { this.redisTemplate.delete(createNewKey(cacheName, key)); } @Override public void removeAll(String cacheName) { Collection keys = getKeys(cacheName); this.redisTemplate.delete(keys); } } ``` ##### 4、创建Redis缓存实现类 ```java package com.education.admin.api.shiro; public class RedisCache implements Cache { private CacheBean redisCacheBean; public RedisCache(CacheBean redisCacheBean) { this.redisCacheBean = redisCacheBean; } // 获取缓存key的value @Override public V get(K k) throws CacheException { return redisCacheBean.get(k); } // 设置缓存 @Override public V put(K key, V value) throws CacheException { redisCacheBean.put(key, value); return value; } // 删除指定缓存数据 @Override public V remove(K k) throws CacheException { V value = redisCacheBean.get(k); redisCacheBean.remove(k); return value; } // 删除redis中的所有缓存 @Override public void clear() throws CacheException { redisCacheBean.remove(); } // 获取缓存中key大小 @Override public int size() { return this.keys().size(); } // 获取缓存中的所有key @Override public Set keys() { return (Set) redisCacheBean.getKeys(); } // 用户获取缓存中的集合对象 @Override public Collection values() { // 获取缓存中的所有key Collection collection = keys(); if (ObjectUtils.isNotEmpty(collection)) { // 用来存储缓存中的所有value集合 Set values = new HashSet<>(); collection.forEach(key -> { values.add(this.get((K) key)); }); return values; } return Collections.emptySet(); } } ``` #### 7.3.3 创建Cache管理器 ```java package com.education.admin.api.shiro; // RedisCache管理器 public class RedisCacheManager implements CacheManager { private CacheBean redisCacheBean; private final Map caches = new ConcurrentHashMap(); public RedisCacheManager(CacheBean redisCacheBean) { this.redisCacheBean = redisCacheBean; } // 获取缓存对象cache @Override public Cache getCache(String key) throws CacheException { return getCacheFormMap(key); } // 获取单例Cache对象 private Cache getCacheFormMap(String key) { Cache cache = caches.get(key); if (ObjectUtils.isNotEmpty(cache)) { synchronized (this) { cache = caches.get(key); if (ObjectUtils.isEmpty(cache)) { RedisCache redisCache = new RedisCache(redisCacheBean); caches.put(key, redisCache); } } } return cache; } } ``` #### 7.3.4 创建SessionDao实现类 ```java package com.education.admin.api.shiro; public class DistributeShiroSession extends AbstractSessionDAO { private CacheBean redisCacheBean; private static final String SESSION_KEY = "user.session.cache"; // 默认失效时间为1天 private static final int ONE_DAY = 24 * 60 * 60; private long expire = ONE_DAY; // 设置session失效时间 public void setExpire(long expire) { this.expire = expire; } public DistributeShiroSession(CacheBean redisCacheBean) { this.redisCacheBean = redisCacheBean; } // 创建sessionId @Override protected Serializable doCreate(Session session) { Serializable sessionId = generateSessionId(session); assignSessionId(session, sessionId); // 将session信息保存到redis this.saveSessionToCache(sessionId, session); return sessionId; } private void saveSessionToCache(Serializable sessionId, Session session) { if (ObjectUtils.isEmpty(sessionId)) { throw new NullPointerException("id argument cannot be null."); } // 设置session时间为24小时 redisCacheBean.put(SESSION_KEY, sessionId, session, ONE_DAY, TimeUnit.SECONDS); } // 用来获取session实例 @Override protected Session doReadSession(Serializable sessionId) { return redisCacheBean.get(SESSION_KEY, sessionId); } // 更新session实例信息 @Override public void update(Session session) throws UnknownSessionException { this.saveSessionToCache(session.getId(), session); } // 用来删除session信息 @Override public void delete(Session session) { redisCacheBean.remove(SESSION_KEY, session.getId()); } // 用来获取所有session集合 @Override public Collection getActiveSessions() { Collection keys = redisCacheBean.getKeys(SESSION_KEY); List sessionList = new ArrayList(); if (ObjectUtils.isNotEmpty(keys)) { keys.forEach(key -> { Session session = redisCacheBean.get(SESSION_KEY, key); sessionList.add(session); }); return Collections.unmodifiableCollection(sessionList); } return Collections.emptySet(); } } ``` #### 7.3.6 修改`ShiroBeanConfig`类 ##### 1、添加三个方法 ```java @Bean public SessionDAO distributeShiroSession(CacheBean redisCacheBean) { return new DistributeShiroSession(redisCacheBean); } @Bean public CacheManager redisCacheManager(CacheBean redisCacheBean) { return new RedisCacheManager(redisCacheBean); } @Bean public SessionManager sessionManager(SessionDAO distributeShiroSession) { DefaultSessionManager sessionManager = new DefaultWebSessionManager(); // 设置session有效期为6小时 sessionManager.setGlobalSessionTimeout(INVALID_TIME); sessionManager.setSessionDAO(distributeShiroSession); return sessionManager; } ``` ##### 2、修改`securityManager`方法 ```java @Bean public SecurityManager securityManager(Realm systemRealm, CacheManager redisCacheManager/** 参数名必须与上面修改的方法名一致 **/) { DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(); securityManager.setRealm(systemRealm); // 将之前的ehcacheManager改为redisCacheManager securityManager.setCacheManager(redisCacheManager); return securityManager; } ``` ##### 7.3.7 获取用户session后需要保存到缓存中 **1、在`BaseService`中添加方法** ```java /** * 更新shiro 缓存中的用户信息,避免由于redis 缓存导致获取用户信息不一致问题 * @param adminUserSession */ public void updateShiroCacheUserInfo(AdminUserSession adminUserSession) { Subject subject = SecurityUtils.getSubject(); PrincipalCollection principals = subject.getPrincipals(); // realName认证信息的key,对应的value就是认证的user对象 String realName = principals.getRealmNames().iterator().next(); // 创建一个PrincipalCollection对象 PrincipalCollection newPrincipalCollection = new SimplePrincipalCollection(adminUserSession, realName); // 调用subject的runAs方法,把新的PrincipalCollection放到session里面 subject.runAs(newPrincipalCollection); } ``` **2、修改`SystemAdminService`类** ​ 在`loadUserMenuAndPermission`方法后添加以下语句,将获取的用户信息保存到缓存中 ```java this.updateShiroCacheUserInfo(adminUserSession); ``` #### 7.3.8 启动nginx和两台服务器进行测试 ​ 如果出现类型转换异常,且两个类型完全一样,可尝试删除父项目pom文件中的`deltools`依赖,再清空浏览器缓存即可解决 #### 7.3.6 session共享总结 ![image-20211206205912284](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211206205912284.png) ### 7.4 限制账号多设备登录 #### 7.4.1 技术解决方案 ![image-20211207172020202](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211207172020202.png) #### 7.4.2 代码实现 1、创建存储在线用户信息模型 ```java package com.education.common.model; import java.io.Serializable; // 用来存储在线用户信息 public class OnlineUser implements Serializable{ // 客户端会话id private String sessionId; private AdminUserSession adminUserSession; public String getSessionId() { return sessionId; } public void setSessionId(String sessionId) { this.sessionId = sessionId; } public AdminUserSession getAdminUserSession() { return adminUserSession; } public void setAdminUserSession(AdminUserSession adminUserSession) { this.adminUserSession = adminUserSession; } public OnlineUser(String sessionId, AdminUserSession adminUserSession) { this.sessionId = sessionId; this.adminUserSession = adminUserSession; } } ``` 2、创建在线用户管理器 ```java package com.education.common.model; import com.education.common.cache.CacheBean; import com.education.common.utils.ObjectUtils; import java.util.ArrayList; import java.util.List; import java.util.Set; // 在线用户管理器 public class OnlineUserManager { private CacheBean redisCacheBean; private static final String USER_ID_CACHE = "user:id:cache"; public OnlineUserManager(CacheBean cacheBean) { this.redisCacheBean = cacheBean; } // 添加在线用户 public void addOnlineUser(Integer userId, OnlineUser onlineUser) { redisCacheBean.put(USER_ID_CACHE, userId, onlineUser); } // 删除在线用户 public void removeOnlineUser(Integer userId) { redisCacheBean.remove(USER_ID_CACHE, userId); } // 根据userId获取在线用户 public OnlineUser getOnlineUser(Integer userId) { return redisCacheBean.get(USER_ID_CACHE, userId); } // 获取所有在线用户 public List getAllOnlineUser() { List onlineUserList = new ArrayList<>(); Set userIds = (Set) redisCacheBean.getKeys(USER_ID_CACHE); if (ObjectUtils.isNotEmpty(userIds)) { userIds.forEach(userId -> { onlineUserList.add(getOnlineUser(userId)); }); } return onlineUserList; } // 删除所有在线用户 public void clear() { Set userIds = (Set) redisCacheBean.getKeys(USER_ID_CACHE); if (ObjectUtils.isNotEmpty(userIds)) { userIds.forEach(userId -> { redisCacheBean.remove(userId); }); } } } ``` 3、在`LoginController`登录成功位置,检查用户是否已登录 ```java @PostMapping("login") public Result login(HttpServletRequest request, @RequestBody Map params) { ... if (result.isSuccess()) { AdminUserSession adminUserSession = systemAdminService.getAdminUserSession(); Integer adminId = (Integer) adminUserSession.getUserMap().get("id"); // 检查用户是否已登录 systemAdminService.checkOnlineUser(adminId); ... } ... } ``` 4、在`SystemAdminService`zh中添加检查用户是否已登录的方法 ```java /** * 1、去用户容器中查找用户是否在其他设备上已登录,如果已登录的话, * 使用websocket异步发送一条消息推送 */ public void checkOnlineUser(Integer userId) { OnlineUser onlineUser = onlineUserManager.getOnlineUser(userId); if (ObjectUtils.isNotEmpty(onlineUser)) { // 不为空,代表已经在其他设备登录,使用websocket异步发送一条消息推送 // TODO } } ``` ### 7.5 `SpringBoot`集成`webSocket` #### 7.5.1 什么是WebSocket? ​ WebSocket是HTML5开始提供的一种在单个TCP连接上进行全双工通讯的协议。WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在WebSocket API中,浏览器和服务器只需要做-一个握手的动作,然后,浏览器和服务器之间就形成了一条快速通道。 #### 7.5.2 Ajax轮询 ​ 在WebSocket诞生之前,很多网站为了实现推送技术,所用的技术都是**Ajax轮询**。轮询是在特定的时间间隔(如每1秒),由浏览器对服务器发出HTTP请求,然后由服务器返回最新的数据给客户端的浏览器。这种传统的模式带来很明显的缺点,即浏览器需要不断的向服务器发出请求,然而HTTP请求可能包含较长的头部,其中真正有效的数据可能只是很小的一部分,显然这样会浪费很多的带宽等资源。 ![image-20211207185847965](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211207185847965.png) #### 7.5.3 WebSocket在浏览器中的使用 - `let websock = new WebSocket(url)` | WebSocket事件 | 描述 | | :-----------: | :------------------------: | | Open () | 连接建立时调用 | | Error () | 通信发生错误时调用 | | Message () | 客户端接收服务端数据时触发 | | Close () | 连接关闭时触发 | WebSocket API中的方法: | WebSocket事件 | 描述 | | :-----------: | :--------------------: | | send () | 用来向服务器端发送数据 | | close () | 关闭WebSocket连接 | #### 7.5.4 代码实现 ##### 1、引入依赖 ```xml org.springframework.boot spring-boot-starter-websocket ``` ##### 2、创建WebSocket连接拦截器 ```java package com.education.service.websocket; import com.education.common.cache.EhcacheBean; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.server.ServerHttpRequest; import org.springframework.http.server.ServerHttpResponse; import org.springframework.web.socket.WebSocketHandler; import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor; import javax.servlet.http.HttpServletRequest; import java.util.Map; // WebSocket 连接拦截器 @Component public class HandshakeInterceptor extends HttpSessionHandshakeInterceptor { protected static final Logger logger = LoggerFactory.getLogger(EhcacheBean.class); // websocket 连接之前调用 public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,WebSocketHandler wsHandler, Map attributes) throws Exception { logger.info("-------执行方法HandshakeInterceptor beforeHandshake-------"); if (request instanceof HttpServletRequest) { HttpServletRequest servletRequest = (HttpServletRequest) request; // 获取客户端会话id String sessionId = servletRequest.getSession().getId(); attributes.put("sessionId", sessionId); } return true; } // websocket 连接之后调用 @Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception ex) { logger.info("-------执行方法HandshakeInterceptor afterHandshake-------"); super.afterHandshake(request, response, wsHandler, ex); } } ``` ##### 3、创建WebSocket消息处理器 ```java package com.education.service.websocket; import com.education.common.cache.CacheBean; import com.education.common.cache.EhcacheBean; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.WebSocketHandler; import org.springframework.web.socket.WebSocketMessage; import org.springframework.web.socket.WebSocketSession; import java.util.Map; // websocket 消息处理器 @Component public class SystemWebSocketHandler implements WebSocketHandler { protected static final Logger logger = LoggerFactory.getLogger(EhcacheBean.class); @Autowired private CacheBean redisCacheBean; private static final String WEB_SOCKET_SESSION_CACHE = "websocket:session"; // websocket 连接就绪时调用 @Override public void afterConnectionEstablished(WebSocketSession webSocketSession) throws Exception { logger.info("-------执行方法SystemWebSocketHandler afterConnectionEstablished-------"); Map attrs = webSocketSession.getAttributes(); String sessionId = (String) attrs.get("sessionId"); // 将webSocketSession对象存放到Redis缓存中 redisCacheBean.put(WEB_SOCKET_SESSION_CACHE, sessionId, webSocketSession); } // 用来处理与客户端的消息推送 @Override public void handleMessage(WebSocketSession webSocketSession, WebSocketMessage webSocketMessage) throws Exception { logger.info("-------执行方法SystemWebSocketHandler handleMessage-------"); } // websocket 连接异常时调用 @Override public void handleTransportError(WebSocketSession webSocketSession, Throwable throwable) throws Exception { logger.info("websocket连接异常"); } // websocket 关闭连接时调用 @Override public void afterConnectionClosed(WebSocketSession webSocketSession, CloseStatus closeStatus) throws Exception { logger.info("websocket关闭成功"); } @Override public boolean supportsPartialMessages() { return false; } } ``` ##### 4、创建WebSocket配置类 ```java package com.education.service.websocket; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.config.annotation.WebSocketConfigurer; import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; @Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { @Autowired private SystemWebSocketHandler systemWebSocketHandler; @Autowired private HandshakeInterceptor handshakeInterceptor; @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry webSocketHandlerRegistry) { webSocketHandlerRegistry.addHandler(systemWebSocketHandler, "/webSocket") .addInterceptors(handshakeInterceptor) // 配置运行跨域 .setAllowedOrigins("*"); // 让WebSocket支持JS webSocketHandlerRegistry.addHandler(systemWebSocketHandler, "/sockJs/webSocket") .addInterceptors(handshakeInterceptor) .setAllowedOrigins("*").withSockJS(); } } ``` ##### 5、在`BeanConfig`中添加在线用户管理器实例 ```java @Bean public OnlineUserManager onlineUserManager(CacheBean redisCacheBean) { return new OnlineUserManager(redisCacheBean); } ``` ### 7.6 `Vue`整合`WebSocket` **1、编辑`main-navbar.vue`文件** ```vue ``` **2、启动前后端项目测试** 启动测试,会发现后端抛出`WebSocket`不支持序列化的异常,可改用Map存放`websocketsession` ```java package com.education.service.websocket; @Component public class SystemWebSocketHandler implements WebSocketHandler { protected static final Logger logger = LoggerFactory.getLogger(EhcacheBean.class); // @Autowired // private CacheBean redisCacheBean; private Map sessionMap = new HashMap<>(); // private static final String WEB_SOCKET_SESSION_CACHE = "websocket:session"; // websocket 连接就绪时调用 @Override public void afterConnectionEstablished(WebSocketSession webSocketSession) throws Exception { logger.info("-------执行方法SystemWebSocketHandler afterConnectionEstablished-------"); Map attrs = webSocketSession.getAttributes(); String sessionId = (String) attrs.get("sessionId"); sessionMap.put(sessionId, webSocketSession); // 将webSocketSession对象存放到Redis缓存中 // redisCacheBean.put(WEB_SOCKET_SESSION_CACHE, sessionId, webSocketSession); } ... } ``` ### 7.7 `WebSocket`实现消息推送 #### 7.7.1 代码实现 **1、在`SystemWebSocketHandler`中添加发送消息方法** ```java public void sendMessage(String sessionId, String message) { WebSocketSession webSocketSession = sessionMap.get(sessionId); try { if (ObjectUtils.isNotEmpty(webSocketSession)) { webSocketSession.sendMessage(new TextMessage(message)); } } catch (IOException e) { logger.error("发送消息异常", e); } } ``` **2、在`BaseService`中添加方法** ```java package com.education.service; public class BaseService { ... // 更新Shiro缓存中的用户信息,避免由于Redis缓存导致获取用户信息不一致的问题 public void updateShiroCacheUserInfo(AdminUserSession adminUserSession) { Subject subject = SecurityUtils.getSubject(); PrincipalCollection principals = subject.getPrincipals(); // realName认证信息的key,对应的value就是认证的user对象 String realName = principals.getRealmNames().iterator().next(); // 创建一个PrincipalCollection对象 PrincipalCollection newPrincipalCollection = new SimplePrincipalCollection(adminUserSession, realName); // 调用subject的runAs方法,把新的PrincipalCollection放到session里面 subject.runAs(newPrincipalCollection); } } ``` **3、检查多设备登录并将登录用户添加到在线用户容器** ```java package com.education.admin.api.controller; @RestController public class LoginController extends BaseController { ... @Autowired private OnlineUserManager onlineUserManager; ... @PostMapping("login") @SystemLog(describe = "用户登录") public Result login(HttpServletRequest request, @RequestBody Map params) { ... if (result.isSuccess()) { // 获取用户信息 AdminUserSession adminUserSession = systemAdminService.getAdminUserSession(); Integer adminId = (Integer) adminUserSession.getUserMap().get("id"); // 校验用户是否已登录 systemAdminService.checkOnlineUser(adminId); // 将目前登录的用户添加到用户容器 String sessionId = request.getSession().getId(); OnlineUser onlineUser = new OnlineUser(sessionId, adminUserSession); onlineUserManager.addOnlineUser(adminId, onlineUser); ... } return result; } } ``` **4、添加校验用户是否已登录的方法** ```java package com.education.service.system; @Service public class SystemAdminService extends BaseService { ... @Resource private OnlineUserManager onlineUserManager; @Autowired private HttpServletRequest request; public void checkOnlineUser(Integer userId) { OnlineUser onlineUser = onlineUserManager.getOnlineUser(userId); // 不为空,代表已经在其他设备登录,使用websocket异步发送一条消息推送 if (ObjectUtils.isNotEmpty(onlineUser)) { // 获取已经登录用户的会话id String sessionId = onlineUser.getSessionId(); // 移除原来的用户信息 onlineUserManager.removeOnlineUser(userId); taskManager.pushTask(() -> { try { Thread.sleep(5000); ResultCode resultCode = new ResultCode(ResultCode.FAIL, "您的账号已在其它设备上登录,5秒后自动下线,如非本人操作,请立即修改密码"); String message = JSONObject.toJSONString(resultCode); systemWebSocketHandler.sendMessage(sessionId, message); } catch (InterruptedException e) { e.printStackTrace(); } }); } } ... } ``` #### 7.7.2 功能测试 ​ 重启前后端项目,分别在两种浏览器上清空缓存,并登录相同账号来模拟多设备登录,当第二次登录时,先登录的浏览器5秒后会弹出自动下线提醒 ### 7.8 腾讯地图api接口介绍 腾讯地图api接口官网:[https://lbs.qq.com/](https://lbs.qq.com/) #### 7.8.1 查看api文档 **1、选择开发文档中的`WebService API`** ![image-20211208202507063](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211208202507063.png) **2、找到`IP定位`,里面有API的使用说明** ![image-20211208202550034](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211208202550034.png) #### 7.8.2 生成API密码 **1、创建应用** ![image-20211208203055503](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211208203055503.png) **2、添加密码,并设置授权ip** ![image-20211208203340258](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211208203340258.png) **3、生成成功,复制key** ![image-20211208203727084](https://gitee.com/cloudimages/cloud-images/raw/master/img/image-20211208203727084.png) #### 7.8.3 编写测试类测试接口 ```java package com.education.admin.api; import com.alibaba.fastjson.JSONObject; import com.jfinal.kit.HttpKit; import org.junit.jupiter.api.Test; import java.util.HashMap; import java.util.Map; // 测试ip定位api public class Test10_getAddressByIp { private static final String API_URL = "https://apis.map.qq.com/ws/location/v1/ip"; private static final String API_KEY = "OFDBZ-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX"; private static final String TEST_IP = "111.20.1.131"; @Test public void TestIp() { Map params = new HashMap(); params.put("ip", TEST_IP); params.put("key", API_KEY); String content = HttpKit.get(API_URL, params); JSONObject jsonObject = JSONObject.parseObject(content); Integer status = jsonObject.getInteger("status"); // 表示接口请求成功 if (status == 0) { JSONObject result = (JSONObject) jsonObject.get("result"); Map adInfo = (Map) result.get("ad_info"); System.out.println(TEST_IP + ":" + adInfo.get("nation") + adInfo.get("province") + adInfo.get("city")); } } } ``` 测试结果: ```java 111.20.1.131:中国陕西省西安市 ``` #### 7.8.4 应用到项目中 **1、将IP定位方法写到工具类中** ```java package com.education.common.utils; public class IpUtils { ... private static final String API_URL = "https://apis.map.qq.com/ws/location/v1/ip"; private static final String API_KEY = "OFDBZ-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX"; public static String getAddressByIp(String ip) { final String CHINA = "中国"; Map params = new HashMap(); params.put("ip", ip); params.put("key", API_KEY); try { String content = HttpKit.get(API_URL, params); JSONObject jsonObject = JSONObject.parseObject(content); Integer status = (Integer) jsonObject.get("status"); // 表示接口请求成功 if (status == 0) { JSONObject result = (JSONObject) jsonObject.get("result"); Map adInfo = (Map) result.get("ad_info"); String nation = (String) adInfo.get("nation"); String province = (String) adInfo.get("province"); String city = (String) adInfo.get("city"); if (CHINA.equals(nation)) { nation = ""; } return nation + province + city; } } catch (Exception e) { logger.error(e.getMessage(), e); } return null; } } ``` **2、修改`checkOnlineUser`方法** ```java package com.education.service.system; // 管理员service @Service public class SystemAdminService extends BaseService { ... @Resource private OnlineUserManager onlineUserManager; @Autowired private HttpServletRequest request; public void checkOnlineUser(Integer userId) { OnlineUser onlineUser = onlineUserManager.getOnlineUser(userId); // 不为空,代表已经在其他设备登录,使用websocket异步发送一条消息推送 if (ObjectUtils.isNotEmpty(onlineUser)) { // 获取已经登录用户的会话id String sessionId = onlineUser.getSessionId(); // 移除原来的用户信息 onlineUserManager.removeOnlineUser(userId); String ip = IpUtils.getAddressIp(request); taskManager.pushTask(() -> { try { Thread.sleep(5000); String adInfo = IpUtils.getAddressByIp(ip); ResultCode resultCode = new ResultCode(ResultCode.FAIL, "您的账号已在" + (adInfo != null ? adInfo : "其它设备上") + "登录,5秒后自动下线,如非本人操作,请立即修改密码"); String message = JSONObject.toJSONString(resultCode); systemWebSocketHandler.sendMessage(sessionId, message); } catch (InterruptedException e) { e.printStackTrace(); } }); } } ... } ``` **注**:本地环境测试时,需要将ip参数设置为正常网络ip才能获取到地区信息 #### 7.8.5 测试 ​ 重启前后端项目,分别在两种浏览器上清空缓存,并登录相同账号来模拟多设备登录,当第二次登录时,先登录的浏览器5秒后会弹出带地区信息的自动下线提醒 ### 7.9 `redis`限制表单重复提交 ​ 表单的重复提交会造成数据库产生重复数据 #### 7.9.1 表单重复提交的场景 - 用户手速过快,在一-瞬间重复点了好几下按钮 - 由于网络延迟问题造成用户重复点击提交按钮 - 表单提交后用户点击**刷新**按钮导致表单重复提交 - 用户提交表单后,点击浏览器的**后退**按钮回退到表单页面后进行再次提交 #### 7.9.2 解决方案 **1、客户端的解决方案** ​ 表单提交后立即将按钮置为禁用状态。这种方法可以有效防止心急的用户在一瞬间多次点击按钮 ​ **缺点**:如果客户端把Javascript给禁止掉,这种方法就无效了 **2、服务器使用Redis防止表单重复提交** ​ 首次访问时将客户端ip地址和访问的接口地址组成一个key存放到Redis,并设置一个有效期,第二次访问先去Redis判断上一次存放的key是否存在 ​ **优点**:响应速度快,适合分布式应用场景 ​ **缺点**:增加了项目的复杂性 **3、通过使用唯一约束或着唯一索引, 防止出现重复数据** ​ **优点**:是简单有效 ​ **缺点**:不适合高并发场景,增加数据库压力 #### 7.9.3 代码实现 **1、创建限制表单重复提交注解类** ```java package com.education.common.annotation; import java.lang.annotation.*; /** * 限制表单重复提交注解类 */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface DisabledResubmit { // 如果五秒内重复请求某一接口,则视为表单的重复提交 int timeOut() default 5; int errorCode() default 0; String message() default ""; } ``` **2、创建限制表单重复提交拦截器类** ```java package com.education.common.interceptor; import com.education.common.annotation.DisabledResubmit; import com.education.common.utils.IpUtils; import com.education.common.utils.ObjectUtils; import com.education.common.utils.Result; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; import org.springframework.web.method.HandlerMethod; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.util.concurrent.TimeUnit; /** * 限制表单重复提交拦截器 */ @Component public class DisabledResubmitInterceptor extends BaseInterceptor { @Autowired private RedisTemplate redisTemplate; /** * 执行接口方法之前判断请求是否重复提交 * @param request * @param response * @param handler * @return * @throws Exception */ @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String ip = IpUtils.getAddressIp(request); // 获取请求的url String target = getRequestUrl(request); if (handler instanceof HandlerMethod) { HandlerMethod handlerMethod = (HandlerMethod) handler; DisabledResubmit disabledResubmit = handlerMethod.getMethod().getAnnotation(DisabledResubmit.class); if (ObjectUtils.isNotEmpty(disabledResubmit)) { String key = ip + ":" + target; // 当key 不存在时,向redis添加这个key, 并返回true, key已经存在的话,直接返回false boolean flag = redisTemplate.opsForValue().setIfAbsent(key, System.currentTimeMillis(), disabledResubmit.timeOut(), TimeUnit.SECONDS); if (!flag) { // 代表表单重复提交 if (ObjectUtils.isNotEmpty(disabledResubmit.message())) { renderJson(response, Result.fail(disabledResubmit.errorCode(), disabledResubmit.message())); return false; } } } } return true; } } ``` **3、在`WebAppConfig`中配置配置拦截器** ```java package com.education.admin.api.config; // 拦截器配置类 @Configuration public class WebAppConfig implements WebMvcConfigurer { ... @Autowired private DisabledResubmitInterceptor disabledResubmitInterceptor; ... @Override public void addInterceptors(InterceptorRegistry registry) { ... registry.addInterceptor(disabledResubmitInterceptor); ... } } ``` **4、编写测试类测试功能** ```java package com.education.admin.api; import com.jfinal.kit.HttpKit; import org.junit.Test; // 测试表单重复提交 public class Test11_DisabledResubmit { @Test public void Test_resubmit() { for (int i = 0; i < 5; i++) { String result = HttpKit.get("http://localhost/test"); System.out.println(result); } } } ``` **5、先启动项目,再运行测试方法** > 测试输出结果: > > ```java > success > {"code":0,"message":"请勿频繁操作","success":false} > {"code":0,"message":"请勿频繁操作","success":false} > {"code":0,"message":"请勿频繁操作","success":false} > {"code":0,"message":"请勿频繁操作","success":false} > ``` ## 八、系统核心之科目及试卷功能 ### 8.1 核心业务表结构设计 #### 8.1.1 系统 ##### 1、地区表(system_region) | 字段名 | 类型 | KEY | 默认值 / 描述 | | :---------- | :----------- | :---------- | :------------ | | id | | PRIMARY KEY | 主键 | | parent_code | VARCHAR(255) | NOT NULL | 父地区代号 | | code | VARCHAR(255) | NOT NULL | 地区代号 | | name | VARCHAR(255) | | 地区名 | | full_name | VARCHAR(255) | | 详细地址 | | create_date | DATETIME | | 创建时间 | | update_date | DATETIME | | 更新时间 | ##### 2、字典类型表(system_dict) | 字段名 | 类型 | KEY | 默认值 / 描述 | | :----- | :----------- | :---------- | :------------ | | id | INT | PRIMARY KEY | 主键 | | name | VARCHAR(100) | | 类型名称 | | type | VARCHAR(100) | | 类型标识 | | sort | INT | DEFAULT 0 | 排序号 | | remark | VARCHAR(500) | | 备注 | ##### 3、字典类型值表(system_dict_value) | 字段名 | 类型 | KEY | 默认值 / 描述 | | :------------- | :----------- | :----------------- | :------------- | | id | INT | PRIMARY KEY | 主键 | | system_dict_id | INT | NOT NULL | 所属字典类型id | | value | VARCHAR(100) | | 字典描述 | | code | INT | | 字典值 | | sort | INT | NOT NULL DEFAULT 0 | 排序号 | | parent_id | INT | DEFAULT 0 | 父类id | #### 8.1.2 教育 ##### 1、试题信息表(question_info) | 字段名 | 类型 | KEY | 默认值 / 描述 | | ------------------ | ------------- | ----------- | ------------------------ | | id | INT | PRIMARY KEY | 主键 | | subject_id | INT | NOT NULL | 所属科目id | | language_points_id | INT | NOT NULL | 所属知识id | | course_id | INT | NOT NULL | 所属课程id | | grade_id | TINYINT(2) | NOT NULL | 所属年级id | | answer | TEXT | | 答案 | | content | TEXT | NOT NULL | 内容 | | analysis | TEXT | | 试题解析 | | school_type | INT(2) | NOT NULL | 所属阶段id | | question_type | INT(2) | NOT NULL | 试题类型 | | options | LONGTEXT | | 试题选项(多个以逗号隔开) | | video_url | VARCHAR(1000) | | 视频地址 | | create_date | DATETIME | | 创建时间 | | update_date | DATETIME | | 更新时间 | ##### 2、试卷试题关联表(test_paper_question_info) | 字段名 | 类型 | KEY | 默认值 / 描述 | | :----------------- | :--- | :---------- | :------------ | | id | INT | PRIMARY KEY | 主键 | | question_info_id | INT | | 试题id | | test_paper_info_id | INT | | 试卷id | ##### 3、科目信息表(subject_info) | 字段名 | 类型 | KEY | 默认值 / 描述 | | :---------- | :----------- | :---------- | :------------ | | id | INT | PRIMARY KEY | 主键 | | name | VARCHAR(100) | | 科目名称 | | school_type | TINYINT(2) | | 所属阶段id | | grade_type | TINYINT(2) | | 所属年级id | | use_flag | TINYINT(1) | | 是否启用 | | create_date | DATETIME | | 创建时间 | | update_date | DATETIME | | 更新时间 | ##### 4、学校信息表(school_info) | 字段名 | 类型 | KEY | 默认值 / 描述 | | :------------- | :----------- | :---------- | :------------ | | id | INT | PRIMARY KEY | 主键 | | name | VARCHAR(100) | | 学校名称 | | school_type | TINYINT(2) | | 所属阶段id | | alias | TINYINT(2) | | 学校简称 | | principal_name | VARCHAR(100) | | 校长姓名 | | mobile | VARCHAR(50) | | 联系方式 | | lng | VARCHAR(50) | | 经度 | | lat | VARCHAR(50) | | 维度 | | address | VARCHAR(100) | | 地址 | | province_code | VARCHAR(50) | | 省份编码 | | city_code | VARCHAR(50) | | 市区编码 | | account_number | INT(4) | | 并发人数 | | create_date | DATETIME | | 创建时间 | | update_date | DATETIME | | 更新时间 | ##### 5、试卷信息表(test_paper_info) | 字段名 | 类型 | KEY | 默认值 / 描述 | | :------------- | :----------- | :---------- | :---------------------- | | id | INT | PRIMARY KEY | 主键 | | name | VARCHAR(100) | | 试卷名称 | | school_type | TINYINT(2) | | 所属阶段id | | grade_type | TINYINT(2) | | 所属年级id | | subject_id | INT | | 所属科目id | | publish_flag | TINYINT(1) | | 是否发布 | | mark | INT | | 试卷总分 | | exam_number | INT(4) | | 考试人数 | | create_type | TINYINT(2) | | 创建类型(1 手动 2 系统) | | correct_number | INT(4) | | 已批改试卷数量 | | remark | VARCHAR(500) | | 备注 | | sort | INT | | 排序号 | | create_date | DATETIME | | 创建时间 | | update_date | DATETIME | | 更新时间 | ##### 6、用户答题表(user_question_answer) | 字段名 | 类型 | KEY | 默认值 / 描述 | | :----------------- | :------------ | :---------- | :------------------------------ | | id | INT | PRIMARY KEY | 主键 | | student_id | INT | | 所属学员id | | question_info_id | INT | | 所属试题id | | answer | INT | | 用户答案 | | test_paper_info_id | INT | | 所属试卷id | | is_right | TINYINT(1) | | 答案是否正确(已弃用) | | enclosure | VARCHAR(1000) | | 答案附件(多个已逗号隔开) | | mark | INT | | 试题得分 | | comment | VARCHAR(255) | | 试题评语 | | question_points | INT | | 试题分数 | | course_id | INT | | 所属课程id | | correct_status | TINYINT(2) | | 批改状态 0 错误 1 正确 2 待批改 | | create_date | DATETIME | | 创建时间 | | update_date | DATETIME | | 更新时间 | ##### 7、课程信息表(course_info) | 字段名 | 类型 | KEY | 默认值 / 描述 | | :---------- | :----------- | :---------- | :------------ | | id | INT | PRIMARY KEY | 主键 | | name | VARCHAR(100) | | 课程名称 | | grade_type | TINYINT(2) | | 所属年级id | | school_type | TINYINT(2) | | 所属阶段id | | subject_id | INT | | 所属科目id | | represent | VARCHAR(500) | | 课程简介 | | code | INT | | 课程编号 | | sort | INT | | 排序号 | | parent_id | INT | DEFAULT 0 | 父类id | | create_date | DATETIME | | 创建时间 | | update_date | DATETIME | | 更新时间 | ##### 8、考试记录表(exam_info) | 字段名 | 类型 | KEY | 默认值 / 描述 | | :----------------- | :----------- | :---------- | :------------ | | id | INT | PRIMARY KEY | 主键 | | student_id | INT | | 所属学员id | | mark | INT(4) | | 考试得分 | | system_mark | INT(4) | | 系统评分 | | teacher_mark | INT(4) | | 老师评分 | | time | VARCHAR(100) | | 考试用时 | | test_paper_info_id | INT | | 所属试卷id | | subject_id | INT | | 所属科目id | | grade_type | INT | | 所属年级id | | correct_flag | INT | | 是否批改 | | create_date | DATETIME | | 创建时间 | | update_date | DATETIME | | 更新时间 | ##### 9、知识点信息表(language_points) | 字段名 | 类型 | KEY | 默认值 / 描述 | | :---------- | :----------- | :---------- | :------------- | | id | INT | PRIMARY KEY | 主键 | | grade_type | INT | | 所属年级id | | name | VARCHAR(100) | | 知识点名称 | | subject_id | INT | | 所属科目id | | parent_id | INT(4) | | 父类id | | hasChildren | TINYINT(1) | | 是否拥有子节点 | | school_type | INT | | 所属阶段 | | sort | INT | | 排序号 | | create_date | DATETIME | | 创建时间 | | update_date | DATETIME | | 更新时间 | #### 8.1.3 用户(user_info) ##### 1、学生信息表(student_info) | 字段名 | 类型 | KEY | 默认值 / 描述 | | :---------- | :----------- | :---------- | :------------ | | id | INT | PRIMARY KEY | 主键 | | grade_type | INT | | 所属年级id | | name | VARCHAR(100) | | 姓名 | | age | INT(4) | | 年龄 | | sex | INT(2) | | 1 男 2 女 | | mobile | VARCHAR(100) | | 联系方式 | | address | VARCHAR(100) | | 家庭住址 | | school_id | INT | | 排序号 | | mother_name | VARCHAR(100) | | 母亲名称 | | father_name | VARCHAR(100) | | 父亲名称 | | head_img | VARCHAR(100) | | 头像 | | create_date | DATETIME | | 创建时间 | | update_date | DATETIME | | 更新时间 | ### 8.2 课程管理之文件上传 #### 8.2.1 文件上传的开发步骤 - 前端使用`Element-UI`文件上传组件 - 对上传的文件进行归类 - 对上传的文件进行重命名 ### 8.3 文件上传及文件删除功能 ### 8.4 课程添加功能 ### 8.5 Tree-Table数据的懒加载 ### 8.6 知识点添加修改模块 ### 8.7 Vue集成百度地图 ### 8.8 学校管理模块 ### 8.9 Spring Boot集成百度富文本编辑器 ### 8.10 试题管理之添加试题 ### 8.11 试题修改 ### 8.12 课程试题管理 ## 九、系统核心之试卷及试卷关联 ## 十、学生端考试功能模块