FEBS-Vue文档

FEBS-Vue为FEBS-Shiro的前后端分离版本,前端使用Vue全家桶,组件库采用Ant-Design-Vue

文档里介绍的示例是在Windows10操作系统下完成的,后端编辑器使用IDEA,前端编辑器使用WebStorm。

项目导入

为了方便,我直接在桌面上通过git bash克隆项目:

1
git clone https://github.com/wuyouzhuguli/FEBS-Vue.git

克隆后,桌面上多出一个FEBS-Vue文件夹:

QQ截图20190409165245.png

backend为后端项目源码,frontend为前端项目源码,sql为数据库初始化脚本。

JDK

因为项目用到了JDK 8的一些特性,所以JDK最低版本不能低于8。

JDK 8官方下载地址:https://www.oracle.com/technetwork/java/javase/downloads。

安装Node.js

Node.js下载地址:http://nodejs.cn/download/,直接安装即可,安装后查看其版本:

QQ截图20190409170853.png

Node.js集成了npm,所以安装好Node.js后npm就可以使用了:

QQ截图20190409171301.png

安装yarn

在CMD中执行npm install -g yarn

QQ截图20190409171954.png

因为我之前已经安装过了,所以这里就相当于更新操作了。

安装Redis

项目缓存数据库使用的是Redis,所以在导入项目前需先安装Redis。

Redis Windows版本下载地址:https://github.com/MicrosoftArchive/redis/releases。直接下载zip版本解压到任意目录即可。

下载后,使用cmd命令切换到Redis根目录,然后运行redis-server.exe redis.windows.conf启动即可:

QQ截图20190409172902.png

安装MySQL

项目数据库采用MySQL社区版,版本为5.7.x。

下载地址:https://dev.mysql.com/downloads/windows/installer/5.7.html

导入SQL

使用Navicat新建一个数据库:

QQ截图20190409173934.png

然后导入SQL脚本即可。

导入后端项目

IDEA选择backend: QQ截图20190409184301.png

导入项目后安装lombok插件(不懂lombok可以自行百度):

QQ截图20190409185417.png

安装完重启IDEA才能生效。

接着修改application.yml中的数据库和Redis配置,修改完后通过Spring Boot入口类FebsApplication启动即可:

QQ截图20190409184818.png

QQ截图20190409185112.png

接着开始导入前端项目。

导入前端项目

使用WebStorm打开frontend:

QQ截图20190409185643.png

在终端输入yarn install命令安装依赖:

QQ截图20190409190322.png

稍等片刻,坐与放宽。

依赖下载完毕后,输入yarn start启动前端项目:

QQ截图20190409191649.png

浏览器访问http://localhost:8081

QQ截图20190409191833.png

项目部署

下面演示如何在Linux上部署项目(例子采用CentOS7)。

Vagrant创建CentOS

如果没有CentOS7环境可以使用Vagrant快速构建一个CentOS虚拟机,具体可以参考:https://mrbird.cc/Create-Virtual-Machine-By-Vagrant.html。我的CentOS虚拟机IP为:192.168.33.11。

使用命令timedatectl set-timezone Asia/Shanghai设置CentOS的时区,以避免因时区带来的BUG。

Java环境配置

  1. 下载JDK8:

QQ截图20190409204143.png

下载后通过Vagrant共享到CentOS上(我的Vagrantfile共享配置为config.vm.synced_folder "./sync", "/vagrant", create:true, owner: "root", group: "root"):

QQ截图20190409204745.png

  1. 安装JDK8:
1
rpm -ivh jdk-8u201-linux-x64.rpm

QQ截图20190409204849.png

  1. 配置环境变量
1
vim /etc/profile

输入以下内容:

1
2
3
4
5
JAVA_HOME=/usr/java/jdk1.8.0_201
JRE_HOME=/usr/java/jdk1.8.0_201/jre
PATH=$PATH:$JAVA_HOME/bin:$JRE_HOME/bin
CLASSPATH=.:$JAVA_HOME/lib/dt.jar:$JAVA_HOME/lib/tools.jar:$JRE_HOME/lib
export JAVA_HOME JRE_HOME PATH CLASSPATH

然后执行以下命令生效:

1
source /etc/profile

安装Docker

官方安装教程:https://docs.docker.com/install/linux/docker-ce/centos/

安装好后:

QQ截图20190409205834.png

Docker安装MySQL

  1. 拉取MySQL镜像:

QQ截图20190409210335.png

  1. 创建目录/home/febs/mysql,用于挂载MySQL volume:

QQ截图20190409210901.png

  1. 创建MySQL容器:
1
2
docker run -d --name mysql -p 3306:3306 \
-v $(pwd):/var/lib/mysql -e MYSQL_ROOT_PASSWORD=123456 mysql:5.7.25

QQ截图20190409211225.png

  1. 使用Navicat连接MySQL,创建数据库并导入数据:

连接:

QQ截图20190409211912.png

新增数据库:

QQ截图20190409212130.png

导入SQL:

QQ截图20190409212334.png

Docker安装Redis

  1. 拉取Redis镜像:

QQ截图20190409213345.png

  1. 创建文件/home/febs/redis/conf/redis.conf,用于挂载Redis配置文件:

QQ截图20190409213604.png

  1. 创建Redis容器:
1
2
3
docker run -d -p 6379:6379 \
-v /home/febs/redis/conf/redis.conf:/usr/local/etc/redis/redis.conf \
--name redis redis:4.0.14

测试连接:

QQ截图20190410094147.png

Docker安装Nginx

  1. 拉取Nginx镜像:

QQ截图20190409220000.png

  1. 创建目录/home/febs/nginx/html、/home/febs/nginx/logs和文件/home/febs/nginx/conf/nginx.conf,分别用于挂载Nginx html,logs和配置文件:

QQ截图20190409225225.png QQ截图20190409220212.png

  1. 修改Nginx配置:
1
vim /home/febs/nginx/conf/nginx.conf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
worker_processes  auto;

error_log /var/log/nginx/error.log;
pid /run/nginx.pid;

events {
worker_connections 1024;
}

http {
include mime.types;
default_type application/octet-stream;

server_names_hash_bucket_size 512;
client_header_buffer_size 32k;
large_client_header_buffers 4 32k;
client_max_body_size 50m;

sendfile on;
tcp_nopush on;

keepalive_timeout 60;
tcp_nodelay on;

fastcgi_connect_timeout 300;
fastcgi_send_timeout 300;
fastcgi_read_timeout 300;
fastcgi_buffer_size 64k;
fastcgi_buffers 4 64k;
fastcgi_busy_buffers_size 128k;
fastcgi_temp_file_write_size 256k;
fastcgi_intercept_errors on;

gzip on;
gzip_min_length 1k;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_comp_level 6;
gzip_types text/plain application/javascript application/x-javascript text/javascript text/css application/xml;
gzip_vary on;
gzip_proxied expired no-cache no-store private auth;
gzip_disable "MSIE [1-6]\.";

limit_conn_zone $binary_remote_addr zone=perip:10m;
limit_conn_zone $server_name zone=perserver:10m;

server_tokens off;
access_log off;


server {
listen 80;
server_name localhost;

charset utf-8;

location / {
root html;
index index.html index.htm;
}
location = /50x.html {
root html;
}
}
}
  1. 创建Nginx容器:
1
2
3
4
docker run --name nginx -d -p 80:80  \
-v /home/febs/nginx/conf/nginx.conf:/etc/ng inx/nginx.conf \
-v /home/febs/nginx/html:/etc/nginx/html \
-v /home/febs/nginx/logs:/var/log/nginx nginx:1.14.2

后端部署

修改application.yml中数据库和redis的连接配置,然后将项目打包成jar文件:

2019-04-10_094842.png

将其上传到CentOS虚拟机的/home/febs/backend目录下:

2019-04-10_100008.png

编写一个启动项目的shell脚本:

1
vim start.sh

内容:

1
nohup java -jar febs_shiro_jwt-1.0.0-release.jar &

编写一个关停项目的shell脚本:

1
vim stop.sh

内容:

1
2
3
4
5
6
7
8
PID=`ps -ef | grep febs_shiro_jwt-1.0.0-release.jar | grep -v grep | awk '{print $2}'`
if [ -z "$PID" ]
then
echo Application is already stopped
else
echo kill $PID
kill -9 $PID
fi

授权,让其可执行:

2019-04-10_100815.png

启动项目:

1
2
3
./start.sh

tail -f nohup.out

看到如下输出的时候说明后端项目启动成功:

2019-04-10_101222.png

前端部署

点击build:

2019-04-10_095141.png

build成功后,项目目录下会多出个dist文件夹:

2019-04-10_095533.png

将这个目录下的文件上传到CentOS虚拟机的/home/febs/nginx/html目录下:

2019-04-10_101510.png

浏览器访问http://192.168.33.11/#/login

2019-04-10_101826.png

2019-04-10_104514.png

部署成功。

后端项目介绍

项目结构

后端项目目录含义如下图所示:

backend-xmind.png

Common模块文件含义如下图所示:

2019-04-08_113758.png

其他模块文件较为简单,略。

项目配置

application.yml中除了各个插件的配置外,下面这段配置为系统配置:

1
2
3
4
5
6
7
8
9
febs:
openAopLog: true
max:
batch:
insert:
num: 1000
shiro:
anonUrl: /login,/logout/**,/regist,/user/check/**
jwtTimeOut: 3600

  • febs.opAopLog:Boolean类型,取值true或者false,为true时表示开启Aop记录用户的操作日志,需和@Log注解搭配使用。

  • febs.max.batch.insert.num:大于0的Integer类型,表示Excel导入数据当次最大入库数据量。比如配置为1000时表示入库数据为0 - 1000 时只会执行一次数据库commit操作。

  • febs.shiro.anonUrl:逗号分隔的字符串,表示无需认证的资源路径。

  • febs.shiro.jwtTimeOut:定义token的有效时间,单位为秒,比如配置为3600表示token一个小时内有效,超过一个小时后需要重新认证。

RESTful风格

系统Controller暴露的接口风格为RESTful,通过HTTP请求method对应增删改查类型,响应以HTTP Code为判断依据。

以UserController为例子:

描述请求URIHTTP Method对应注解
查询所有用户/userGET@GetMapping
通过用户名查找用户/user/{username}GET@GetMapping
新增用户/userPOST@PostMapping
修改用户/userPUT@PutMapping
删除用户/user/{userIds}DELETE@DeleteMapping

Controller方法默认返回200状态码,当Controller抛出异常时,将被GlobalExceptionHandler捕获,根据异常类型,返回不同的HTTP状态码:

异常类型异常描述状态码对应常量
UnauthorizedException未授权异常,权限不足异常403HttpStatus.FORBIDDEN
LimitAccessException限制访问异常,访问接口频率超限429HttpStatus.TOO_MANY_REQUESTS
ConstraintViolationException参数校验异常(普通传参)400HttpStatus.BAD_REQUEST
BindException参数校验异常(实体对象传参)400HttpStatus.BAD_REQUEST
FebsExceptionFebs系统异常500HttpStatus.INTERNAL_SERVER_ERROR
Exception剩下的别的异常500HttpStatus.INTERNAL_SERVER_ERROR

数据层介绍

首先看看表结构,数据表分为两大类:定时任务表和系统表。

QQ截图20190408141755.png

以qrtz_开头的为定时任务表,定时任务有基于内存和基于数据库的,本项目使用的是基于数据库持久化的方案。要详细了解这些表可以参考文章:http://www.ibloger.net/article/2650.html

以t_开头的为系统表,他们的关系如下所示:

2019-04-08_145115.png

其中用户,角色和权限之间的关系使用的是经典的RBAC(Role-Based Access Control,基于角色的访问控制)模型。简单地说,一个用户拥有若干角色,每一个角色拥有若干权限。这样,就构造成“用户-角色-权限”的授权模型。在这种模型中,用户与角色之间,角色与权限之间,一般者是多对多的关系。如下图所示:

QQ截图20190408145646.png

比如获取用户名为mrbrid的用户权限过程为:

  1. 通过mrbrid的user_id从t_user_role表获取对应的role_id;

  2. 通过第1步获取的role_id从t_role_menu表获取对应的menu_id;

  3. 通过第2步获取的menu_id从t_menu获取menu相关信息(t_menu表的permission为权限信息)。

数据层框架采用的是MybatisPlus,具体可以参考其官方文档。

登录逻辑

登录逻辑如下图所示:

QQ截图20190408153154.png

这里详细说明下登录成功后的第2步、第3步和第4步过程:

  • 登录成功后,构建一个ActiveUser对象,对应LoginControllersaveTokenToRedis方法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // 构建在线用户
    ActiveUser activeUser = new ActiveUser();
    activeUser.setUsername(user.getUsername());
    activeUser.setIp(ip);
    activeUser.setToken(token.getToken());
    activeUser.setLoginAddress(AddressUtil.getCityInfo(DbSearcher.BTREE_ALGORITHM, ip));

    // zset 存储登录用户,score 为过期时间戳
    this.redisService.zadd(FebsConstant.ACTIVE_USERS_ZSET_PREFIX, Double.valueOf(token.getExipreAt()), mapper.writeValueAsString(activeUser));

    然后将activeUser通过ObjectMapper序列化,存储到Redis的Zset结构中,key为FebsConstant.ACTIVE_USERS_ZSET_PREFIX(即febs.user.active),score为该用户的登录过期时间点(即Tokean失效时间),value为activeUser序列化值。

    Zset它在set的基础上增加了一个顺序属性(score),这一属性在添加修改元素时候可以指定,每次指定后,zset会自动重新按新的值调整顺序。可以理解为有两列字段的数据表,一列存value,一列存顺序编号。

    Zset相关Redis命令:

    redis-zset.png

    比如当用户mrbird和scott登录成功后,查看Redis中key为febs.user.active的值:

    QQ截图20190408154805.png

  • 将Token存储到Redis中:key为febs.cache.token.token值.IP地址,value为token值,有效期为token的有效时长,对应的源码为:

    1
    this.redisService.set(FebsConstant.TOKEN_CACHE_PREFIX + token.getToken() + StringPool.DOT + ip, token.getToken(), properties.getShiro().getJwtTimeOut() * 1000);
  • 返回前端数据包括:

    1.token:token;

    2.exipreTime:token过期时间;

    3.roles:用户角色;

    4.permissions:用户权限;

    5.config:用户前端系统的个性化配置;

    6.user:用户信息(不包括密码)。

    比如,当scott登录成功后,接口返回数据如下所示:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    {
    "data": {
    "permissions": [
    "user:view",
    "dept:add",
    "job:export",
    "role:add",
    "weather:view",
    "dict:add",
    "role:export",
    "menu:export",
    "dict:view",
    "dept:export",
    "menu:view",
    "role:view",
    "user:export",
    "job:add",
    "dept:view",
    "article:view",
    "log:view",
    "jobLog:view",
    "job:view",
    "menu:add",
    "redis:view",
    "log:export",
    "movie:coming",
    "movie:hot",
    "dict:export",
    "jobLog:export",
    "user:online"
    ],
    "roles": [
    "注册用户"
    ],
    "exipreTime": "20190408164521",
    "config": {
    "userId": 2,
    "theme": "light",
    "layout": "side",
    "multiPage": "0",
    "fixSiderbar": "1",
    "fixHeader": "1",
    "color": "rgb(24, 144, 255)"
    },
    "user": {
    "userId": 2,
    "username": "scott",
    "password": "it's a secret",
    "deptId": 6,
    "deptName": null,
    "email": "scott@qq.com",
    "mobile": "15134627380",
    "status": "1",
    "createTime": "2017-12-30 00:16:39",
    "modifyTime": "2019-01-18 08:59:09",
    "lastLoginTime": "2019-01-23 15:34:28",
    "ssex": "0",
    "description": "我是scott,嗯嗯",
    "avatar": "gaOngJwsRYRaVAuXXcmB.png",
    "roleId": null,
    "roleName": null,
    "sortField": null,
    "sortOrder": null,
    "createTimeFrom": null,
    "createTimeTo": null,
    "id": "YBeNsMJ0ZJm9GLJP1rlO",
    "authCacheKey": 2
    },
    "token": "b25e39b47e774b4a05b3cb1555fc377f209457c3fd339d373d3fca7b1ea8be56fdc6ed05b7ffb0700e7300d242fb83b57b35f45ee1b155b380 50a0671bc7ec54c2f2c5bb1aee0651db69ce657e8ab4cb79c7806209103eda8a3bc96aa043a0144ae3c06a5c549ac168183c37384cf4347e450bf11644d0 62c31ffc3059e63722f849a5de4540b0d1"
    },
    "message": "认证成功"
    }

Redis缓存使用

在系统启动过程中,会执行缓存初始化操作,对应CacheInitRunner

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Slf4j
@Component
public class CacheInitRunner implements ApplicationRunner {
@Autowired
private UserService userService;
@Autowired
private UserManager userManager;

@Override
public void run(ApplicationArguments args) {
try {
...
List<User> list = this.userService.list();
for (User user : list) {
userManager.loadUserRedisCache(user);
}
} catch (Exception e) {
...
}
}
}

loadUserRedisCache方法源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 将用户相关信息添加到 Redis缓存中
*
* @param user user
*/
public void loadUserRedisCache(User user) throws Exception {
// 缓存用户
cacheService.saveUser(user.getUsername());
// 缓存用户角色
cacheService.saveRoles(user.getUsername());
// 缓存用户权限
cacheService.savePermissions(user.getUsername());
// 缓存用户个性化配置
cacheService.saveUserConfigs(String.valueOf(user.getUserId()));
}

可以看到,这一过程缓存了用户信息,用户角色信息,用户权限信息,用户的个性化配置信息,缓存的具体key,value可以查看上述方法的源码。通过这些缓存,可以一定程度减轻数据库压力。

为了确保缓存数据和数据库数据的一致性,我们必须在相应的增删改方法中对缓存进行相应的操作。比如在更新用户后,我们必须更新相应的缓存:

1
2
3
4
5
6
7
8
9
10
@Override
@Transactional
public void updateUser(User user) throws Exception {
...

// 重新将用户信息,用户角色信息,用户权限信息 加载到 redis中
cacheService.saveUser(user.getUsername());
cacheService.saveRoles(user.getUsername());
cacheService.savePermissions(user.getUsername());
}

总而言之,由于我们在启动系统的时候缓存了用户信息,用户角色信息,用户权限信息,用户的个性化配置信息,之后凡是涉及到用户,用户角色,用户权限和用户个性化配置的相关增删改操作都应该及时更新相应的缓存。

动态路由构建

不同的用户拥有不同的角色,不同的角色对应不同的菜单权限,所以我们需要通过用户查询出对应的菜单列表,然后将列表构建成前端需要的路由(前端根据路由信息构建相应的菜单)。

获取用户路由的方法为UserManage#getUserRouters

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 通过用户名构建 Vue路由
*
* @param username 用户名
* @return 路由集合
*/
public ArrayList<VueRouter<Menu>> getUserRouters(String username) {
List<VueRouter<Menu>> routes = new ArrayList<>();
List<Menu> menus = this.menuService.findUserMenus(username);
menus.forEach(menu -> {
VueRouter<Menu> route = new VueRouter<>();
route.setId(menu.getMenuId().toString());
route.setParentId(menu.getParentId().toString());
route.setIcon(menu.getIcon());
route.setPath(menu.getPath());
route.setComponent(menu.getComponent());
route.setName(menu.getMenuName());
route.setMeta(new RouterMeta(true, null));
routes.add(route);
});
return TreeUtil.buildVueRouter(routes);
}

比如用户mrbird对应的前端路由为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
[
{
"path": "/",
"name": "主页",
"component": "MenuView",
"icon": "none",
"redirect": "/home",
"children": [
{
"path": "/home",
"name": "系统主页",
"component": "HomePageView",
"icon": "home",
"meta": {
"closeable": false,
"isShow": true
}
},
{
"path": "/system",
"name": "系统管理",
"component": "PageView",
"icon": "appstore-o",
"meta": {
"closeable": true
},
"children": [
{
"path": "/system/user",
"name": "用户管理",
"component": "system/user/User",
"icon": "",
"meta": {
"closeable": true
}
},
{
"path": "/system/role",
"name": "角色管理",
"component": "system/role/Role",
"icon": "",
"meta": {
"closeable": true
}
},
{
"path": "/system/menu",
"name": "菜单管理",
"component": "system/menu/Menu",
"icon": "",
"meta": {
"closeable": true
}
},
{
"path": "/system/dept",
"name": "部门管理",
"component": "system/dept/Dept",
"icon": "",
"meta": {
"closeable": true
}
},
{
"path": "/system/dict",
"name": "字典管理",
"component": "system/dict/Dict",
"icon": "",
"meta": {
"closeable": true
}
}
]
},
{
"path": "/monitor",
"name": "系统监控",
"component": "PageView",
"icon": "dashboard",
"meta": {
"closeable": true
},
"children": [
{
"path": "/monitor/online",
"name": "在线用户",
"component": "monitor/Online",
"icon": "",
"meta": {
"closeable": true
}
},
{
"path": "/monitor/systemlog",
"name": "系统日志",
"component": "monitor/SystemLog",
"icon": "",
"meta": {
"closeable": true
}
},
{
"path": "/monitor/redis/info",
"name": "Redis监控",
"component": "monitor/RedisInfo",
"icon": "",
"meta": {
"closeable": true
}
},
{
"path": "/monitor/httptrace",
"name": "请求追踪",
"component": "monitor/Httptrace",
"meta": {
"closeable": true
}
},
{
"path": "/monitor/system",
"name": "系统信息",
"component": "EmptyPageView",
"meta": {
"closeable": true
},
"children": [
{
"path": "/monitor/system/jvminfo",
"name": "JVM信息",
"component": "monitor/JvmInfo",
"meta": {
"closeable": true
}
},
{
"path": "/monitor/system/tomcatinfo",
"name": "Tomcat信息",
"component": "monitor/TomcatInfo",
"meta": {
"closeable": true
}
},
{
"path": "/monitor/system/info",
"name": "服务器信息",
"component": "monitor/SystemInfo",
"meta": {
"closeable": true
}
}
]
}
]
},
{
"path": "/job",
"name": "任务调度",
"component": "PageView",
"icon": "clock-circle-o",
"meta": {
"closeable": true
},
"children": [
{
"path": "/job/job",
"name": "定时任务",
"component": "quartz/job/Job",
"icon": "",
"meta": {
"closeable": true
}
},
{
"path": "/job/log",
"name": "调度日志",
"component": "quartz/log/JobLog",
"icon": "",
"meta": {
"closeable": true
}
}
]
},
{
"path": "/web",
"name": "网络资源",
"component": "PageView",
"icon": "compass",
"meta": {
"closeable": true
},
"children": [
{
"path": "/web/weather",
"name": "天气查询",
"component": "web/Weather",
"icon": "",
"meta": {
"closeable": true
}
},
{
"path": "/web/dailyArticle",
"name": "每日一文",
"component": "web/DailyArticle",
"icon": "",
"meta": {
"closeable": true
}
},
{
"path": "/web/movie",
"name": "影视资讯",
"component": "EmptyPageView",
"meta": {
"closeable": true
},
"children": [
{
"path": "/web/movie/hot",
"name": "正在热映",
"component": "web/MovieHot",
"icon": "",
"meta": {
"closeable": true
}
},
{
"path": "/web/movie/coming",
"name": "即将上映",
"component": "web/MovieComing",
"icon": "",
"meta": {
"closeable": true
}
}
]
}
]
},
{
"path": "/others",
"name": "其他模块",
"component": "PageView",
"icon": "coffee",
"meta": {
"closeable": true
},
"children": [
{
"path": "/others/excel",
"name": "导入导出",
"component": "others/Excel",
"meta": {
"closeable": true
}
}
]
},
{
"path": "/profile",
"name": "个人中心",
"component": "personal/Profile",
"icon": "none",
"meta": {
"closeable": true,
"isShow": false
}
}
]
},
{
"path": "*",
"name": "404",
"component": "error/404"
}
]

关于Vue Router可以参考:https://router.vuejs.org/zh/

权限控制

我们可以在Controller的方法上通过Shiro相关的权限注解进行权限控制,比如下面这个方法只有当用户拥有user:add权限才能访问:

1
2
3
4
5
@PostMapping("/user")
@RequiresPermissions("user:add")
public void addUser(@Valid User user) {
...
}

当用户没有user:add权限时,系统将抛出UnauthorizedException异常,由GlobalExceptionHandler捕获,返回403状态码。

更多Shiro提供的权限注解可以参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 表示当前Subject已经通过login进行了身份验证;即Subject.isAuthenticated()返回true。
@RequiresAuthentication

// 表示当前Subject已经身份验证或者通过记住我登录的。
@RequiresUser

// 表示当前Subject没有身份验证或通过记住我登录过,即是游客身份。
@RequiresGuest

// 表示当前Subject需要角色admin和user。
@RequiresRoles(value={"admin", "user"}, logical= Logical.AND)

// 表示当前Subject需要权限user:a或user:b。
@RequiresPermissions (value={"user:a", "user:b"}, logical= Logical.OR)

多数据源

多数据源采用的是MyBatis Plus提供的方案:https://mp.baomidou.com/guide/dynamic-datasource.html

代码生成

目前只有后端代码生成器,采用的是MyBatis Plus提供的方案,对应源码cc.mrbird.febs.common.generator.CodeGenerator,执行其main方法,然后输入表名,就可以生成相应的domain、dao、service、controller、mapper.xml。

Excel导入导出

Excel导入导出使用的插件为:https://gitee.com/wuwenze/ExcelKit,具体操作规则可以仔细阅读这个项目的Readme.md

统一参数校验

统一参数校验可以参考我的博客:Spring Boot配合Hibernate Validator参数校验

SQL打印

SQL打印采用的插件为p6spy,要开启p6spy的SQL打印功能,只需将配置文件application.yml中的spring.datasource.dynamic.p6spy改为true即可。

在p6spy.properties文件中可以配置打印规则:

1
2
3
4
5
6
7
8
9
10
11
12
# 使用日志系统记录 sql
appender=com.p6spy.engine.spy.appender.Slf4JLogger
# 自定义日志打印
logMessageFormat=cc.mrbird.febs.common.config.P6spySqlFormatConfig
# 是否开启慢 SQL记录
outagedetection=true
# 慢 SQL记录标准 2 秒
outagedetectioninterval=2
# 开启过滤
filter=true
# 包含 QRTZ的不打印
exclude=QRTZ

SQL打印效果如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
2019-04-08 15:29:50 | INFO  | http-nio-9527-exec-1 | p6spy | 2019-04-08 15:29:50 | 耗时 73 ms | SQL 语句:
UPDATE t_user SET last_login_time='2019-04-08T15:29:50.724+0800' WHERE username = 'mrbird';
2019-04-08 15:29:50 | INFO | http-nio-9527-exec-1 | p6spy | 2019-04-08 15:29:50 | 耗时 0 ms | SQL 语句:
SELECT USER_ID,username,password,dept_id,email,mobile,status,create_time,modify_time,last_login_time,ssex,description,avatar FROM t_user WHERE username = 'mrbird';
2019-04-08 15:29:52 | INFO | http-nio-9527-exec-1 | p6spy | 2019-04-08 15:29:52 | 耗时 489 ms | SQL 语句:
INSERT INTO t_login_log ( username, login_time, location, ip ) VALUES ( 'mrbird', '2019-04-08T15:29:50.874+0800', '', '127.0.0.1' );
2019-04-08 15:45:20 | INFO | http-nio-9527-exec-7 | p6spy | 2019-04-08 15:45:20 | 耗时 1 ms | SQL 语句:
UPDATE t_user SET last_login_time='2019-04-08T15:45:20.193+0800' WHERE username = 'scott';
2019-04-08 15:45:20 | INFO | http-nio-9527-exec-7 | p6spy | 2019-04-08 15:45:20 | 耗时 0 ms | SQL 语句:
SELECT USER_ID,username,password,dept_id,email,mobile,status,create_time,modify_time,last_login_time,ssex,description,avatar FROM t_user WHERE username = 'scott';
2019-04-08 15:45:21 | INFO | http-nio-9527-exec-7 | p6spy | 2019-04-08 15:45:21 | 耗时 89 ms | SQL 语句:
INSERT INTO t_login_log ( username, login_time, location, ip ) VALUES ( 'scott', '2019-04-08T15:45:20.466+0800', '', '127.0.0.1' );

开启这个功能方便我们开发调试,生产环境最好关闭这个功能,因为它在一定程度上会造成性能耗损。

更多p6psy的配置可以参考:https://p6spy.readthedocs.io/en/latest/configandusage.html

AOP记录操作日志

具体可以参考我的博客:Spring Boot AOP记录用户操作日志

记录操作日志的过程可以改为异步的方式,这样不会造成接口性能损耗,可以参考我的博客:Spring Boot 中的异步调用

接口限流

项目中@Limit注解可以实现接口的限流。即规定一段时间内最多可以访问该接口的次数,超过这个次数则抛出LimitAccessException异常。@Limit注解如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import cc.mrbird.common.domain.LimitType;
import java.lang.annotation.*;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Limit {
// 资源名称,用于描述接口功能
String name() default "";
// 资源 key
String key() default "";
// key prefix
String prefix() default "";
// 时间的,单位秒
int period();
// 限制访问次数
int count();
// 限制类型
LimitType limitType() default LimitType.CUSTOMER;
}

其中,limitType包含传统类型限流和根据IP限流,其为枚举类型:

1
2
3
4
5
6
public enum LimitType {
// 传统类型
CUSTOMER,
// 根据 IP 限制
IP;
}

下面举个使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import cc.mrbird.common.annotation.Limit;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.concurrent.atomic.AtomicInteger;

@RestController
public class TestController {

private static final AtomicInteger ATOMIC_INTEGER = new AtomicInteger();

@Limit(key = "test", period = 600, count = 10, name = "resource", prefix = "limit")
@GetMapping("/test")
public int testLimiter() {
return ATOMIC_INTEGER.incrementAndGet();
}
}

上面配置表示使用传统限流的方式,testLimiter方法在600秒内最多只能访问10次。当600秒内第11次访问该接口时,接口将抛出LimitAccessException异常。

Shiro教程

  1. Apache Shiro简介

  2. Spring Boot Shiro用户认证

  3. Spring Boot Shiro 添加记住我功能

  4. Spring Boot Shiro权限控制

  5. Spring Boot Shiro中使用缓存

  6. Spring Boot Thymeleaf中使用Shiro标签

  7. Spring Boot Shiro在线会话管理

Shiro如何整合JWT

Shiro如何整合JWT可以参考:https://gitlab.com/wuyouzhuguli/shiro_jwt

为了简化过程,例子没有使用数据库和Redis,在内存中模拟了两个用户:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 模拟两个用户
*
* @return List<User>
*/
private static List<User> users() {
List<User> users = new ArrayList<>();
// 模拟两个用户:
// 1. 用户名 admin,密码 123456,角色 admin(管理员),权限 "user:add","user:view"
// 1. 用户名 scott,密码 123456,角色 regist(注册用户),权限 "user:view"
users.add(new User(
"admin",
"bfc62b3f67a4c3e57df84dad8cc48a3b",
new HashSet<>(Collections.singletonList("admin")),
new HashSet<>(Arrays.asList("user:add", "user:view"))));
users.add(new User(
"scott",
"11bd73355c7bbbac151e4e4f943e59be",
new HashSet<>(Collections.singletonList("regist")),
new HashSet<>(Collections.singletonList("user:view"))));
return users;
}

可以使用PostMan进行接口测试。

前端项目介绍

项目结构

项目机构如下图所示:

2019-04-08_185510.png

数据存储

在用户登录成功后,项目会通过Vuex和localstorage存储一些数据供项目全局使用。

正如前面所说,当用户登录成功后,后端会返回如下数据:

  1. token:token;

  2. exipreTime:token过期时间;

  3. roles:用户角色;

  4. permissions:用户权限;

  5. config:用户前端系统的个性化配置;

  6. user:用户信息(不包括密码)。

在src/views/login/Login.vue文件中你会看到如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
...mapMutations({
setToken: 'account/setToken',
setExpireTime: 'account/setExpireTime',
setPermissions: 'account/setPermissions',
setRoles: 'account/setRoles',
setUser: 'account/setUser',
setTheme: 'setting/setTheme',
setLayout: 'setting/setLayout',
setMultipage: 'setting/setMultipage',
fixSiderbar: 'setting/fixSiderbar',
fixHeader: 'setting/fixHeader',
setColor: 'setting/setColor'
}),
saveLoginData (data) {
this.setToken(data.token)
this.setExpireTime(data.exipreTime)
this.setUser(data.user)
this.setPermissions(data.permissions)
this.setRoles(data.roles)
this.setTheme(data.config.theme)
this.setLayout(data.config.layout)
this.setMultipage(data.config.multiPage === '1')
this.fixSiderbar(data.config.fixSiderbar === '1')
this.fixHeader(data.config.fixHeader === '1')
this.setColor(data.config.color)
}

这将把登录接口返回的数据存储到Vuex和浏览器的localstorage中。

存储Token的原因是,后续需要认证的请求,都会在请求头中携带这个Token;存储ExpireTime的原因是为了优化认证过期时的用户体验;存储用户信息的原因是为了供个人中心和系统主页使用;存储角色和权限是为了判断页面按钮渲染与否;存储用户个性化配置的原因是为了通过这些配置渲染不同的系统界面。

路由导航守卫

路由导航守卫代码位置:frontend/src/router/index.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
...
const whiteList = ['/login']

let asyncRouter

// 导航守卫,渲染动态路由
router.beforeEach((to, from, next) => {
if (whiteList.indexOf(to.path) !== -1) {
next()
}
let token = db.get('USER_TOKEN')
let user = db.get('USER')
let userRouter = get('USER_ROUTER')
if (token.length && user) {
if (!asyncRouter) {
if (!userRouter) {
request.get(`menu/${user.username}`).then((res) => {
asyncRouter = res.data
save('USER_ROUTER', asyncRouter)
go(to, next)
})
} else {
asyncRouter = userRouter
go(to, next)
}
} else {
next()
}
} else {
next('/login')
}
})

function go (to, next) {
asyncRouter = filterAsyncRouter(asyncRouter)
router.addRoutes(asyncRouter)
next({...to, replace: true})
}

...

主要逻辑分为下面几步:

  1. 判断要跳转的路由地址是否在路由白名单内,是的话放行,不是的话进行第2步;

  2. 从内存中获取token和用户信息,如果存在则进行第3步,不存在跳转到登录页;

  3. 判断动态路由信息是否存在,存在则放行,不存在则进行第4步;

  4. 判断用户路由是否已经加载,是的话将用户路由赋值给动态路由,并执行路由添加操作,然后跳转;如果用户路由不存在,则执行第5步;

  5. 根据用户名从后台获取用户路由信息,并将其保存到内存中,再执行路由添加操作,然后跳转。

权限控制

在前端页面中,我们已经实现了通过不同用户获取不同的路由,以此渲染出不同的菜单列表功能,此外页面上的操作按钮也必须进行权限控制。

正如前面所述,在登录成功后,系统会把用户的角色和权限信息存储到了内存中,所以我们可以通过这些信息结合 自定义Vue指令 的方式来实现按钮的权限控制。

目前支持的和权限相关的Vue指令有:

指令含义示例
v-hasPermission当用户拥有列出的权限的时候,渲染该元素<template v-hasPermission="'user:add','user:update'"><span>hello</span></template>
v-hasAnyPermission当用户拥有列出的任意一项权限的时候,渲染该元素<template v-hasAnyPermission="'user:add','user:update'"><span>hello</span></template>
v-hasRole当用户拥有列出的角色的时候,渲染该元素<template v-hasRole="'admin','register'"><span>hello</span></template>
v-hasAnyRole当用户拥有列出的任意一个角色的时候,渲染该元素<template v-hasAnyRole="'admin','register'"><span>hello</span></template>
hasNoPermission当用户没有列出的权限的时候,渲染该元素<template v-hasNoPermission="'user:add','register'"><span>无操作权限</span></template>

v-hasPermission="user:add"为例,详细介绍下自定义权限Vue指令的实现过程:

  1. 在src/utils/permissionDirect.js中定义如下代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
     export const hasPermission = {
    install (Vue) {
    Vue.directive('hasPermission', {
    bind (el, binding, vnode) {
    let permissions = vnode.context.$store.state.account.permissions
    let value = binding.value.split(',')
    let flag = true
    for (let v of value) {
    if (!permissions.includes(v)) {
    flag = false
    }
    }
    if (!flag) {
    if (!el.parentNode) {
    el.style.display = 'none'
    } else {
    el.parentNode.removeChild(el)
    }
    }
    }
    })
    }
    }

    上面代码中,我们从Vuex中获取了用户所拥有的权限,然后判断这些权限中是否包含user:add,如果不包含,则将对应的元素(el)移除或者隐藏。所以当用户没有user:add权限时,下面的按钮将不会被渲染在页面上:

    1
    <template v-hasPermission="'user:add'"><button>新增用户</button></template>
  2. 要让自定义Vue指令生效,还需要在src/utils/install.js中将其添加到Plugins列表。

Axios封装

项目使用Axios插件来发送HTTP请求,并对它进行了封装(frontend/src/utils/request.js),这里主要讲述下request.js中主要逻辑。

1
2
3
4
5
6
7
8
9
// 统一配置
let FEBS_REQUEST = axios.create({
baseURL: 'http://127.0.0.1:9527/',
responseType: 'json',
validateStatus (status) {
// 200 外的状态码都认定为失败
return status === 200
}
})

上面这段代码对请求进行了统一配置,baseURL定义了后端地址的基础路径,responseType定义了响应数据类型为JSON,只有状态码为200时才认定请求成功。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 拦截请求
FEBS_REQUEST.interceptors.request.use((config) => {
let expireTime = store.state.account.expireTime
let now = moment().format('YYYYMMDDHHmmss')
// 让token早10秒种过期,提升“请重新登录”弹窗体验
if (now - expireTime >= -10) {
Modal.error({
title: '登录已过期',
content: '很抱歉,登录已过期,请重新登录',
okText: '重新登录',
mask: false,
onOk: () => {
return new Promise((resolve, reject) => {
// 为什么不直接跳转到登录页呢?那是因为Vue没有提供清空路由的方法,只能通过刷新页面的方式
// 来清除之前动态添加的路由信息。
db.clear()
location.reload()
})
}
})
}
// 有 token就带上
if (store.state.account.token) {
config.headers.Authentication = store.state.account.token
}
return config
}, (error) => {
return Promise.reject(error)
})

在Axios的前置拦截中,我们首先判断了token是否已经过期,如果过期了则清空在登录时保存的数据,并且刷新页面。这时候通过路由导航守卫,页面会被重定向到登录页面,引导用户重新登录。

如果token没有过期,则在请求头上带上token信息。headers.Authentication和后端代码中的TOKEN名称相对应:

QQ截图20190409101831.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 拦截响应
FEBS_REQUEST.interceptors.response.use((config) => {
return config
}, (error) => {
if (error.response) {
let errorMessage = error.response.data === null ? '系统内部异常,请联系网站管理员' : error.response.data.message
switch (error.response.status) {
case 404:
notification.error({
message: '系统提示',
description: '很抱歉,资源未找到',
duration: 4
})
break
case 403:
case 401:
notification.warn({
message: '系统提示',
description: '很抱歉,您无法访问该资源,可能是因为没有相应权限或者登录已失效',
duration: 4
})
break
default:
notification.error({
message: '系统提示',
description: errorMessage,
duration: 4
})
break
}
}
return Promise.reject(error)
})

上面代码中我们实现了异常响应的统一处理,根据不同的状态码给予不同的提示信息。

此外,项目还对各种HTTP请求进行了封装,下面一一列举出它们的用法:

GET请求

方式一:

1
2
3
4
5
this.$get('user', {
...params
}).then((r) => {
console.log(r)
})

因为我们在前面已经定义了后端地址的基础路径,所以上面这个请求的实际地址为:http://127.0.0.1:9527/user。

在前面我们已经对异常响应进行统一处理,当然你也可以通过catch来覆盖:

1
2
3
4
5
6
7
this.$get('user', {
...params
}).then((r) => {
console.log(r)
}).catch((error) => {
alert('出错啦')
})

方式二(路径传参):

1
2
3
this.$get(`menu/${user.username}`).then((r) => {
console.log(r)
})

方式三:

1
2
3
this.$get(`user?username=${user.username}`).then((r) => {
console.log(r)
})

POST请求

1
2
3
4
5
this.$post('user', {
...params
}).then((r) => {
console.log(r)
})

PUT请求

1
2
3
4
5
this.$put('user', {
...params
}).then((r) => {
console.log(r)
})

DELETE请求

1
2
3
this.$delete('user/${user.userId}').then((r) => {
console.log(r)
})

下载文件

1
this.$download('file', { ...params }, 'xxx.xx')

xxx.xx为下载的文件名,比如下载xlsx文件的话为 Excel文件.xlsx,后端接口返回数据流即可。

上传文件

1
2
3
4
let formData = new FormData($("#form")[0]);
this.$upload('upload', formData).then((r) => {
console.log(r)
})

后端以MultipartFile接收文件即可。

路径配置

在build/webpack.base.conf.js中,我们定义了一些路径变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
module.exports = {
...
resolve: {
...
alias: {
'vue$': 'vue/dist/vue.esm.js',
'@': resolve('src'),
'~': resolve('src/components'),
'utils': resolve('src/utils')
}
},
...
}

比如~代表src/components路径,在项目中,如果要引入src/components/test.vue只需要这样做即可:

1
import ~/test.vue

当然,你也可以使用相对路径和绝对路径,而不使用这些变量。

第三方组件介绍

Ant Design Vue已经提供了非常丰富的组件,除此之外,项目里使用的图表插件为:apexcharts.js,其官方文档提供了很多示例,这里就不赘述了。

开发建议

相信正在阅读文档的你十有八九是一名后端开发者,可能对前端特别是对Vue不太熟悉,这里给出几点改造frontend的建议:

  1. 要有一定的ES6语法基础,可以参考 ES6学习笔记 ,如果要系统学习ES6,推荐阮一峰的: ECMAScript 6 入门

  2. Vue的官方文档还是挺详细的,建议仔细阅读,我在学习Vue的时候也做了一些笔记,可以参考:https://mrbird.cc/Vue-Learn-Note.html。Vue的学习路线:Vue基础语法 -> Vuex -> Vue Router。

  3. 前端组件用的是Ant Design Vue,所以在使用它提供的组件的时候,多阅读它的使用文档。

开发示例

新建一个页面

在frontend工程的src/views下新建一个test目录,在该目录下新建一个test.vue文件: QQ截图20190409133947.png

内容如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<template>
<div class="test">{{hello}}</div>
</template>
<script>
export default {
name: 'test',
data () {
return {
hello: 'hello world'
}
}
}
</script>
<style lang="less" scoped>
.test {
color: #42b984;
font-size: 1.1rem;
font-weight: 600;
}
</style>

启动项目,使用管理员账号登录,然后在菜单管理中新建一个菜单:

QQ截图20190409134916.png

  1. 菜单URL:这里填写/test的话,在访问这个页面的时候,浏览器地址栏为http://localhost:8081/#/test,只要确保这个值不重复即可;

  2. 组件地址:对应要渲染的Vue组件地址,在路由导航守卫中有如下一段代码:

    QQ截图20190409135425.png

    所以这里填test/test,对应@/views/test/test.vue组件。

  3. 相关权限:访问这个页面需要test:view权限。

点击确定后,这个菜单就被建好了:

QQ截图20190409135740.png

接着修改管理员角色,将刚刚新建的菜单授权给管理员:

QQ截图20190409141220.png

点击确定修改后,重新登录系统:

QQ截图20190409141410.png

如何新建一个多级菜单

在新增多级菜单前,先了解下系统中的一个约定:在一个多级菜单中,顶级菜单对应的组件为PageView,末级菜单对应的组件为需要渲染的页面组件,剩下的(非顶级,非末级的中间菜单对应的组件为EmptyPageView)

我们来建一个四级菜单,首先新增一个顶级菜单:

QQ截图20190409142251.png

因为是顶级菜单,所以对应组件填PageView。

接着新增第二级菜单:

QQ截图20190409142642.png

因为它数据中间菜单(非顶级非末级)所以对应组件填EmptyPageView,上级菜单勾选刚刚新建的一级菜单。

继续新增三级菜单:

QQ截图20190409143223.png

因为它也是一个中间菜单,所以对应组件填EmptyPageView,上级菜单勾选刚刚新建的二级菜单。

最后将我们前面建好的测试页面作为末级,点击测试页面后面的小齿轮按钮,进行修改:

QQ截图20190409143905.png

点击确定后一个四级菜单就建好了:

QQ截图20190409143943.png

最后一步授权!修改管理员角色:

QQ截图20190409144206.png

修改后,重新登录系统:

QQ截图20190409144908.png

因为我没有设置排序,所以默认排在最前面了。

如何隐藏路由

有的时候,一些路由并不需要渲染成菜单,比如个人中心这类页面。而这些页面一般都是所有用户共有的,所以在后台系统里构建路由的时候写死即可。要隐藏路由只需要将路由meta的isShow属性设置为false即可:

参考后台代码cc.mrbird.febs.common.utils.TreeUtil:

QQ截图20190409150427.png

如何分配权限

权限是和角色绑定的,所以要分配权限实际就是对角色的增删改。假如现在要配置一个角色 ——— 系统监控管理员,负责系统监控模块的查看:

QQ截图20190409152413.png

然后新增一个用户 ——— yuuki,角色为系统监控管理员:

QQ截图20190409152550.png

新建好后,使用yuuki的账号登录:

QQ截图20190409153002.png

可以看到,yuuki只有系统监控模块的权限。

前端如何添加依赖

https://www.npmjs.com/搜索需要安装的依赖,比如jQuery:

QQ截图20190409153227.png

比如我需要安装jQuery 3.3.1版本,只需要在终端输入如下命令即可:

QQ截图20190409153710.png

安装好后,在package.json的依赖列表里会多出一个jquery 3.3.1:

QQ截图20190409153800.png

如何处理排序

对于前端来说,需要上送两个参数:

  1. sortField:需要排序的字段;

  2. sortOrder:排序规则,ascend或者descend。

对于后端来说,排序主要分为四种情况:

  1. 结果需要分页的,并且是通过xml定义的SQL查询出来的结果:
1
SortUtil.handlePageSort(request, page, "userId", FebsConstant.ORDER_ASC, false);

userIdFebsConstant.ORDER_ASC指定了默认的排序规则,即默认按照userId字段升序排序。最后一个参数表示是否需要开启驼峰转下划线,这种情况下不需要,false即可。

具体可以参考cc.mrbird.febs.system.service.impl.UserServiceImpl#findUserDetail。

如果不需要指定默认排序规则,使用handlePageSort的重载方法即可:

1
SortUtil.handlePageSort(request, page, false);
  1. 结果需要分页的,并且是通过Mybatis Plus插件查询出来的结果:
1
SortUtil.handlePageSort(request, page, true);

这种情况下,最后一个参数值必须为true。

具体可以参考cc.mrbird.febs.system.service.impl.DictServiceImpl#findDicts。

1和2主要区别就是是否需要开启驼峰转下划线。

  1. 结果不需要分页,并且是通过xml定义的SQL查询出来的结果:
1
SortUtil.handleWrapperSort(request, queryWrapper, "orderNum", FebsConstant.ORDER_ASC, false);

具体可以参考:cc.mrbird.febs.system.service.impl.DeptServiceImpl#findDepts

  1. 结果不需要分页,并且是通过Mybatis Plus插件查询出来的结果:
1
SortUtil.handleWrapperSort(request, queryWrapper, "orderNum", FebsConstant.ORDER_ASC, true);

总之,对于处理排序的方法,第一个参数一定是cc.mrbird.febs.common.domain.QueryRequest。第二个参数如果需要分页,则传递com.baomidou.mybatisplus.extension.plugins.pagination.Page,不需要分页则传递com.baomidou.mybatisplus.core.conditions.query.QueryWrapper。最后一个参数如果查询结果是Mybatis Plus查询出来的结果,则需设置为true,否则为false。

后端接口测试

由于后端接口为RESTful接口,所以不能使用浏览器来测试,可以使用PostMan或者Chrome插件RestLet来测试后端接口。文档以PostMan为例。

因为后台接口大部分都需要用户认证后才能访问,所以在测试之前需要通过登录接口获取一个可用的token。

QQ截图20190409161742.png

成功获取到了token。

测试获取mrbird的前端路由信息:

QQ截图20190409161947.png

在Headers设置一个键值对,key为Authentication,value为刚刚获取到的token,发送请求便可以获取到mrbird的路由信息。其他接口测试以此类推。

如果token填错或者不填:

QQ截图20190409162214.png

后端将返回401。

常见问题

导入项目编译出错,代码是否不全?

编辑器安装lombok插件即可。

导入SQL为何出错?

MySQL数据库请使用5.7.x版本,不同版本SQL语法有差异。如果你SQL技术过硬可以通过错误信息去修改出错的SQL,更推荐的做法是安装推荐版本的MySQL数据库。

ip2region是啥玩意,打开怎么乱码?

通过ip获取地址的开源软件数据库文件,不要直接打开。ip2region地址:https://github.com/lionsoul2014/ip2region

项目缺陷

  1. 前端页面不支持移动端(不能自适应);

  2. 前端打包后vendor.js较大,通过nginx压缩后在591kb左右,在我的渣渣服务器(1核1G1M)下,访问时间大约为7 - 8秒左右:

QQ截图20190409162930.png

如果你的服务器带宽够大,或者是部署在公司局域网内的话,这个问题可以忽略。如果要在根源上解决这个问题个人觉得可以从这几个地方入手:

由于我才疏学浅,前端技能薄弱,所以没能够很好地解决这个问题。欢迎来自五湖四海的能人志士pull request来改善这个问题,感激不尽。

QQ图片20190409164403.jpg


TOP