SpringBoot整合mybatis、shiro、redis实现基于数据库的细粒度动态权限管理系统实例(转)

1.前言

本文主要介绍使用SpringBoot与shiro实现基于数据库的细粒度动态权限管理系统实例。
使用技术:SpringBoot、mybatis、shiro、thymeleaf、pagehelper、Mapper插件、druid、dataTables、ztree、jQuery
开发工具:intellij idea
数据库:mysql、redis
基本上是基于使用SpringSecurity的demo上修改而成,地址 http://blog.csdn.net/poorcoder_/article/details/70231779

2.表结构

还是是用标准的5张表来展现权限。如下图:image
分别为用户表,角色表,资源表,用户角色表,角色资源表。在这个demo中使用了mybatis-generator自动生成代码。运行mybatis-generator:generate -e 根据数据库中的表,生成 相应的model,mapper单表的增删改查。不过如果是导入本项目的就别运行这个命令了。新增表的话,也要修改mybatis-generator-config.xml中的tableName,指定表名再运行。

3.maven配置

  1 <?xml version="1.0" encoding="UTF-8"?>
  2 
  3 <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  4 
  5          xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  6 
  7     <modelVersion>4.0.0</modelVersion>
  8 
  9 
 10 
 11     <groupId>com.study</groupId>
 12 
 13     <artifactId>springboot-shiro</artifactId>
 14 
 15     <version>0.0.1-SNAPSHOT</version>
 16 
 17     <packaging>jar</packaging>
 18 
 19 
 20 
 21     <name>springboot-shiro</name>
 22 
 23     <description>Demo project for Spring Boot</description>
 24 
 25 
 26 
 27     <parent>
 28         <groupId>org.springframework.boot</groupId>
 29 
 30         <artifactId>spring-boot-starter-parent</artifactId>
 31 
 32         <version>1.5.2.RELEASE</version>
 33 
 34         <relativePath/> <!-- lookup parent from repository -->
 35 
 36     </parent>
 37 
 38 
 39 
 40     <properties>
 41         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
 42 
 43         <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
 44 
 45         <java.version>1.8</java.version>
 46 
 47     </properties>
 48 
 49 
 50 
 51     <dependencies>
 52 
 53         <dependency>
 54 
 55             <groupId>org.springframework.boot</groupId>
 56 
 57             <artifactId>spring-boot-starter</artifactId>
 58 
 59         </dependency>
 60 
 61 
 62 
 63         <dependency>
 64 
 65             <groupId>org.springframework.boot</groupId>
 66 
 67             <artifactId>spring-boot-starter-test</artifactId>
 68 
 69             <scope>test</scope>
 70 
 71         </dependency>
 72 
 73         <dependency>
 74 
 75             <groupId>org.springframework.boot</groupId>
 76 
 77             <artifactId>spring-boot-starter-web</artifactId>
 78 
 79         </dependency>
 80 
 81         <dependency>
 82 
 83             <groupId>org.springframework.boot</groupId>
 84 
 85             <artifactId>spring-boot-starter-thymeleaf</artifactId>
 86 
 87         </dependency>
 88 
 89         <dependency>
 90 
 91             <groupId>com.github.pagehelper</groupId>
 92 
 93             <artifactId>pagehelper-spring-boot-starter</artifactId>
 94 
 95             <version>1.1.0</version>
 96         </dependency>
 97 
 98         <dependency>
 99 
100             <groupId>tk.mybatis</groupId>
101 
102             <artifactId>mapper-spring-boot-starter</artifactId>
103 
104             <version>1.1.1</version>
105 
106         </dependency>
107 
108         <dependency>
109 
110             <groupId>org.apache.shiro</groupId>
111 
112             <artifactId>shiro-spring</artifactId>
113 
114             <version>1.3.2</version>
115 
116         </dependency>
117 
118         <dependency>
119 
120             <groupId>com.alibaba</groupId>
121 
122             <artifactId>druid</artifactId>
123 
124             <version>1.0.29</version>
125 
126         </dependency>
127 
128         <dependency>
129 
130             <groupId>mysql</groupId>
131 
132             <artifactId>mysql-connector-java</artifactId>
133 
134         </dependency>
135 
136         <dependency>
137 
138             <groupId>net.sourceforge.nekohtml</groupId>
139 
140             <artifactId>nekohtml</artifactId>
141 
142             <version>1.9.22</version>
143 
144         </dependency>
145 
146         <dependency>
147 
148             <groupId>com.github.theborakompanioni</groupId>
149 
150             <artifactId>thymeleaf-extras-shiro</artifactId>
151 
152             <version>1.2.1</version>
153 
154         </dependency>
155 
156         <dependency>
157 
158             <groupId>org.crazycake</groupId>
159 
160             <artifactId>shiro-redis</artifactId>
161 
162             <version>2.4.2.1-RELEASE</version>
163 
164         </dependency>
165 
166     </dependencies>
167 
168 
169     <build>
170 
171         <plugins>
172 
173             <plugin>
174 
175                 <groupId>org.springframework.boot</groupId>
176 
177                 <artifactId>spring-boot-maven-plugin</artifactId>
178 
179             </plugin>
180 
181             <plugin>
182 
183                 <groupId>org.mybatis.generator</groupId>
184 
185                 <artifactId>mybatis-generator-maven-plugin</artifactId>
186 
187                 <version>1.3.5</version>
188 
189                 <configuration>
190 
191                     <configurationFile>${basedir}/src/main/resources/generator/generatorConfig.xml</configurationFile>
192 
193                     <overwrite>true</overwrite>
194 
195                     <verbose>true</verbose>
196 
197                 </configuration>
198 
199                 <dependencies>
200 
201                     <dependency>
202 
203                         <groupId>mysql</groupId>
204 
205                         <artifactId>mysql-connector-java</artifactId>
206 
207                         <version>${mysql.version}</version>
208 
209                     </dependency>
210 
211                     <dependency>
212 
213                         <groupId>tk.mybatis</groupId>
214 
215                         <artifactId>mapper</artifactId>
216 
217                         <version>3.4.0</version>
218 
219                     </dependency>
220 
221                 </dependencies>
222 
223             </plugin>
224 
225         </plugins>
226 
227     </build>
228 
229 
230 
231 
232 
233 </project>

4.配置Druid

 1 package com.study.config;
 2 
 3 
 4 
 5 import com.alibaba.druid.support.http.StatViewServlet;
 6 
 7 import com.alibaba.druid.support.http.WebStatFilter;
 8 
 9 import org.springframework.boot.web.servlet.FilterRegistrationBean;
10 
11 import org.springframework.boot.web.servlet.ServletRegistrationBean;
12 
13 import org.springframework.context.annotation.Bean;
14 
15 import org.springframework.context.annotation.Configuration;
16 
17 
18 
19 
20 /**
21 
22  * Created by yangqj on 2017/4/19.
23 
24  */
25 
26 @Configuration
27 
28 public class DruidConfig {
29 
30 
31     @Bean
32 
33     public ServletRegistrationBean druidServlet() {
34 
35 
36 
37         ServletRegistrationBean servletRegistrationBean = new ServletRegistrationBean(new StatViewServlet(), "/druid/*");
38 
39         //登录查看信息的账号密码.
40 
41 
42 
43         servletRegistrationBean.addInitParameter("loginUsername","admin");
44 
45 
46         servletRegistrationBean.addInitParameter("loginPassword","123456");
47 
48         return servletRegistrationBean;
49 
50     }
51 
52 
53     @Bean
54 
55     public FilterRegistrationBean filterRegistrationBean() {
56 
57         FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean();
58 
59         filterRegistrationBean.setFilter(new WebStatFilter());
60 
61         filterRegistrationBean.addUrlPatterns("/*");
62 
63         filterRegistrationBean.addInitParameter("exclusions", "*.js,*.gif,*.jpg,*.png,*.css,*.ico,/druid/*");
64 
65         return filterRegistrationBean;
66 
67     }
68 
69 }

在application.properties中加入:

 1 # 数据源基础配置
 2 
 3 spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
 4 
 5 spring.datasource.driver-class-name=com.mysql.jdbc.Driver
 6 
 7 spring.datasource.url=jdbc:mysql://localhost:3306/shiro
 8 
 9 spring.datasource.username=root
10 
11 spring.datasource.password=root
12 
13 # 连接池配置
14 
15 # 初始化大小,最小,最大
16 
17 spring.datasource.initialSize=1
18 
19 spring.datasource.minIdle=1
20 
21 spring.datasource.maxActive=20
配置好后,运行项目访问http://localhost:8080/druid/ 输入配置的账号密码admin,123456进入



5.配置mybatis

使用springboot 整合mybatis非常方便,只需在application.properties

 1 mybatis.type-aliases-package=com.study.model
 2 
 3 mybatis.mapper-locations=classpath:mapper/*.xml
 4 
 5 mapper.mappers=com.study.util.MyMapper
 6 
 7 mapper.not-empty=false
 8 
 9 mapper.identity=MYSQL
10 
11 pagehelper.helperDialect=mysql
12 
13 pagehelper.reasonable=true
14 
15 pagehelper.supportMethodsArguments=true
16 
17 
18 pagehelper.params=count=countSql

将相应的路径改成项目包所在的路径即可。配置文件中可以看出来还加入了pagehelper 和Mapper插件。如果不需要,把上面配置文件中的 pagehelper删除。

MyMapper:
 1 package com.study.util;
 2 
 3 
 4 
 5 /**
 6 
 7  * Created by yangqj on 2017/4/20.
 8 
 9  */
10 
11 import tk.mybatis.mapper.common.Mapper;
12 
13 import tk.mybatis.mapper.common.MySqlMapper;
14 
15 public interface MyMapper<T> extends Mapper<T>, MySqlMapper<T> {
16 
17 }
对于Springboot整合mybatis可以参考https://github.com/abel533/MyBatis-Spring-Boot

6.thymeleaf配置

thymeleaf是springboot官方推荐的,所以来试一下。 
首先加入配置:


 1 #spring.thymeleaf.prefix=classpath:/templates/
 2 
 3 #spring.thymeleaf.suffix=.html
 4 
 5 #spring.thymeleaf.mode=HTML5
 6 
 7 #spring.thymeleaf.encoding=UTF-8
 8 
 9 # ;charset=<encoding> is added
10 
11 #spring.thymeleaf.content-type=text/html
12 
13 # set to false for hot refresh
14 
15 spring.thymeleaf.cache=false
16 
17 spring.thymeleaf.mode=LEGACYHTML5
可以看到其实上面都是注释了的,因为springboot会根据约定俗成的方式帮我们配置好。所以上面注释部分是springboot自动配置的,如果需要自定义配置,只需要修改上注释部分即可。 
后两行没有注释的部分,spring.thymeleaf.cache=false表示关闭缓存,这样修改文件后不需要重新启动,缓存默认是开启的,所以指定为false。但是在intellij idea中还需要按Ctrl + Shift + F9.
对于spring.thymeleaf.mode=LEGACYHTML5。thymeleaf对html中的语法要求非常严格,像我从网上找的模板,使用thymeleaf后报一堆的语法错误,后来没办法,使用弱语法校验,所以加入配置spring.thymeleaf.mode=LEGACYHTML5。加入这个配置后还需要在maven中加入
1 <dependency>
2 
3     <groupId>net.sourceforge.nekohtml</groupId>
4 
5     <artifactId>nekohtml</artifactId>
6 
7     <version>1.9.22</version>
8 
9 </dependency>
否则会报错的。 
在前端页面的头部加入一下配置后,就可以使用thymeleaf了


1 <link rel="stylesheet" th:href="@{/css/bootstrap.min.css}" />



不过这个项目因为使用了datatables都是使用jquery 的ajax来访问数据与处理数据,所以用到的thymeleaf语法非常少,基本上可以参考的就是js即css的导入和类似于jsp的include功能的部分页面引入。 
对于静态文件的引入:


1 <link rel="stylesheet" th:href="@{/css/bootstrap.min.css}" />

而文件在项目中的位置是static-css-bootstrap.min.css。为什么这样可以访问到该文件,也是因为springboot对于静态文件会自动查找/static public、/resources、/META-INF/resources下的文件。所以不需要加static.

页面引入:
局部页面如下:

1 <div  th:fragment="top">
2     ...
3 </div>
主体页面映入方式:


1 <div th:include="common/top :: top"></div>
inclide=”文件路径::局部代码片段名称”

7.shiro配置

配置文件ShiroConfig
  1 package com.study.config;
  2 
  3 
  4 import at.pollux.thymeleaf.shiro.dialect.ShiroDialect;
  5 
  6 import com.github.pagehelper.util.StringUtil;
  7 
  8 import com.study.model.Resources;
  9 
 10 import com.study.service.ResourcesService;
 11 
 12 import com.study.shiro.MyShiroRealm;
 13 
 14 import org.apache.shiro.authc.credential.HashedCredentialsMatcher;
 15 
 16 import org.apache.shiro.mgt.SecurityManager;
 17 
 18 import org.apache.shiro.spring.LifecycleBeanPostProcessor;
 19 
 20 import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
 21 
 22 import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
 23 
 24 import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
 25 
 26 import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
 27 
 28 import org.crazycake.shiro.RedisCacheManager;
 29 
 30 import org.crazycake.shiro.RedisManager;
 31 
 32 import org.crazycake.shiro.RedisSessionDAO;
 33 
 34 import org.springframework.beans.factory.annotation.Autowired;
 35 
 36 import org.springframework.beans.factory.annotation.Value;
 37 
 38 import org.springframework.context.annotation.Bean;
 39 
 40 import org.springframework.context.annotation.Configuration;
 41 
 42 
 43 
 44 import java.util.LinkedHashMap;
 45 
 46 import java.util.List;
 47 
 48 import java.util.Map;
 49 
 50 
 51 
 52 /**
 53 
 54  * Created by yangqj on 2017/4/23.
 55 
 56  */
 57 
 58 @Configuration
 59 
 60 public class ShiroConfig {
 61 
 62     @Autowired(required = false)
 63 
 64     private ResourcesService resourcesService;
 65 
 66 
 67 
 68     @Value("${spring.redis.host}")
 69 
 70     private String host;
 71 
 72 
 73 
 74     @Value("${spring.redis.port}")
 75 
 76     private int port;
 77 
 78 
 79 
 80     @Value("${spring.redis.timeout}")
 81 
 82     private int timeout;
 83 
 84 
 85 
 86     @Bean
 87 
 88     public static LifecycleBeanPostProcessor getLifecycleBeanPostProcessor() {
 89 
 90         return new LifecycleBeanPostProcessor();
 91 
 92     }
 93 
 94 
 95     /**
 96 
 97      * ShiroDialect,为了在thymeleaf里使用shiro的标签的bean
 98 
 99      * @return
100 
101      */
102 
103     @Bean
104 
105     public ShiroDialect shiroDialect() {
106 
107         return new ShiroDialect();
108 
109     }
110 
111     /**
112 
113      * ShiroFilterFactoryBean 处理拦截资源文件问题。
114 
115      * 注意:单独一个ShiroFilterFactoryBean配置是或报错的,因为在
116 
117      * 初始化ShiroFilterFactoryBean的时候需要注入:SecurityManager
118 
119      *
120 
121      Filter Chain定义说明
122 
123      1、一个URL可以配置多个Filter,使用逗号分隔
124 
125      2、当设置多个过滤器时,全部验证通过,才视为通过
126 
127      3、部分过滤器可指定参数,如perms,roles
128 
129      *
130 
131      */
132 
133     @Bean
134 
135     public ShiroFilterFactoryBean shirFilter(SecurityManager securityManager){
136 
137         System.out.println("ShiroConfiguration.shirFilter()");
138 
139         ShiroFilterFactoryBean shiroFilterFactoryBean  = new ShiroFilterFactoryBean();
140 
141 
142 
143         // 必须设置 SecurityManager
144 
145         shiroFilterFactoryBean.setSecurityManager(securityManager);
146 
147         // 如果不设置默认会自动寻找Web工程根目录下的"/login.jsp"页面
148 
149         shiroFilterFactoryBean.setLoginUrl("/login");
150 
151         // 登录成功后要跳转的链接
152 
153         shiroFilterFactoryBean.setSuccessUrl("/usersPage");
154 
155         //未授权界面;
156 
157         shiroFilterFactoryBean.setUnauthorizedUrl("/403");
158 
159         //拦截器.
160 
161         Map<String,String> filterChainDefinitionMap = new LinkedHashMap<String,String>();
162 
163 
164 
165         //配置退出 过滤器,其中的具体的退出代码Shiro已经替我们实现了
166 
167         filterChainDefinitionMap.put("/logout", "logout");
168 
169         filterChainDefinitionMap.put("/css/**","anon");
170 
171         filterChainDefinitionMap.put("/js/**","anon");
172 
173         filterChainDefinitionMap.put("/img/**","anon");
174 
175         filterChainDefinitionMap.put("/font-awesome/**","anon");
176 
177         //<!-- 过滤链定义,从上向下顺序执行,一般将 /**放在最为下边 -->:这是一个坑呢,一不小心代码就不好使了;
178 
179         //<!-- authc:所有url都必须认证通过才可以访问; anon:所有url都都可以匿名访问-->
180 
181         //自定义加载权限资源关系
182 
183         List<Resources> resourcesList = resourcesService.queryAll();
184 
185          for(Resources resources:resourcesList){
186 
187 
188 
189             if (StringUtil.isNotEmpty(resources.getResurl())) {
190 
191                 String permission = "perms[" + resources.getResurl()+ "]";
192 
193                 filterChainDefinitionMap.put(resources.getResurl(),permission);
194 
195             }
196 
197         }
198 
199         filterChainDefinitionMap.put("/**", "authc");
200 
201 
202 
203 
204         shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
205 
206         return shiroFilterFactoryBean;
207 
208     }
209 
210 
211 
212     @Bean
213 
214     public SecurityManager securityManager(){
215 
216         DefaultWebSecurityManager securityManager =  new DefaultWebSecurityManager();
217 
218         //设置realm.
219 
220         securityManager.setRealm(myShiroRealm());
221 
222         // 自定义缓存实现 使用redis
223 
224         //securityManager.setCacheManager(cacheManager());
225 
226         // 自定义session管理 使用redis
227 
228         securityManager.setSessionManager(sessionManager());
229 
230         return securityManager;
231 
232     }
233 
234 
235 
236     @Bean
237 
238     public MyShiroRealm myShiroRealm(){
239 
240         MyShiroRealm myShiroRealm = new MyShiroRealm();
241 
242         myShiroRealm.setCredentialsMatcher(hashedCredentialsMatcher());
243 
244         return myShiroRealm;
245 
246     }
247 
248 
249 
250     /**
251 
252      * 凭证匹配器
253      * (由于我们的密码校验交给Shiro的SimpleAuthenticationInfo进行处理了
254 
255      *  所以我们需要修改下doGetAuthenticationInfo中的代码;
256 
257      * )
258 
259      * @return
260 
261      */
262 
263     @Bean
264 
265     public HashedCredentialsMatcher hashedCredentialsMatcher(){
266 
267         HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();
268 
269 
270 
271         hashedCredentialsMatcher.setHashAlgorithmName("md5");//散列算法:这里使用MD5算法;
272 
273         hashedCredentialsMatcher.setHashIterations(2);//散列的次数,比如散列两次,相当于 md5(md5(""));
274 
275 
276 
277         return hashedCredentialsMatcher;
278 
279     }
280 
281 
282 
283     /**
284 
285      *  开启shiro aop注解支持.
286 
287      *  使用代理方式;所以需要开启代码支持;
288 
289      * @param securityManager
290      * @return
291 
292      */
293 
294     @Bean
295 
296     public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager){
297 
298         AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
299 
300         authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
301 
302         return authorizationAttributeSourceAdvisor;
303 
304     }
305 
306 
307     /**
308 
309      * 配置shiro redisManager
310 
311      * 使用的是shiro-redis开源插件
312 
313      * @return
314 
315      */
316 
317     public RedisManager redisManager() {
318 
319         RedisManager redisManager = new RedisManager();
320 
321         redisManager.setHost(host);
322 
323         redisManager.setPort(port);
324 
325         redisManager.setExpire(1800);// 配置缓存过期时间
326 
327         redisManager.setTimeout(timeout);
328 
329         // redisManager.setPassword(password);
330 
331         return redisManager;
332 
333     }
334 
335 
336 
337     /**
338 
339      * cacheManager 缓存 redis实现
340 
341      * 使用的是shiro-redis开源插件
342 
343      * @return
344 
345      */
346 
347     public RedisCacheManager cacheManager() {
348 
349         RedisCacheManager redisCacheManager = new RedisCacheManager();
350 
351         redisCacheManager.setRedisManager(redisManager());
352 
353         return redisCacheManager;
354 
355     }
356 
357 
358 
359     /**
360 
361      * RedisSessionDAO shiro sessionDao层的实现 通过redis
362 
363      * 使用的是shiro-redis开源插件
364 
365      */
366 
367     @Bean
368 
369     public RedisSessionDAO redisSessionDAO() {
370 
371         RedisSessionDAO redisSessionDAO = new RedisSessionDAO();
372 
373         redisSessionDAO.setRedisManager(redisManager());
374 
375         return redisSessionDAO;
376 
377     }
378 
379 
380     /**
381 
382      * shiro session的管理
383 
384      */
385 
386     @Bean
387 
388     public DefaultWebSessionManager sessionManager() {
389 
390         DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
391 
392         sessionManager.setSessionDAO(redisSessionDAO());
393 
394         return sessionManager;
395 
396     }
397 
398 
399 }
配置自定义Realm
  1 package com.study.shiro;
  2 
  3 
  4 import com.study.model.Resources;
  5 
  6 import com.study.model.User;
  7 
  8 import com.study.service.ResourcesService;
  9 
 10 import com.study.service.UserService;
 11 
 12 import org.apache.shiro.SecurityUtils;
 13 
 14 import org.apache.shiro.authc.*;
 15 
 16 import org.apache.shiro.authz.AuthorizationInfo;
 17 
 18 import org.apache.shiro.authz.SimpleAuthorizationInfo;
 19 
 20 import org.apache.shiro.realm.AuthorizingRealm;
 21 
 22 import org.apache.shiro.session.Session;
 23 
 24 import org.apache.shiro.subject.PrincipalCollection;
 25 
 26 import org.apache.shiro.util.ByteSource;
 27 
 28 
 29 
 30 import javax.annotation.Resource;
 31 
 32 import java.util.HashMap;
 33 
 34 import java.util.List;
 35 
 36 import java.util.Map;
 37 
 38 
 39 /**
 40 
 41  * Created by yangqj on 2017/4/21.
 42 
 43  */
 44 
 45 public class MyShiroRealm extends AuthorizingRealm {
 46 
 47 
 48 
 49     @Resource
 50 
 51     private UserService userService;
 52 
 53 
 54 
 55     @Resource
 56 
 57     private ResourcesService resourcesService;
 58 
 59 
 60     //授权
 61 
 62     @Override
 63 
 64     protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
 65 
 66         User user= (User) SecurityUtils.getSubject().getPrincipal();//User{id=1, username='admin', password='3ef7164d1f6167cb9f2658c07d3c2f0a', enable=1}
 67 
 68         Map<String,Object> map = new HashMap<String,Object>();
 69 
 70         map.put("userid",user.getId());
 71 
 72         List<Resources> resourcesList = resourcesService.loadUserResources(map);
 73 
 74         // 权限信息对象info,用来存放查出的用户的所有的角色(role)及权限(permission)
 75 
 76         SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
 77 
 78         for(Resources resources: resourcesList){
 79 
 80             info.addStringPermission(resources.getResurl());
 81 
 82         }
 83 
 84         return info;
 85 
 86     }
 87 
 88 
 89     //认证
 90 
 91     @Override
 92 
 93     protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
 94 
 95         //获取用户的输入的账号.
 96 
 97         String username = (String)token.getPrincipal();
 98 
 99         User user = userService.selectByUsername(username);
100 
101         if(user==null) throw new UnknownAccountException();
102 
103         if (0==user.getEnable()) {
104 
105             throw new LockedAccountException(); // 帐号锁定
106 
107         }
108 
109         SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(
110 
111                 user, //用户
112 
113                 user.getPassword(), //密码
114 
115                 ByteSource.Util.bytes(username),
116 
117                 getName()  //realm name
118 
119         );
120 
121         // 当验证都通过后,把用户信息放在session里
122 
123         Session session = SecurityUtils.getSubject().getSession();
124 
125         session.setAttribute("userSession", user);
126 
127         session.setAttribute("userSessionId", user.getId());
128 
129         return authenticationInfo;
130 
131     }
132 
133 
134 
135 
136 }
认证:

shiro的主要模块分别就是授权和认证和会话管理。
我们先讲认证。认证就是验证用户。比如用户登录的时候验证账号密码是否正确。
我们可以把对登录的验证交给shiro。我们执行要查询相应的用户信息,并传给shiro。如下代码则为用户登录:

 1 @RequestMapping(value="/login",method=RequestMethod.POST)
 2 
 3     public String login(HttpServletRequest request, User user, Model model){
 4 
 5         if (StringUtils.isEmpty(user.getUsername()) || StringUtils.isEmpty(user.getPassword())) {
 6 
 7             request.setAttribute("msg", "用户名或密码不能为空!");
 8 
 9             return "login";
10 
11         }
12 
13         Subject subject = SecurityUtils.getSubject();
14 
15         UsernamePasswordToken token=new UsernamePasswordToken(user.getUsername(),user.getPassword());
16 
17         try {
18 
19             subject.login(token);
20 
21             return "redirect:usersPage";
22 
23         }catch (LockedAccountException lae) {
24 
25             token.clear();
26 
27             request.setAttribute("msg", "用户已经被锁定不能登录,请与管理员联系!");
28 
29             return "login";
30 
31         } catch (AuthenticationException e) {
32 
33             token.clear();
34 
35             request.setAttribute("msg", "用户或密码不正确!");
36 
37             return "login";
38 
39         }
可见用户登陆的代码主要就是  subject.login(token);调用后就会进去我们自定义的realm中的doGetAuthenticationInfo()方法。
 1 //认证
 2 
 3     @Override
 4 
 5     protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
 6 
 7         //获取用户的输入的账号.
 8 
 9         String username = (String)token.getPrincipal();
10 
11         User user = userService.selectByUsername(username);
12 
13         if(user==null) throw new UnknownAccountException();
14 
15         if (0==user.getEnable()) {
16 
17             throw new LockedAccountException(); // 帐号锁定
18 
19         }
20 
21         SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(
22 
23                 user, //用户
24 
25                 user.getPassword(), //密码
26 
27                 ByteSource.Util.bytes(username),
28 
29                 getName()  //realm name
30 
31         );
32 
33         // 当验证都通过后,把用户信息放在session里
34 
35         Session session = SecurityUtils.getSubject().getSession();
36 
37         session.setAttribute("userSession", user);
38 
39         session.setAttribute("userSessionId", user.getId());
40         return authenticationInfo;
41 
42     }
而我们在ShiroConfig中配置了凭证匹配器:

 1 @Bean
 2 
 3     public MyShiroRealm myShiroRealm(){
 4 
 5         MyShiroRealm myShiroRealm = new MyShiroRealm();
 6 
 7         myShiroRealm.setCredentialsMatcher(hashedCredentialsMatcher());
 8 
 9         return myShiroRealm;
10 
11     }
12 
13 
14 
15  @Bean
16     public HashedCredentialsMatcher hashedCredentialsMatcher(){
17 
18         HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();
19 
20 
21 
22         hashedCredentialsMatcher.setHashAlgorithmName("md5");//散列算法:这里使用MD5算法;
23 
24         hashedCredentialsMatcher.setHashIterations(2);//散列的次数,比如散列两次,相当于 md5(md5(""));
25 
26 
27         return hashedCredentialsMatcher;
28 
29     }
所以在认证时的密码是加过密的,使用md5散发将密码与盐值组合加密两次。则我们在增加用户的时候,对用户的密码则要进过相同规则的加密才行。 
添加用户代码如下:
 1 @RequestMapping(value = "/add")
 2 
 3     public String add(User user) {
 4 
 5         User u = userService.selectByUsername(user.getUsername());
 6 
 7         if(u != null)
 8 
 9             return "error";
10 
11         try {
12 
13             user.setEnable(1);
14 
15             PasswordHelper passwordHelper = new PasswordHelper();
16 
17             passwordHelper.encryptPassword(user);
18 
19             userService.save(user);
20 
21             return "success";
22 
23         } catch (Exception e) {
24 
25             e.printStackTrace();
26 
27             return "fail";
28 
29         }
30 
31     }
PasswordHelper:

 1 package com.study.util;
 2 
 3 
 4 
 5 import com.study.model.User;
 6 
 7 import org.apache.shiro.crypto.RandomNumberGenerator;
 8 
 9 import org.apache.shiro.crypto.SecureRandomNumberGenerator;
10 
11 import org.apache.shiro.crypto.hash.SimpleHash;
12 import org.apache.shiro.util.ByteSource;
13 
14 
15 public class PasswordHelper {
16 
17     //private RandomNumberGenerator randomNumberGenerator = new SecureRandomNumberGenerator();
18 
19     private String algorithmName = "md5";
20 
21     private int hashIterations = 2;
22 
23 
24 
25     public void encryptPassword(User user) {
26 
27         //String salt=randomNumberGenerator.nextBytes().toHex();
28 
29         String newPassword = new SimpleHash(algorithmName, user.getPassword(),  ByteSource.Util.bytes(user.getUsername()), hashIterations).toHex();
30 
31         //String newPassword = new SimpleHash(algorithmName, user.getPassword()).toHex();
32 
33         user.setPassword(newPassword);
34 
35 
36 
37     }
38 
39     public static void main(String[] args) {
40 
41         PasswordHelper passwordHelper = new PasswordHelper();
42 
43         User user = new User();
44 
45         user.setUsername("admin");
46 
47             user.setPassword("admin");
48 
49         passwordHelper.encryptPassword(user);
50 
51         System.out.println(user);
52 
53     }
54 
55 }
授权:

接下来讲下授权。在自定义relalm中的代码为:

 1  //授权
 2 
 3     @Override
 4 
 5     protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
 6 
 7         User user= (User) SecurityUtils.getSubject().getPrincipal();//User{id=1, username='admin', password='3ef7164d1f6167cb9f2658c07d3c2f0a', enable=1}
 8 
 9         Map<String,Object> map = new HashMap<String,Object>();
10 
11         map.put("userid",user.getId());
12 
13         List<Resources> resourcesList = resourcesService.loadUserResources(map);
14         // 权限信息对象info,用来存放查出的用户的所有的角色(role)及权限(permission)
15 
16         SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
17 
18         for(Resources resources: resourcesList){
19 
20             info.addStringPermission(resources.getResurl());
21 
22         }
23 
24         return info;
25 
26     }

从以上代码中可以看出来,我根据用户id查询出用户的权限,放入SimpleAuthorizationInfo。关联表user_role,role_resources,resources,三张表,根据用户所拥有的角色,角色所拥有的权限,查询出分配给该用户的所有权限的url。当访问的链接中配置在shiro中时,或者使用shiro标签,shiro权限注解时,则会访问该方法,判断该用户是否拥有相应的权限。

ShiroConfig中有如下代码:

 1 @Bean
 2 
 3     public ShiroFilterFactoryBean shirFilter(SecurityManager securityManager){
 4 
 5         System.out.println("ShiroConfiguration.shirFilter()");
 6 
 7         ShiroFilterFactoryBean shiroFilterFactoryBean  = new ShiroFilterFactoryBean();
 8 
 9 
10 
11         // 必须设置 SecurityManager
12 
13         shiroFilterFactoryBean.setSecurityManager(securityManager);
14 
15         // 如果不设置默认会自动寻找Web工程根目录下的"/login.jsp"页面
16 
17         shiroFilterFactoryBean.setLoginUrl("/login");
18 
19         // 登录成功后要跳转的链接
20 
21         shiroFilterFactoryBean.setSuccessUrl("/usersPage");
22 
23         //未授权界面;
24 
25         shiroFilterFactoryBean.setUnauthorizedUrl("/403");
26 
27         //拦截器.
28 
29         Map<String,String> filterChainDefinitionMap = new LinkedHashMap<String,String>();
30 
31 
32         //配置退出 过滤器,其中的具体的退出代码Shiro已经替我们实现了
33 
34         filterChainDefinitionMap.put("/logout", "logout");
35 
36         filterChainDefinitionMap.put("/css/**","anon");
37 
38         filterChainDefinitionMap.put("/js/**","anon");
39 
40         filterChainDefinitionMap.put("/img/**","anon");
41 
42         filterChainDefinitionMap.put("/font-awesome/**","anon");
43 
44         //<!-- 过滤链定义,从上向下顺序执行,一般将 /**放在最为下边 -->:这是一个坑呢,一不小心代码就不好使了;
45 
46         //<!-- authc:所有url都必须认证通过才可以访问; anon:所有url都都可以匿名访问-->
47 
48         //自定义加载权限资源关系
49 
50         List<Resources> resourcesList = resourcesService.queryAll();
51          for(Resources resources:resourcesList){
52 
53 
54 
55             if (StringUtil.isNotEmpty(resources.getResurl())) {
56 
57                 String permission = "perms[" + resources.getResurl()+ "]";
58 
59                 filterChainDefinitionMap.put(resources.getResurl(),permission);
60 
61             }
62 
63         }
64 
65         filterChainDefinitionMap.put("/**", "authc");
66 
67 
68 
69 
70         shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
71 
72         return shiroFilterFactoryBean;
73 
74     }
该代码片段为配置shiro的过滤器。以上代码将静态文件设置为任何权限都可访问,然后
 1 List<Resources> resourcesList = resourcesService.queryAll();
 2 
 3          for(Resources resources:resourcesList){
 4 
 5 
 6 
 7             if (StringUtil.isNotEmpty(resources.getResurl())) {
 8                 String permission = "perms[" + resources.getResurl()+ "]";
 9 
10                 filterChainDefinitionMap.put(resources.getResurl(),permission);
11 
12             }
13 
14         }
在数据中查询所有的资源,将该资源的url当作key,配置拥有该url权限的用户才可访问该url。 
最后加入 filterChainDefinitionMap.put(“/*”, “authc”);表示其他没有配置的链接都需要认证才可访问。注意这个要放最后面,因为shiro的匹配是从上往下,如果匹配到就不继续匹配了,所以把 /放到最前面,则 后面的链接都无法匹配到了。
而这段代码是在项目启动的时候加载的。加载的数据是放到内存中的。但是当权限增加或者删除时,正常情况下不会重新启动来,重新加载权限。所以需要调用以下代码的updatePermission()方法来重新加载权限。其实下面的代码有些重复了,可以稍微调整下,我就先这么写了。



  1 package com.study.shiro;
  2 
  3 
  4 import com.github.pagehelper.util.StringUtil;
  5 
  6 import com.study.model.Resources;
  7 
  8 import com.study.model.User;
  9 
 10 import com.study.service.ResourcesService;
 11 
 12 import org.apache.shiro.SecurityUtils;
 13 
 14 import org.apache.shiro.mgt.RealmSecurityManager;
 15 
 16 import org.apache.shiro.session.Session;
 17 
 18 import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
 19 
 20 import org.apache.shiro.subject.SimplePrincipalCollection;
 21 
 22 import org.apache.shiro.subject.support.DefaultSubjectContext;
 23 
 24 import org.apache.shiro.web.filter.mgt.DefaultFilterChainManager;
 25 
 26 import org.apache.shiro.web.filter.mgt.PathMatchingFilterChainResolver;
 27 
 28 import org.apache.shiro.web.servlet.AbstractShiroFilter;
 29 import org.crazycake.shiro.RedisSessionDAO;
 30 
 31 import org.springframework.beans.factory.annotation.Autowired;
 32 
 33 import org.springframework.stereotype.Service;
 34 
 35 
 36 
 37 import java.util.*;
 38 
 39 
 40 /**
 41 
 42  * Created by yangqj on 2017/4/30.
 43 
 44  */
 45 
 46 @Service
 47 
 48 public class ShiroService {
 49 
 50     @Autowired
 51 
 52     private ShiroFilterFactoryBean shiroFilterFactoryBean;
 53 
 54     @Autowired
 55 
 56     private ResourcesService resourcesService;
 57 
 58     @Autowired
 59 
 60     private RedisSessionDAO redisSessionDAO;
 61 
 62     /**
 63 
 64      * 初始化权限
 65 
 66      */
 67 
 68     public Map<String, String> loadFilterChainDefinitions() {
 69 
 70         // 权限控制map.从数据库获取
 71 
 72         Map<String, String> filterChainDefinitionMap = new LinkedHashMap<String, String>();
 73 
 74         filterChainDefinitionMap.put("/logout", "logout");
 75 
 76         filterChainDefinitionMap.put("/css/**","anon");
 77 
 78         filterChainDefinitionMap.put("/js/**","anon");
 79 
 80         filterChainDefinitionMap.put("/img/**","anon");
 81 
 82         filterChainDefinitionMap.put("/font-awesome/**","anon");
 83 
 84         List<Resources> resourcesList = resourcesService.queryAll();
 85 
 86         for(Resources resources:resourcesList){
 87 
 88 
 89 
 90             if (StringUtil.isNotEmpty(resources.getResurl())) {
 91 
 92                 String permission = "perms[" + resources.getResurl()+ "]";
 93 
 94                 filterChainDefinitionMap.put(resources.getResurl(),permission);
 95 
 96             }
 97 
 98         }
 99 
100         filterChainDefinitionMap.put("/**", "authc");
101 
102         return filterChainDefinitionMap;
103 
104     }
105 
106 
107     /**
108      * 重新加载权限
109      */
110 
111     public void updatePermission() {
112 
113 
114 
115         synchronized (shiroFilterFactoryBean) {
116 
117 
118 
119             AbstractShiroFilter shiroFilter = null;
120 
121             try {
122 
123                 shiroFilter = (AbstractShiroFilter) shiroFilterFactoryBean
124 
125                         .getObject();
126 
127             } catch (Exception e) {
128 
129                 throw new RuntimeException(
130 
131                         "get ShiroFilter from shiroFilterFactoryBean error!");
132 
133             }
134 
135 
136 
137             PathMatchingFilterChainResolver filterChainResolver = (PathMatchingFilterChainResolver) shiroFilter
138 
139                     .getFilterChainResolver();
140 
141             DefaultFilterChainManager manager = (DefaultFilterChainManager) filterChainResolver
142 
143                     .getFilterChainManager();
144 
145 
146 
147             // 清空老的权限控制
148 
149             manager.getFilterChains().clear();
150 
151 
152 
153             shiroFilterFactoryBean.getFilterChainDefinitionMap().clear();
154 
155             shiroFilterFactoryBean
156 
157                     .setFilterChainDefinitionMap(loadFilterChainDefinitions());
158 
159             // 重新构建生成
160 
161             Map<String, String> chains = shiroFilterFactoryBean
162 
163                     .getFilterChainDefinitionMap();
164 
165             for (Map.Entry<String, String> entry : chains.entrySet()) {
166 
167                 String url = entry.getKey();
168 
169                 String chainDefinition = entry.getValue().trim()
170 
171                         .replace(" ", "");
172 
173                 manager.createChain(url, chainDefinition);
174 
175             }
176 
177 
178 
179             System.out.println("更新权限成功!!");
180 
181         }
182 
183     }
184 
185 
186 
187 
188 }
会话管理

这个例子使用了redis保存session。这样可以实现集群的session共享。在ShiroConfig中有代码:

 1 @Bean
 2 
 3     public SecurityManager securityManager(){
 4 
 5         DefaultWebSecurityManager securityManager =  new DefaultWebSecurityManager();
 6 
 7         //设置realm.
 8 
 9         securityManager.setRealm(myShiroRealm());
10 
11         // 自定义缓存实现 使用redis
12 
13         //securityManager.setCacheManager(cacheManager());
14 
15         // 自定义session管理 使用redis
16 
17         securityManager.setSessionManager(sessionManager());
18 
19         return securityManager;
20 
21     }
配置了自定义session,网上已经有大神实现了 使用redis 自定义session管理,直接拿来用,引入包
1 <dependency>
2 
3     <groupId>org.crazycake</groupId>
4 
5     <artifactId>shiro-redis</artifactId>
6 
7     <version>2.4.2.1-RELEASE</version>
8 
9 </dependency>  
然后再配置:
 1 /**
 2 
 3      * 配置shiro redisManager
 4 
 5      * 使用的是shiro-redis开源插件
 6 
 7      * @return
 8 
 9      */
10 
11     public RedisManager redisManager() {
12 
13         RedisManager redisManager = new RedisManager();
14 
15         redisManager.setHost(host);
16 
17         redisManager.setPort(port);
18 
19         redisManager.setExpire(1800);// 配置缓存过期时间
20 
21         redisManager.setTimeout(timeout);
22 
23         // redisManager.setPassword(password);
24 
25         return redisManager;
26 
27     }
28 
29 
30     /**
31 
32      * cacheManager 缓存 redis实现
33 
34      * 使用的是shiro-redis开源插件
35 
36      * @return
37 
38      */
39 
40     public RedisCacheManager cacheManager() {
41 
42         RedisCacheManager redisCacheManager = new RedisCacheManager();
43 
44         redisCacheManager.setRedisManager(redisManager());
45 
46         return redisCacheManager;
47 
48     }
49 
50 
51 
52 
53     /**
54 
55      * RedisSessionDAO shiro sessionDao层的实现 通过redis
56 
57      * 使用的是shiro-redis开源插件
58 
59      */
60 
61     @Bean
62 
63     public RedisSessionDAO redisSessionDAO() {
64 
65         RedisSessionDAO redisSessionDAO = new RedisSessionDAO();
66 
67         redisSessionDAO.setRedisManager(redisManager());
68 
69         return redisSessionDAO;
70 
71     }
72 
73 
74     /**
75 
76      * shiro session的管理
77 
78      */
79 
80     @Bean
81 
82     public DefaultWebSessionManager sessionManager() {
83 
84         DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
85 
86         sessionManager.setSessionDAO(redisSessionDAO());
87 
88         return sessionManager;
89 
90     }
RedisConfig

 1 package com.study.config;
 2 
 3 
 4 import org.apache.log4j.Logger;
 5 
 6 import org.springframework.beans.factory.annotation.Value;
 7 
 8 import org.springframework.cache.annotation.CachingConfigurerSupport;
 9 
10 import org.springframework.cache.annotation.EnableCaching;
11 
12 import org.springframework.context.annotation.Bean;
13 
14 import org.springframework.context.annotation.Configuration;
15 
16 import redis.clients.jedis.JedisPool;
17 
18 import redis.clients.jedis.JedisPoolConfig;
19 
20 
21 /**
22 
23  * Created by yangqj on 2017/4/30.
24  */
25 @Configuration
26 
27 @EnableCaching
28 
29 public class RedisConfig extends CachingConfigurerSupport {
30     @Value("${spring.redis.host}")
31 
32     private String host;
33 
34 
35 
36     @Value("${spring.redis.port}")
37 
38     private int port;
39 
40 
41 
42     @Value("${spring.redis.timeout}")
43 
44     private int timeout;
45 
46 
47     @Value("${spring.redis.pool.max-idle}")
48 
49     private int maxIdle;
50 
51 
52 
53     @Value("${spring.redis.pool.max-wait}")
54 
55     private long maxWaitMillis;
56 
57 
58 
59     @Bean
60 
61     public JedisPool redisPoolFactory() {
62 
63         Logger.getLogger(getClass()).info("JedisPool注入成功!!");
64 
65         Logger.getLogger(getClass()).info("redis地址:" + host + ":" + port);
66 
67         JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
68 
69         jedisPoolConfig.setMaxIdle(maxIdle);
70 
71         jedisPoolConfig.setMaxWaitMillis(maxWaitMillis);
72 
73 
74         JedisPool jedisPool = new JedisPool(jedisPoolConfig, host, port, timeout);
75 
76 
77         return jedisPool;
78 
79     }
80 
81 
82 }
配置文件 application.properties中加入: 
 1 #redis
 2 
 3 # Redis服务器地址
 4 
 5 spring.redis.host= localhost
 6 
 7 # Redis服务器连接端口
 8 
 9 spring.redis.port= 6379
10 
11 # 连接池中的最大空闲连接
12 
13 spring.redis.pool.max-idle= 8
14 
15 # 连接池中的最小空闲连接
16 
17 spring.redis.pool.min-idle= 0
18 
19 # 连接池最大连接数(使用负值表示没有限制)
20 
21 spring.redis.pool.max-active= 8
22 
23 # 连接池最大阻塞等待时间(使用负值表示没有限制)
24 
25 spring.redis.pool.max-wait= -1
26 
27 # 连接超时时间(毫秒)
28 spring.redis.timeout= 0
当然运行的时候要先启动redis。将自己的redis配置在以上配置中。这样session就存在redis中了。 
上面ShiroConfig中的securityManager()方法中,我把
1 //securityManager.setCacheManager(cacheManager());
这行代码注了,是这样的,因为每次在需要验证的地方,比如在subject.hasRole(“admin”) 或 subject.isPermitted(“admin”)、@RequiresRoles(“admin”) 、 shiro:hasPermission=”/users/add”的时候都会调用MyShiroRealm中的doGetAuthorizationInfo()。
但是以为这些信息不是经常变的,所以有必要进行缓存。把这行代码的注释打开,的时候都会调用MyShiroRealm中的doGetAuthorizationInfo()的返回结果会被redis缓存。但是这里稍微有个小问题,就是在刚修改用户的权限时,无法立即失效
。本来我是使用了ShiroService中的clearUserAuthByUserId()想清除当前session存在的用户的权限缓存,但是没有效果。
不知道什么原因。希望哪个大神看到后帮忙弄个解决方法。所以我干脆就把doGetAuthorizationInfo()的返回结果通过spring cache的方式加入缓存。
1 @Cacheable(cacheNames="resources",key="#map['userid'].toString()+#map['type']")
2     public List<Resources> loadUserResources(Map<String, Object> map) {
3 
4         return resourcesMapper.loadUserResources(map);
5 
6     }
这样也可以实现,然后在修改权限时加上注解
1  @CacheEvict(cacheNames="resources", allEntries=true)

这样修改权限后可以立即生效。其实我感觉这样不好,因为清楚了我是清除了所有用户的权限缓存,其实只要修改当前session在线中被修改权限的用户就行了。 先这样吧,以后再研究下,修改得更好一点。

按钮控制

在前端页面,对按钮进行细粒度权限控制,只需要在按钮上加上shiro:hasPermission

1 <button shiro:hasPermission="/users/add" type="button"  onclick="$('#addUser').modal();" class="btn btn-info" >新增</button>
这里的参数就是我们在ShiroConfig-shirFilter()权限加载时的过滤器 中的value,也就是资源的url。
1  filterChainDefinitionMap.put(resources.getResurl(),permission);

8.效果图

9.运行、下载

下载项目后运行resources下的shiro.sql文件。需要运行redis后运行项目。访问http://localhost:8080/ 账号密码:admin admin 或user1 user1.新增的用户也可以登录。

github下载地址:https://github.com/lovelyCoder/springboot-shiro

转自:https://www.cnblogs.com/jpfss/p/8311317.html#

原文地址:https://www.cnblogs.com/chenlove/p/9366820.html