微服务(五)-Oauth2、Spring Cloud Security、JWT令牌、SpringMVC拦截器、迁移问答功能模块(提问、回答、评论)

1 Oauth2

1.1 什么是Oauth2?

  O(pen)Auth(开发授权) 是一个公开的授权标准,Oauth2.0是Oauth的延续版本,早期的Oauth1.0已经被淘汰。现在市面上使用的都是Oauth2,这个Oauth2.0是一个授权协议,它规定了多种授权模式和授权方法,这个标准下很多公司都开放了授权登录第三方网站的功能。比如我们常用的授权模式有:

  • 扫码登录

  • 用户名和密码

  • 客户端授权等

我们添加Oauth2依赖,添加之后当前项目会自动添加一些控制器依赖,比较常见的控制器路径有:

  • /oauth/token:向这个路径发送请求,返回令牌

  • /oauth/check_token:给定一个令牌做参数,能够返回当前用户信息

1.2 Spring Cloud Security

  支持微服务的Spring Security安全框架:Spring Cloud Security,我们单体项目使用了Spring Security安全框架管理登录和权限,在微服务的框架下,我们需要使用支持微服务的版本,再结合Oauth2标准,实现使用Token的授权过程。

具体的Spring Cloud Security结合Oauth2标准实现单点登录思路如下:

  上面的模式中,我们创建的微服务项目分成两大类:

  1. 授权服务器:接收用户名、密码,验证登录,并返回令牌---获取令牌

  2. 资源服务器:在用户请求时,解析令牌,获得用户信息,执行业务操作---解析令牌

  最后,利用Oauth2中自带的控制器提供的端点(暴露的Rest接口)。

1.3 创建授权服务器

1.3.1 创建knows-auth

  在父项目knows上单击右键,新建module:knows-auth,什么都不勾选,完成项目的创建。

父子相认:在父项目的knows的pom.xml文件中添加子项目依赖knows-auth

 <module>knows-auth</module>

子项目knows-auth的pom.xml文件中删除无关配置,添加依赖:

 <?xml version="1.0" encoding="UTF-8"?>
 <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
          xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
     <modelVersion>4.0.0</modelVersion>
     <parent>
         <groupId>cn.tedu</groupId>
         <artifactId>knows</artifactId>
         <version>0.0.1-SNAPSHOT</version>
         <relativePath/> <!-- lookup parent from repository -->
     </parent>
     <groupId>cn.tedu</groupId>
     <artifactId>knows-auth</artifactId>
     <version>0.0.1-SNAPSHOT</version>
     <name>knows-auth</name>
     <description>Demo project for Spring Boot</description>
 
     <dependencies>
         <!-- spring cloud security 依赖 -->
         <dependency>
             <groupId>org.springframework.cloud</groupId>
             <artifactId>spring-cloud-starter-security</artifactId>
         </dependency>
         <!-- 添加支持Oauth2开放标准的实现 -->
         <dependency>
             <groupId>org.springframework.cloud</groupId>
             <artifactId>spring-cloud-starter-oauth2</artifactId>
         </dependency>
         <!-- jwt令牌支持 -->
         <dependency>
             <groupId>org.springframework.security</groupId>
             <artifactId>spring-security-jwt</artifactId>
         </dependency>
  <!-- 数据库相关依赖 -->
         <dependency>
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-starter-jdbc</artifactId>
         </dependency>
         <dependency>
             <groupId>mysql</groupId>
             <artifactId>mysql-connector-java</artifactId>
         </dependency>
         <!--springboot内置tomcat和SpringMVC,可进行启动web服务-->
         <dependency>
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-starter-web</artifactId>
         </dependency>
          <!--注册到nacos-->
         <dependency>
             <groupId>com.alibaba.cloud</groupId>
             <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
         </dependency>
         <!--通用实体类-->
         <dependency>
             <groupId>cn.tedu</groupId>
             <artifactId>knows-commons</artifactId>
         </dependency>
     </dependencies>
 
 </project>

application.properties中添加配置信息:

 # Oauth授权端口
 server.port=8010
 
 # 微服务项目名称
 spring.application.name=auth-service
 
 # 地址
 spring.cloud.nacos.discovery.server-addr=localhost:8848
 
 # 连接数据库
 spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
 spring.datasource.url=jdbc:mysql://localhost:3307/knows?characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true
 spring.datasource.username=root
 spring.datasource.password=root
 
 # 允许我们自己写的@Bean也就是注入到Spring容器的对象和系统提供的id相同
 # 并覆盖掉原有的对象
 spring.main.allow-bean-definition-overriding=true

SpringBoot启动类:

 @SpringBootApplication
 @EnableDiscoveryClient  //注册到nacos
 public class KnowsAuthApplication {
 
     public static void main(String[] args) {
         SpringApplication.run(KnowsAuthApplication.class, args);
    }
 
 }

1.3.2 授权服务器组件准备

auth模块需要用户信息,所以需要使用Ribbon请求sys模块的用信息,它需要Ribbon的支持:

 //添加ribbon支持
 @Bean
 @LoadBalanced
 public RestTemplate restTemplate(){
     return new RestTemplate();
 }

项目仍然是Spring Security框架下的,仍然需要配置Spring Security信息,创建security包,包中创建SecurityConfig代码如下:

 package cn.tedu.knows.auth.security;
 
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.security.authentication.AuthenticationManager;
 import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
 import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
 import org.springframework.security.crypto.password.PasswordEncoder;
 
 @Configuration
 @EnableGlobalMethodSecurity(prePostEnabled = true) //权限管理
 public class SecurityConfig extends WebSecurityConfigurerAdapter {
     //设置Security全部放行,因为我们Oauth2的方式验证,原有的session验证就不需要了
     @Override
     protected void configure(HttpSecurity http) throws Exception {
         http.csrf().disable()
                .authorizeRequests()
                .anyRequest().permitAll()
                .and().formLogin();
    }
 
     //当前是配置类,我们框架中需要进行加密,我们注入一个对象到Spring容器
     // 确定它的加密规则,这里仍然使用bcrypt
     @Bean
     public PasswordEncoder passwordEncoder(){
         return  new BCryptPasswordEncoder();
    }
 
     // 当前类继承了一个父类,这个父类中有一个对象包含着验证登录的代码
     // 当前Spring容器需要这个对象,把他保存到Spring容器中
     @Bean
     public AuthenticationManager authenticationManagerBean() throws Exception {
         return super.authenticationManagerBean();
    }
 }
 

我们要生成Token,生成Token需要生成器对象,我们在security包定义一个生成器类,创建TokenConfig,先写一个简单的令牌生成,后期会修改为加密的:

 @Configuration
 public class TokenConfig {
 
     // 向Spring 容器中注入一个令牌生成器对象
     @Bean
     public TokenStore tokenStore(){
         return new InMemoryTokenStore();
    }
 }

还需要sys模块提供用户信息的支持,我们编写一个类,这个类中需要从sys模块中获得用户信息、权限信息、角色信息,最终返回UserDetails类型。

下面转到knows-sys模块,在这个模块中我们要保证它包含如下3个控制方法:

  • 根据用户名获得用户对象

  • 根据用户id获得所有权限

  • 根据用户id获得所有角色

我们将这个操作称之为暴露Rest接口(编写控制方法),以便auth使用Ribbon调用

业务逻辑层:

IUserService添加方法:

 // 根据用户id查询所有权限
 List<Permission> getPermissionsById(Integer id);
 
 // 根据用户id查询所有角色
 List<Role> getRolesById(Integer id);

实现类UserServiceImpl:

 // 根据用户id查询所有权限的逻辑层实现
 @Override
 public List<Permission> getPermissionsById(Integer id) {
     return userMapper.findUserPermissionsById(id);
 }
 
 // 根据用户id查询所有角色的逻辑层实现
 @Override
 public List<Role> getRolesById(Integer id) {
     return userMapper.findUserRolesById(id);
 }

控制层AuthController:

 // 根据用户id查询所有权限
 @GetMapping("/permissions")
 public List<Permission> permissions(Integer id){
     return userService.getPermissionsById(id);
 }
 
 // 根据用户id查询所有角色
 @GetMapping("/roles")
 public List<Role> roles(Integer id){
     return userService.getRolesById(id);
 }

sys模块提供的方法已经编写完成,转回到knows-auth,开始编写登录返回用户详情的代码:

 package cn.tedu.knows.auth.service;
 
 import cn.tedu.knows.commons.model.Permission;
 import cn.tedu.knows.commons.model.Role;
 import cn.tedu.knows.commons.model.User;
 import org.springframework.security.core.userdetails.UserDetails;
 import org.springframework.security.core.userdetails.UserDetailsService;
 import org.springframework.security.core.userdetails.UsernameNotFoundException;
 import org.springframework.stereotype.Component;
 import org.springframework.web.client.RestTemplate;
 
 import javax.annotation.Resource;
 
 @Component
 public class UserDetailsServiceImpl implements UserDetailsService {
     @Resource
     private RestTemplate restTemplate;
 
     //重写loadeUsername方法
     @Override
     public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
         // 声明要调用的控制器的路径
         // sys-service:要调用的微服务注册到Nacos的名称
         // /v1/auth/user:要调用的控制器的访问路径
         // 有参数的Ribbon调用
         // url请求的路径写完之后,使用?分割开始编写参数列表
         // 参数的值不能写死,要用{1},{2}....这种方式占位
         String url = "http://sys-service/v1/auth/user?username={1}";//微服务模块之间通信不过网关
         User user = restTemplate.getForObject(url,User.class,username);
         if(user==null){
             throw new UsernameNotFoundException("用户名不存在");
        }
 
         url = "http://sys-service/v1/auth/permissions?id={1}";
         Permission[] permissions = restTemplate.getForObject(url, Permission[].class,user.getId());
 
         url = "http://sys-service/v1/auth/roles?id={1}";
         Role[] roles = restTemplate.getForObject(url,Role[].class,user.getId());
 
         //定义auth数组,包含所有角色和权限的名称
         String[] auth = new String[permissions.length + roles.length];
         //声明起始位置
         int i = 0;
         //遍历权限数组
         for(Permission p:permissions){
             auth[i++] = p.getName();
        }
         //遍历角色数组
         for(Role r:roles){
             auth[i++] = r.getName();
        }
         //构建UserDetails对象
         UserDetails u = org.springframework.security.core.userdetails
                .User.builder()
                .username(user.getUsername())
                .password(user.getPassword())
                .authorities(auth) //已经将权限和角色都存到字符串数组中了
                .accountLocked(user.getLocked()==1)
                .accountExpired(user.getEnabled()==0)
                .build();
         return u;
    }
 }
 

下面进行一下测试,启动nacos,启动sys模块,测试代码如下:

 @Resource
 UserDetailsServiceImpl userDetailsService;
 
 @Test
 void contextLoads() {
     UserDetails user=userDetailsService.loadUserByUsername("st2");
     System.out.println(user);
 }

运行能够获得用户信息,表示一切正常,输出结果如下:

完整内容:

 Username: st2; Password: [PROTECTED]; Enabled: true; AccountNonExpired: true; credentialsNonExpired: true; AccountNonLocked: true; 
Granted Authorities: /index.html,/question/create,/question/detail,/question/uploadMultipleFile,ROLE_STUDENT

1.3.3 授权服务器核心配置

  上面准备了很多零件,都是用于下面的核心配置的,这个配置就是授权服务器的主体配置,配置大致分为3部分:

  1. 配置授权服务器的登录参数

  1. 当前登录的客户端有哪些权限

  1. 配置资源服务器的安全限制

在security包中创建一个类,做上面的配置AuthorizationServer(授权服务器类)

 @Configuration
 // @EnableAuthorizationServer这个注解表示下面的类是配置Oauth2授权标准的核心类
 // Oauth2包含的控制器方法中会读取下面的配置,实施具体授权
 @EnableAuthorizationServer
 public class AuthorizationServer extends
         AuthorizationServerConfigurerAdapter {
     @Autowired
     private AuthenticationManager authenticationManager;
     @Autowired
     private UserDetailsServiceImpl userDetailsService;
 
     // 配置授权服务器的登录参数(配置用什么方式获取授权)
     @Override
     public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
         //endpoints就是端点的意思,这里指客户端登录访问的Rest接口
         // 配置SpringSecurity中的认证管理器
         endpoints.authenticationManager(authenticationManager)
                 //配置登录获得用户详情的方法
                .userDetailsService(userDetailsService)
                 // 设置登录的方式只能是post保证安全
                .allowedTokenEndpointRequestMethods(HttpMethod.POST)
                 // 配置令牌生成器(如何生成令牌)
                .tokenServices(tokenService());
    }
 
     @Autowired
     private TokenStore tokenStore;
     @Autowired
     private ClientDetailsService clientDetailsService;
 
     // 配置生成令牌的方法
     @Bean
     public AuthorizationServerTokenServices tokenService() {
         //下面开始设置生成令牌的参数
         DefaultTokenServices services = new DefaultTokenServices();
         //支持令牌刷新
         services.setSupportRefreshToken(true);
         //设置令牌生成对象
         services.setTokenStore(tokenStore);
         // 设置令牌有效期(3600是秒 指1小时有效)
         services.setAccessTokenValiditySeconds(3600);
         // 设置令牌刷新最大时间
         services.setRefreshTokenValiditySeconds(3600);
         // 配置客户端详情
         services.setClientDetailsService(clientDetailsService);
         // 千万别忘了返回services
         return services;
    }
 
     @Autowired
     private PasswordEncoder passwordEncoder;
     // 配置客户端详情,规定客户端权限
     @Override
     public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
         clients.inMemory()  //在内存中保存客户端权限设置
                 //设置客户端id
                .withClient("knows")
                 //当前客户端加密规则:设置一个秘钥
                .secret(passwordEncoder.encode("123456"))
                 //赋予客户端权限(只是一个名称)
                .scopes("main")
                 // 允许客户端登录的类型
                 // password表示支持用户名密码登录
                 // refresh_token表示支持令牌刷新
                .authorizedGrantTypes("password","refresh_token");
    }
     // 认证成功后,当前客户端能够访问Oauth2的端点设置
     @Override
     public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
         security
                 // 当前Oauth2允许任何人访问/oauth/token端点
                .tokenKeyAccess("permitAll()")
                 // 当前Oauth2允许任何人访问/oauth/check_token端点
                .checkTokenAccess("permitAll()")
                 // 允许通过验证的客户端获得令牌
                .allowFormAuthenticationForClients();
    }
 }

1.3.4 测试授权服务器

打开postman,新建一个请求(HTTP Request),启动auth服务,路径编写如下:

http://localhost:8010/oauth/token?client_id=knows&client_secret=123456&grant_type=password&username=st2&password=888888,注意用post方式发请求,观察登录结果。

注意:要删除数据库中用户密码列的算法id,修改密码为下面的格式:

2a10$ELGiEhKyLlO9r3.WVOkHDe16JTCKCErcABhElD5CF7ZwQ.Hm6sVRW

上面的结果中access_token就是生成的令牌。

解析令牌:post/get均可,输入地址:http://localhost:8010/oauth/check_token,其中,参数token=后面的值是上面生成的令牌

附测试结果:

postman测试结果(失败):post请求写成了get

postman测试结果(成功):post请求

解析令牌get/post均可,解析结果:

 

2 JWT令牌

  经过上面的学习,我们已经完成了利用Oauth2颁发和验证令牌,但是我们登录成功用户的信息仍然保存在了服务器内存中,其实就是我们看到的令牌是UUID作为的key,用户的信息是内存中的value。

  本质上是使用这个uuid获得内存中的用户,为了实现刚开始的目标,解放内存,我们需要使用JWT令牌。

2.1 什么是JWT令牌?

  JWT(Json Web Token)是一种json格式,保存用户信息到客户端的一种形式,将用户信息转换为json格式,然后进行加密,最后发送到客户端。客户端接收到这个JWT之后,保存在客户端,之后在访问其它模块时,带着JWT,资源服务器解析获得用户信息,进行访问,这样就能不在内存中保存信息了。

下面我们尝试实现这个效果,TokenConfig类中修改代码:

 package cn.tedu.knows.auth.security;
 
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.security.jwt.Jwt;
 import org.springframework.security.oauth2.common.OAuth2AccessToken;
 import org.springframework.security.oauth2.common.OAuth2RefreshToken;
 import org.springframework.security.oauth2.provider.OAuth2Authentication;
 import org.springframework.security.oauth2.provider.token.TokenStore;
 import org.springframework.security.oauth2.provider.token.store.InMemoryTokenStore;
 import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
 import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;
 import org.springframework.security.oauth2.provider.token.store.jwk.JwkTokenStore;
 
 import java.util.Collection;
 
 @Configuration
 public class TokenConfig {
     //定义JWT令牌的解密口令
     private final String SINGING_KEY="pass";
 
     // 向Spring 容器中注入一个令牌生成器对象
     @Bean
     public TokenStore tokenStore(){
         //return new InMemoryTokenStore();//在内存中生成Token
         return new JwtTokenStore(accessTokenConverter());//利用加密规则进行加密
    }
 
     @Bean
     public JwtAccessTokenConverter accessTokenConverter(){
         //实例化Jwt加密器
         JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
         //设置加密解密口令
         converter.setSigningKey(SINGING_KEY);
         return converter;
    }
 
 }
 

除此之外,还需要在授权服务器中添加一些配置:

 // ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
 @Autowired
 private JwtAccessTokenConverter accessTokenConverter;
 // 配置生成令牌的方法
 @Bean
 public AuthorizationServerTokenServices tokenService() {
     //下面开始设置生成令牌的参数
     DefaultTokenServices services = new DefaultTokenServices();
     //支持令牌刷新
     services.setSupportRefreshToken(true);
     //设置令牌生成对象
     services.setTokenStore(tokenStore);
     // ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
     // 必须设置令牌增强才能生成Jwt令牌
     // 实例化令牌增强对象
     TokenEnhancerChain chain=new TokenEnhancerChain();
     // 令牌增强对象可以设置很转换器,我们Jwt转换器是其中一个
     // 将Jwt转换器添加到这个令牌增强对象中
     chain.setTokenEnhancers(
                     Arrays.asList(accessTokenConverter));
     // 将令牌增强对象赋值为令牌生成器
     services.setTokenEnhancer(chain);
     // ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑
     // 设置令牌有效期(3600是秒 指1小时有效)
     services.setAccessTokenValiditySeconds(3600);
     // 设置令牌刷新最大时间
     services.setRefreshTokenValiditySeconds(3600);
     // 配置客户端详情
     services.setClientDetailsService(clientDetailsService);
     // 千万别忘了返回services
     return services;
 }

  上次课我们已经完成了auth项目的Oauth2颁发令牌的配置,postman软件能够正常获得令牌。下面我们需要在页面上发送请求,能够获得Oauth2响应的内容。

2.2 配置网关

  前端项目只访问9000端口,所以我们先设置9000端口到8010端口的路由配置。

转到gateway项目:在application.yml添加配置

 - id: gateway-auth
  uri: lb://auth-service
  predicates:
    - Path=/oauth/**

2.3 修改登录页面

转到client前端项目:login.html页面修改如下:

 <!DOCTYPE html>
 <html lang="zh-CN">
 <head>
   <meta charset="UTF-8">
   <meta http-equiv="X-UA-Compatible" content="IE=edge">
   <meta name="renderer" content="webkit">
   <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
   <title>达内知道登录</title>
   <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
   <link rel="stylesheet" href="bower_components/bootstrap/dist/css/bootstrap.css">
   <link rel="stylesheet" href="bower_components/font-awesome/css/font-awesome.css">
   <link rel="stylesheet" href="css/login.css" >
 </head>
 <body class="bg-light">
    <!-- ↓↓↓↓↓↓↓ -->
 <div class="container-fluid" id="loginApp">
   <div class="row">
     <div class="mx-auto mt-5" style="width: 400px;">
       <h2 class="text-center "><b>达内</b>·知道</h2>
       <div class="bg-white p-4">
         <p class="text-center">用户登录</p>
         <div id="error" class="alert alert-danger d-none">
           <i class="fa fa-exclamation-triangle"></i> 账号或密码错误
         </div>
         <div id="logout" class="alert alert-info d-none">
           <i class="fa fa-exclamation-triangle"></i> 已经登出系统
         </div>
         <div id="register" class="alert alert-info d-none">
           <i class="fa fa-exclamation-triangle"></i> 已经成功注册,请登录。
         </div>
           <!-- ↓↓↓↓↓↓↓ -->
         <form action="/login" method="post"
           @submit.prevent="login">
           <div class="form-group has-icon">
               
               <!-- ↓↓↓↓↓↓↓ -->
             <input type="text" class="form-control d-inline"
                    name="username" placeholder="手机号"
                     v-model="username">
             <span class="fa fa-phone form-control-icon"></span>
           </div>
           <div class="form-group has-icon">
               <!-- ↓↓↓↓↓↓↓ -->
             <input type="password" class="form-control"
                    name="password" placeholder="密码"
                    v-model="password">
             <span class="fa fa-lock form-control-icon"></span>
           </div>
           <button type="submit" class="btn btn-primary btn-block ">登录</button>
         </form>
         <a class="d-block mt-1" href="resetpassword.html" >忘记密码?</a>
         <a class="d-block mt-1" href="register.html"  >新用户注册</a>
       </div>
     </div>
   </div>
 </div>
 <script src="bower_components/jquery/dist/jquery.js" ></script>
 <script src="bower_components/bootstrap/dist/js/bootstrap.js" ></script>
     <!-- ↓↓↓↓↓↓↓ -->
 <script src="bower_components/vue/dist/vue.js"></script>
 <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
 <script>
   if (location.search == "?error"){
     $("#error").removeClass("d-none");
  }
   if (location.search == "?logout"){
     $("#logout").removeClass("d-none");
  }
   if (location.search == "?register"){
     $("#register").removeClass("d-none");
  }
     // ↓↓↓↓↓↓↓
   let loginApp =new Vue({
     el:"#loginApp",
     data:{
       username:"",
       password:""
    },
     methods:{
       login:function(){
         let form=new FormData();
         form.append("client_id","knows");
         form.append("client_secret","123456");
         form.append("grant_type","password");
         form.append("username",this.username);
         form.append("password",this.password);
         axios({
           url:"http://localhost:9000/oauth/token",
           method:"post",
           data:form
        }).then(function(response){
   /*输出json字符串中access_token里面的内容---token令牌*/
           alert(response.data.access_token);
        })
      }
    }
  })
 </script
</body
</html>

重启服务进行测试,进行登录,存在跨域问题:

2.4 Oauth2授权服务器实现跨域

  我们编写的auth模块和之前的sys / faq模块的跨域复杂度不同,faq / sys模块是简单的基本跨域,而Oauth的控制器路径其中的功能涉及到复杂跨域。开发时,我们需要对Oauth授权服务的跨域做特殊配置,我们建议的解决方案是业界比较流行的过滤器。

转到auth模块:创建filter包,包中新建CorsFilter类(事先准备好的)

 public class CorsFilter implements Filter {
     public static final String ACCESS_CONTROL_REQUEST_METHOD = "Access-Control-Request-Method";
 
     public static final String OPTIONS = "OPTIONS";
 
     public void doFilter(ServletRequest request, ServletResponse response,
                          FilterChain chain) throws IOException, ServletException {
 
         HttpServletRequest httpRequest = (HttpServletRequest) request;
         HttpServletResponse httpResponse = (HttpServletResponse) response;
 
         if (isCorsRequest(httpRequest)) {
             httpResponse.setHeader("Access-Control-Allow-Origin", "*");
             httpResponse.setHeader("Access-Control-Allow-Methods",
                     "POST, GET, PUT, DELETE");
             httpResponse.setHeader("Access-Control-Allow-Credentials", "true");
             // response.setIntHeader("Access-Control-Max-Age", 1728000);
             httpResponse
                    .setHeader(
                             "Access-Control-Allow-Headers",
                             "Origin, X-Requested-With, Content-Type, Accept, Accept-Encoding, Authorization");
             if (isPreFlightRequest(httpRequest)) {
                 return;
            }
        }
         chain.doFilter(request, response);
    }
 
     public void init(FilterConfig filterConfig) {
    }
 
     public void destroy() {
    }
 
     public boolean isCorsRequest(HttpServletRequest request) {
         return (request.getHeader(HttpHeaders.ORIGIN) != null);
    }
     public boolean isPreFlightRequest(HttpServletRequest request) {
         return (isCorsRequest(request) && OPTIONS.equals(request.getMethod()) && request
                .getHeader(ACCESS_CONTROL_REQUEST_METHOD) != null);
    }
 }

  过滤器在Vrd项目中使用过,大概作用就是在请求到达服务器目标之前,可以先由过滤器进行处理,处理之后才会运行目标,跨域设置就会在过滤器中设置完成,在运行具体方法前允许跨域。和SpringMvc配置不同,我们需要手动编写注册过滤器的代码,在SpringBoot启动类中添加如下代码,过滤器才会生效:

 //注册过滤器
 @Bean
 public FilterRegistrationBean registrationBean(){
     FilterRegistrationBean<CorsFilter> bean= new FilterRegistrationBean<>();
     // 设置哪些路径要经过滤器的处理
     bean.addUrlPatterns("/*");// 所有路径都需要过滤
     bean.setOrder(Ordered.HIGHEST_PRECEDENCE);// 设置优先级最高
     bean.setFilter(new CorsFilter());// 设置过滤器对象
     return bean;//返回设置到Spring容器
 }

  重启相关项目(auth),再次发送请求,观察变化正常情况下,alert出用户的Jwt令牌。

2.5 将Jwt令牌保存到客户端

  到此为止,授权服务器已经能够正常提供服务器了,将我们从授权服务器获得的Jwt保存到客户端,对login.html的vue代码修改:

 .then(function(response){
   //alert(response.data.access_token);
   console.log(response.data.access_token);
   //将我们获得的Jwt保存到浏览器(客户端)
   //浏览器保存信息的方式有两种:
   //1.cookie
   //2.localStorage(简单)
   //我们选择将Jwt保存到localStorage
   window.localStorage.setItem("accessToken",response.data.access_token);
   //能运行到这里就是登录成功了!
   //登录成功,跳转到index.html判断身份跳转首页
   location.href="/index.html"
 }).catch(function(error){
   //登录失败运行这里,输出错误信息
   console.log(error);
 })

  代码中还包含了输出登录失败错误信息和登录成功跳转页面的代码。

2.6 根据不同身份跳转不同页面

  上面的步骤中,登录成功跳转到index.html页面(还没有),我们先创建这个页面,在这个页面中编写页面加载完毕之后立即运行的axios方法,具体代码如下:

 <!DOCTYPE html>
 <html lang="en">
 <head>
     <meta charset="UTF-8">
     <title>Title</title>
     <script src="bower_components/jquery/dist/jquery.min.js"></script>
     <script src="bower_components/bootstrap/dist/js/bootstrap.min.js"></script>
     <script src="bower_components/vue/dist/vue.js"></script>
     <!--引入axios框架-->
     <script src="https://cdn.bootcdn.net/ajax/libs/axios/0.21.1/axios.min.js">
     </script>
 </head>
 <body>
 </body>
 <script>
     // jquery提供的,页面加载完毕立即运行的方法
     $(function(){
         // 这个方法一定是携带令牌发送请求给判断用户身份的控制器
         // 下面测试是否能够获得令牌:localStorage中获得Jwt
         let token=window.localStorage.getItem("accessToken");
         console.log(token);
    })
 </script>
 </html>

  重启服务,进行测试登录,登录到index首页:

转到knows-portal项目:将portal项目中的HomeController复制到knows-sys模块的controller包中

代码修改如下:

 // ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
 @RestController
 @RequestMapping("/v1/home")
 public class HomeController {
     //Spring-Security框架判断资格时,需要使用下面的类型常量
     public static final GrantedAuthority STUDENT=
             new SimpleGrantedAuthority("ROLE_STUDENT");
     public static final GrantedAuthority TEACHER=
             new SimpleGrantedAuthority("ROLE_TEACHER");
 
     // /v1/home 访问这个方法
     // ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
     @GetMapping
     public String index(@AuthenticationPrincipal UserDetails user){
         //判断当前登录用户是否包含讲师身份
         if(user.getAuthorities().contains(TEACHER)){
             //跳转到讲师首页 重定向到讲师首页
             // ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
             return "/index_teacher.html";
        }else if(user.getAuthorities().contains(STUDENT)){
             //如果是学生 重定向到学生首页
             // ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
             return "/index_student.html";
        }
         //既不是学生又不是讲师,去登录
         // ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
         return "/login.html";
    }
 }

 

3 Spring Mvc 拦截器

  上面的代码完成了大概,但是没有编写Jwt解析的过程,Jwt解析过程还是比较复杂的。如果每个控制器接收到Jwt都编写解析代码,就会比较冗余,我们可以通过拦截器实现所有控制器运行之前执行统一代码的效果。

3.1 什么是拦截器?

  拦截器是由SpringMvc框架提供的一个功能,是可以在控制器方法运行之前\之后等特定时间节点运行指定代码的接口。

常见面试题 : 过滤器和拦截器的区别

和过滤器的区别主要有如下几点:

  • 提供者不同 :

    • 过滤器是javaEE提供的

    • 拦截器是SpringMvc框架提供的

  • 过滤目标不同:

    • 过滤器可以处理任何到当前服务器的请求

    • 拦截器只能处理到控制器的请求

  • 功能方面:

    • 过滤器依靠原生javaEE操作,功能较弱,和Spring框架无关,不能直接处理Spring容器中的内容;

    • 拦截器依赖SpringMvc操作,功能强,关联Spring框架,可以直接操作Spring容器中的内容,拦截器功能更强。

拦截器工作流程图:

3.2 拦截器基本使用

拦截器使用两个步骤:

  1. 创建拦截器类

  1. 注册拦截器类

转到sys模块:创建一个包interceptor,包中创建一个类DemoInterceptor

 package cn.tedu.knows.sys.interceptor;
 
 import org.springframework.stereotype.Component;
 import org.springframework.web.servlet.HandlerInterceptor;
 import org.springframework.web.servlet.ModelAndView;
 
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
 @Component //编写这个注解,将对象保存到Spring容器中
 public class DemoInterceptor implements HandlerInterceptor {//实现HandlerInterceptor接口
     //Ctrl+O 重写所有方法
     @Override
     public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
         System.out.println("preHandle");
         // 这个返回值表示是否运行目标控制器方法
         // 返回true表示放行,返回false表示阻止
         return true;
    }
 
     @Override
     public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
         System.out.println("postHandle");
    }
 
     @Override
     public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
         System.out.println("afterCompletion");
    }
 }
 

  上面的代码编写完毕之后,要想真正生效,还要注册到SpringMvc。我们之前在sys模块编写的SpringMvc配置跨域的WebConfig类(security包),就在这个类中编写拦截器的配置,代码如下:

 //配置拦截器
 @Autowired
 private DemoInterceptor demoInterceptor;
 
 //设置拦截器生效路径
 @Override
 public void addInterceptors(InterceptorRegistry registry) {
     registry.addInterceptor(demoInterceptor)
             //设置哪些路径经过这个拦截器
            .addPathPatterns("/v1/auth/demo");
 }

  重启sys,测试访问/v1/auth/demo,观察Idea控制台输出信息:

  IDEA控制台:

3.3 拦截器解析JWT

  我们已经学习的拦截器的使用,知道了它的preHandle方法会在控制器运行之前运行,所以在这个方法中我们解析Jwt,并保存在Spring-Security中。

转到Sys模块:sys模块需要使用Ribbon请求auth模块的功能,SpringBoot启动类中添加Ribbon的支持:

 //添加Ribbon支持
 @Bean
 @LoadBalanced
 public RestTemplate restTemplate(){
     return new RestTemplate();
 }

下面编写拦截器,拦截器中编写具体解析代码,AuthInterceptor代码如下:

 @Component
 public class AuthInterceptor implements HandlerInterceptor {
 
     @Autowired
     private RestTemplate restTemplate;
 
     @Override
     public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
         //我们要获得用户发送过来的Jwt
         // 拦截器中获得用户请求中的信息代码如下:
         String token=request.getParameter("accessToken");//从请求中获取参数,此处获得jwt
         // 利用auth授权服务器的功能,完成解析jwt的工作
         String url="http://auth-service/oauth/check_token?token={1}";
         Map<String,Object> map=restTemplate.getForObject(url, Map.class,token);
         // 根据我们观察的返回结果,我们需要的数据是:
         // 获得当前登录用户的用户名: map.get("user_name")
         // 获得当前用户所有权限和角色的: map.get("authorities")
         // 因为因为权限和角色是一个数组,在现在的情况下被解析为一个List接收
         String username=map.get("user_name").toString();
         List<String> list=(List<String>)map.get("authorities");
         //获得了用户必要的信息,下面要将这些信息保存到Spring-Security中
         // 而要通过编码方式保存,需要遵守固定格式
         String[] auth=list.toArray(new String[0]);//list转为array,里面要写类型,指定长度
         UserDetails userDetails= User.builder()
                .username(username)
                .password("")
                .authorities(auth)
                .build();
         // 上面代码获得了一个UserDetails类型对象
         // 我们要将它保存到Spring-Security框架中,
         // 以便@AuthenticationPrincipal注解获得
         // Spring-Security框架给了固定方式实现这个目标
         PreAuthenticatedAuthenticationToken authenticationToken=
                 new PreAuthenticatedAuthenticationToken(
                         userDetails,
            userDetails.getPassword(),
                         AuthorityUtils.createAuthorityList(auth)
                );
         // 由于我们是要将这个用户保存到Spring-Security,所以需要关联当前请求
         authenticationToken.setDetails(new WebAuthenticationDetails(request));
         // 将当前保存用户信息的对象添加到Spring-Security中
         SecurityContextHolder.getContext().setAuthentication(authenticationToken);
         // 千万别忘了将返回值改为true!!!!!
         return true;
    }
 }

拦截器要想生效必须在SpringMvc配置中注册,WebConfig类中添加配置:

 @Autowired
 private DemoInterceptor demoInterceptor;
 //↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
 @Autowired
 private AuthInterceptor authInterceptor;
 //设置拦截器生效路径
 @Override
 public void addInterceptors(InterceptorRegistry registry) {
     registry.addInterceptor(demoInterceptor)
             //这个哪些路径经过这个拦截器
            .addPathPatterns("/v1/auth/demo");
     //↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
     registry.addInterceptor(authInterceptor)
            .addPathPatterns("/v1/home");
 }

index.html页面发送axios请求,并携带Jwt令牌。

转到client前端项目:index.html的js代码修改为:

 // jquery提供的,页面加载完毕立即运行的方法
 $(function(){
     // 这个方法一定是携带令牌发送请求给判断用户身份的控制器
     // 下面测试是否能够获得令牌:localStorage中获得Jwt
     let token=window.localStorage.getItem("accessToken");
     console.log(token);
     // axios请求,根据当前用户信息获得要跳转的首页路径
     axios({
         url:"http://localhost:9000/v1/home",
         method:"get",
         params:{//get对应params、post对应data
             "accessToken":token
        }
    }).then(function(response){
         // 控制器响应了要跳转的页面,直接跳转即可
         location.href=response.data;
    })
 })

  重启sys和client项目,进行登录,检查是否能够按身份跳转不同页面(事先将user表st2、tc2密码中的{bcrypt}去掉才可以):

  (1)学生首页

  (2)讲师首页

 

4 迁移问答功能模块

  上面为止,我们实现了单点登录,下面开始迁移达内知道的问答功能,转到faq模块

4.1 迁移业务逻辑层

  将knows-portal项目下的IQuestionService、QuestionServiceImpl、QuestionVo复制到knows-faq中的相应位置:

  复制QuestionVo到faq,没有vo包创建即可,不报错;

  复制IQuestionService到faq,导包即可

  复制业务逻辑层实现类QuestionServiceImpl:

  这个类复制过来是报错的,而且只能导包解决一部分。在保证当前faq模块由RestTemplate对象注入的前提下,在QuestionServiceImpl类中编写一个方法,使用Ribbon根据用户名获得用户对象,最终修改代码如下:

 @Service
 @Slf4j
 public class QuestionServiceImpl extends ServiceImpl<QuestionMapper, Question> implements IQuestionService {
 
     @Autowired
     private QuestionMapper questionMapper;
 
     @Override
     public PageInfo<Question> getMyQuestions(
             String username,Integer pageNum,Integer pageSize) {
         //1.通过用户名查询用户信息
         //↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
         User user=getUser(username);
         
         //2.根据当前登录用户的id查询问题
         QueryWrapper<Question> query=new QueryWrapper<>();
         query.eq("user_id",user.getId());
         query.eq("delete_status",0);
         query.orderByDesc("createtime");
         //查询执行之前使用PageHelper对象进行分页设置
         //第一个参数是页码,第二个参数是每页最大条数
         //这个代码一旦编写,会在下一次进行的查询时,自动添加limit关键字,以及具体的截取区间
         PageHelper.startPage(pageNum,pageSize);
         List<Question> list=questionMapper.selectList(query);
         
         //3.将查询到的当前问题的所有标签获得
         for(Question question : list){
             List<Tag> tags=tagName2Tags(question.getTagNames());
             question.setTags(tags);
        }
         //4.返回查询到的问题
         log.debug("当前用户问题数量:{}", list.size());
         //千万别忘了返回!!!
         return new PageInfo<>(list);
    }
 
     @Autowired
     private ITagService tagService;
     
     //编写一个方法,根据tag_names的值获得一个对应的List<Tag>集合
     private List<Tag> tagName2Tags(String tagNames){
         //tagNames : "Java基础,Java SE,面试题"
         String[] names=tagNames.split(",");
         // names:   {"Java基础","Java SE","面试题"}
         //准备包含所有标签的Map
         Map<String,Tag> tagMap=tagService.getTagMap();
         //声明List<Tag> 用于接收标签名称对应的标签对象
         List<Tag> tags=new ArrayList<>();
         //遍历数组,将数组元素对应的标签对象添加到tags中
         for(String name:names){
             tags.add(tagMap.get(name));
        }
         //别忘了返回
         return tags;
    }
 
     @Autowired
     private QuestionTagMapper questionTagMapper;
     
     @Autowired
     private UserQuestionMapper userQuestionMapper;
     
     //新增用户发布的问题
     @Override
     //@Transactional标记上业务逻辑层的方法上
     //SpringBoot封装的功能,将下面方法定义为一个事务
     //这个方法中的所有对数据库的操作要么都执行,要么都回滚
     @Transactional
     public void saveQuestion(QuestionVo questionVo, String username) {
         //1.根据用户名查询用户信息
         //↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
         User user=getUser(username);
         
         //2.根据学生选中的标签构建tag_names列的值
         StringBuilder builder=new StringBuilder();
         //{"java基础","javaSE","面试题"}
         for(String tagName : questionVo.getTagNames()){
             builder.append(tagName).append(",");
        }
         String tagNames=builder
                .deleteCharAt(builder.length()-1).toString();
         //"java基础,javaSE,面试题,"
         
         //3.实例化Question对象并赋值
         Question question=new Question()
                .setTitle(questionVo.getTitle())
                .setContent(questionVo.getContent())
                .setUserNickName(user.getNickname())
                .setUserId(user.getId())
                .setCreatetime(LocalDateTime.now())
                .setStatus(0)
                .setPageViews(0)
                .setPublicStatus(0)
                .setDeleteStatus(0)
                .setTagNames(tagNames);
         
         //4.执行新增Question
         int num=questionMapper.insert(question);
         if(num!=1){
             throw new ServiceException("服务器忙!");
        }
         
         //5.新增Question和tag的关系
         //获得包含所有标签的Map
         Map<String,Tag> tagMap=tagService.getTagMap();
         for(String tagName : questionVo.getTagNames()){
             Tag t=tagMap.get(tagName);
             QuestionTag questionTag=new QuestionTag()
                    .setQuestionId(question.getId())
                    .setTagId(t.getId());
             num=questionTagMapper.insert(questionTag);
             if(num!=1){
                 throw new ServiceException("服务器忙");
            }
             log.debug("新增了问题和标签的关系:{}",questionTag);
        }
 
         //6.新增User(讲师)和Question的关系
         //↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
         //通过Ribbon获得所有讲师的数组
         String url="http://sys-service/v1/users/master";
         User[] users=restTemplate.getForObject(url,User[].class);
         //实例化讲师集合
         Map<String,User> teacherMap=new HashMap<>();
         //编辑所有讲师数组,为map赋值
         for(User u:users){
             teacherMap.put(u.getNickname(),u);//注意是昵称不是用户名
        }
         // ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑
         //遍历老师名称数组
         for(String nickname:questionVo.getTeacherNicknames()){//注意是老师昵称
             //根据老师昵称获得老师对象
             User teacher = teacherMap.get(nickname);
             //实例化UserQuestion对象,并赋值
             UserQuestion userQuestion = new UserQuestion()
                    .setQuestionId(question.getId()) //问题id 
                  .setUserId(teacher.getId()) //注意是老师id,不是userid 
                  .setCreatetime(LocalDateTime.now()); 
           num = userQuestionMapper.insert(userQuestion); 
           if(num!=1){ 
               throw new ServiceException("数据库异常"); 
          } 
           log.debug("新增了关系:{}",userQuestion); 
      } 
  } 
​ 
   //根据用户id查询用户的问题数的逻辑层实现 
   @Override 
   public Integer countQuestionsByUserId(Integer userId) { 
       QueryWrapper<Question> query=new QueryWrapper<>(); 
       query.eq("user_id",userId); 
       query.eq("delete_status",0); 
       Integer count=questionMapper.selectCount(query); 
       return count; 
  } 
​ 
   //根据用户名(讲师)查询讲师问题列表的逻辑层实现 
   @Override 
   public PageInfo<Question> getTeacherQuestions(String username, Integer pageNum, Integer pageSize) { 
       //↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ 
       User user=getUser(username); 
       //设置分页 
       PageHelper.startPage(pageNum,pageSize); 
       List<Question> questions= questionMapper.findTeacherQuestions(user.getId()); 
       //将查询出的问题包含的标签赋值 
       for(Question question: questions){ 
           List<Tag> tags=tagName2Tags(question.getTagNames()); 
           question.setTags(tags); 
      } 
       //别忘了返回!!!! 
       return new PageInfo<>(questions); 
  } 
​ 
   // 根据问题id返回问题详情 
   @Override 
   public Question getQuestionById(Integer id) { 
       //按id查询Question 
       Question question = questionMapper.selectById(id); 
       //获得当前问题对应的所有标签对象的集合 
       List<Tag> tags = tagNamesToTags(question.getTagNames()); 
       //将标签集合赋值到当前question对象的tags属性 
       question.setTags(tags); 
       //千万别忘了返回!!!! 
       return question; 
  } 
​ 
   //↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ 
   @Autowired 
   private RestTemplate restTemplate; 
    
   //根据用户名获得用户对象 
   public User getUser(String username){ 
       //使用Ribbon实现根据用户名获得用户对象 
       String url="http://sys-service/v1/auth/user?username={1}"; 
       User user=restTemplate.getForObject(url,User.class,username); 
       return user; 
  } 
}

4.2 迁移控制层

  将knows-portal项目中的QuestionController复制到knows-faq项目对应路径下:

  处理导包即可,修改请求路径:

 @RequestMapping("/v2/questions")

4.3 配置拦截器

  将sys模块的拦截器AuthInterceptor复制到faq模块,并注册:

在security包中的WebConfig类中添加如下代码,完成拦截器注册:

 //配置拦截器,实现拦截器注册
 @Autowired
 private AuthInterceptor authInterceptor;
 
 @Override
 public void addInterceptors(InterceptorRegistry registry) {
     registry.addInterceptor(authInterceptor)
            .addPathPatterns(
                     "/v2/questions",        //学生发布问题
                     "/v2/questions/my",     //学生首页问题列表
                     "/v2/questions/teacher" //讲师首页问题列表
            );
 }

4.4 迁移页面内容

  转到client项目:上面所有需要当前用户信息的控制器都需要页面的axios请求发送当前用户Jwt,这样很多方法都需要编写从localStorage中获得Jwt的代码。为了减少冗余,我们在utils.js文件中获得一次,其它js文件需要时直接获取即可,在utils.js文件中添加代码:

 //获得Jwt
 let token=window.localStorage.getItem("accessToken");

下面就可以在学生首页和讲师首页的axios请求中使用它了:

index.js

 axios({
     url: 'http://localhost:9000/v2/questions/my',
     method: "GET",
     params:{
         pageNum:pageNum,
         accessToken:token
    }
 })

index_teacher.js

 axios({
     url: 'http://localhost:9000/v2/questions/teacher',
     method: "GET",
     params:{
         pageNum:pageNum,
         accessToken:token
    }
 })

  重启服务,就可以在学生和讲师首页加载问题列表了:

  (1)学生首页

  (2)讲师首页

 

在createQuestion.js修改一系列js代码,主要是axios请求路径:

 let form =new FormData();
 form.append("title",this.title);
 form.append("tagNames",this.selectedTags);
 form.append("teacherNicknames",this.selectedTeachers);
 form.append("content",content);
 //添加Jwt到请求 ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
 form.append("accessToken",token);
 console.log(form);
 axios({
     //   ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
     url:'http://localhost:9000/v2/questions',
     method:'POST',
     data:form
 })

加载所有标签:

 axios({
     url:'http://localhost:9000/v2/tags',
     method: 'GET'
 })

加载所有讲师:

 axios({
     url:'http://localhost:9000/v1/users/master',
     method: 'GET'
 })

4.5 迁移用户信息面板

  显示用户信息面板属于用户管理模块的功能,所以在sys模块中编写。但是这个代码中涉及根据用户id查询用户问题数的功能(查询收藏数自己做),我们需要在faq模块中编写一个根据用户id查询问题数的Rest接口(控制器方法),

转到faq模块:

  在QuestionController类中编写代码如下:

 // 根据用户id查询用户的问题数
 @GetMapping("/count")
 public Integer count(Integer userId){
     return questionService.countQuestionsByUserId(userId);
 }

转回sys模块:

  faq模块准备好了方法,在sys模块中调用service包中的UserServiceImpl类的getUserVo方法上做修改:

 //根据用户名查询用户信息面板
 @Autowired
 private RestTemplate restTemplate;
 
 @Override
 public UserVo getUserVo(String username) {
     // 根据用户名获得UserVo对象
     UserVo userVo=userMapper.findUserVoByUsername(username);
     // 查询当前登录用户的问题数
     String url= "http://faq-service/v2/questions/count?userId={1}";
     Integer count=restTemplate.getForObject(url,Integer.class,userVo.getId());
     // 为userVo对象的问题数属性赋值
     userVo.setQuestions(count);
     // 查询当前登录用户的收藏数(未完成)
     // 为userVo对象的收藏数属性赋值(未完成)
     return userVo;
 }

  在security的WebConfig配置类中添加拦截器拦截的路径,通过解析该路径获得token,进而获得用户信息:

 registry.addInterceptor(authInterceptor)
        .addPathPatterns(
    //设置哪些路径经过这个拦截器
                 "/v1/home",     //跳转不同首页
                 "/v1/users/me"  //根据当前登录用户查询用户的面板信息
        );

  sys模块修改完成。

转到前端client项目:

  user_info.js文件中的axios路径修改:

 axios({
     url:"http://localhost:9000/v1/users/me",
     method:"get",
     params:{
         accessToken:token
    }
 })

  重启faq、sys、client服务,登录观察用户信息面板(学生、讲师),不显示时进行按F12后,进行清缓存处理,如果均能加载出用户信息面板表示修改成功。

4.6 迁移文件上传功能

  文件上传功能隶属knows-resource模块,我们只需要在这个模块中创建一个控制器类管理上传即可。

创建controller包,包中创建一个ImageController类,代码如下:

 @RestController
 @Slf4j
 // 下面这个注解是表示当前类中所有方法都支持跨域访问的注解
 // 相比配置文件更简单,但是如果类比较多,每个类都要写
 // @CrossOrigin 跨域注解,SpringMVC提供的功能
 @CrossOrigin
 @RequestMapping("/file")//注意此处路径不要加/image了,之前已经在网关中配置好了
 public class ImageController {
     // 这个属性的值会从application.properties文件中获得
     // 获得的是knows.resource.path对应的值
     @Value("${knows.resource.path}")
     private File resourcePath;
 
     @Value("${knows.resource.host}")
     private String resourceHost;
 
     //上传图片
     @PostMapping
     public String uploadImage(MultipartFile imageFile) throws IOException {
         // 按日期创建文件夹的路径
         String path = DateTimeFormatter.ofPattern("yyyy/MM/dd")
                .format(LocalDate.now());
         // path: 2021/08/31
         File folder=new File(resourcePath,path);
         // folder: F:/upload/2021/08/31
         folder.mkdirs(); //mkdirsssssssssssssss!!!!!
         log.debug("上传的文件夹为:{}",folder.getAbsolutePath());
         //获得用户上传文件的原始后缀
         // a.gif     ->   uuid.gif
         // 01234
         String fileName=imageFile.getOriginalFilename();
         String ext=fileName.substring(fileName.lastIndexOf("."));
         // ext: .gif
         //生成uuid确定文件名称
         String name= UUID.randomUUID().toString()+ext;
         // name: jkha-sdjf-haj-sdhfj.gif
         File file=new File(folder,name);
         // F:/upload/2021/08/31/jkha-sdjf-haj-sdhfj.gif
         log.debug("最终上传的路径:{}",file.getAbsolutePath());
         imageFile.transferTo(file);
 
         // 拼接可以访问刚刚上传图片的url
         // http://localhost:8899/2021/08/31/jkha-sdjf-haj-sdhfj.gif
         String url=resourceHost+"/"+path+"/"+name;
         return url;
    }
 }

  当前类需要配置才能运行,knows-resource的application.properties文件中添加配置 :

 knows.resource.path=file:F:/upload
 knows.resource.host=http://localhost:9000/image

  加载资源时同样存在跨域问题,为了成功跨域,将sys模块或faq模块的WebConfig类复制过来,删除不需要的内容(拦截器注册代码删除),也可以直接在ImageController类中直接添加@CrossOrigin注解替代。

 //使用SpringMvc框架功能配置跨域
 @Configuration
 public class WebConfig implements WebMvcConfigurer {
     @Override
     public void addCorsMappings(CorsRegistry registry) {
             registry.addMapping("/**")
                    .allowedOrigins("*")
                    .allowedMethods("*")
                    .allowedHeaders("*");
    }
 }

转到client项目:

  在static/question/create.html页面最后编写的上传文件的js代码上修改路径 :

 axios({
   url:"http://localhost:9000/image/file",
   method:"post",
   data:form
 })

  重启resource模块和前端client项目,测试上传功能,发布问题后查看数据库是否添加了对应信息。

4.7 迁移问题详情页

  我们还差最后一个问题详情页功能没有迁移,主要是answer和comment相关的功能,转到Faq模块

4.7.1 迁移数据访问层

  复制knows-portal项目下的AnswerMapper、CommentMapper、AnswerMapper.xml到knows-faq对应路径下:

  两个Mapper接口直接重新导包即可,但是AnswerMapper.xml文件中需要修改,而且需要Rebuild!!!!

  修改后代码如下:

 <mapper namespace="cn.tedu.knows.faq.mapper.AnswerMapper">
  <!-- ↑↑↑↑↑↑↑   -->
   
     <resultMap id="answerCommentMap" type="cn.tedu.knows.commons.model.Answer">
                                                    <!-- ↑↑↑↑↑↑↑   -->
         <id column="id" property="id" />
         <result column="content" property="content" />
         <result column="like_count" property="likeCount" />
         <result column="user_id" property="userId" />
         <result column="user_nick_name" property="userNickName" />
         <result column="quest_id" property="questId" />
         <result column="createtime" property="createtime" />
         <result column="accept_status" property="acceptStatus" />
         <collection property="comments" ofType="cn.tedu.knows.commons.model.Comment">
                                                         <!-- ↑↑↑↑↑↑↑   -->
             <id column="comment_id" property="id"></id>
             <result column="comment_user_id" property="userId"></result>
             <result column="comment_user_nick_name" property="userNickName"></result>
             <result column="comment_answer_id" property="answerId"></result>
             <result column="comment_content" property="content"></result>
             <result column="comment_createtime" property="createtime"></result>
         </collection>
     </resultMap>
     
     <!--
         id属性和AnswerMapper接口中的方法对应,和方法名一致
         resultMap表示当前方法的返回值类型,和上面定义的<resultMap>的id一致
      -->
     <select id="findAnswersByQuestionId" resultMap="answerCommentMap">
        SELECT
            a.id,
            a.content,
            a.like_count,
            a.user_id,
            a.user_nick_name,
            a.quest_id,
            a.createtime,
            a.accept_status,
            c.id comment_id,
            c.user_id comment_user_id,
            c.answer_id comment_answer_id,
            c.user_nick_name   comment_user_nick_name,
            c.content comment_content,
            c.createtime comment_createtime
        FROM answer a LEFT JOIN comment c
        ON a.id=c.answer_id
        WHERE a.quest_id=#{id}
        ORDER BY a.createtime
     </select>
 </mapper>

4.7.2 迁移业务逻辑层

  先从knows-portal项目复制vo类AnswerVo、CommentVo,再复制接口IAnswerService、ICommentService,最后复制实现类AnswerServiceImpl、CommentServiceImpl:

  vo类复制过来不用动

  接口复制过来需要重新导包

  实现类复制过来,除了导包,还需要Ribbon调用,根据用户名获得用户信息的方法,但是两个类都需要这个功能,都写就冗余了,所以新建一个类RibbonClient,类中写一个方法根据用户名获得用户信息,业务逻辑层实现类需要时调用,代码如下:

 @Component
 public class RibbonClient {
  //获取Ribbon对象,进行模块间调用业务
     @Autowired
     private RestTemplate restTemplate;
     
     //根据用户名获得用户对象
     public User getUser(String username){
         String url="http://sys-service/v1/auth/user?username={1}";
         User user=restTemplate.getForObject(url,User.class,username);
         return user;
    }
 }

  哪里需要使用上面的方法获得用户对象,就在哪个位置编写上面方法的调用,先添加注入:

 @Resource
 private RibbonClient ribbonClient;

  在需要的行直接调用getUser方法:

 User user=ribbonClient.getUser(username);

4.7.3 迁移控制层

  将knows-portal项目中AnswerController、CommentController复制到knows-faq项目对应路径下:

  处理编译错误(都是导包问题),别忘了将路由特征值修改为v2:

  AnswerController:

 @RequestMapping("/v2/answers")

  CommentController:

 @RequestMapping("/v2/comments")

  控制层代码处理完毕。

4.7.4 注册拦截器拦截路径

  在faq模块security包的WebConfig类中添加上述需要用户信息的控制器路径配置,拦截路径后解析token,获得用户信息:

 @Override
 public void addInterceptors(InterceptorRegistry registry) {
     registry.addInterceptor(authInterceptor)
            .addPathPatterns(
                     "/v2/questions",        //学生发布问题
                     "/v2/questions/my",     //学生首页问题列表
                     "/v2/questions/teacher",//讲师首页问题列表
                     "/v2/answers",          //新增回答
                     "/v2/comments",         //新增评论
                     "/v2/comments/*/delete",  // 删除评论
                     "/v2/comments/*/update",  // 修改评论
                     "/v2/answers/*/solved"   // 采纳回答
            );
 }

4.7.4 前端代码修改

转到client项目:

  js文件夹中找到question_detail.js文件,修改axios的路径和Jwt令牌发送:

  loadQuestion方法:

 axios({//此处不用添加jwt令牌,因为是加载问题,与用户信息无关
     url:"http://localhost:9000/v2/questions/"+qid,
     method:"get"
 })

  postAnswer方法:

 let form=new FormData();
 form.append("questionId",qid)
 form.append("content",content);
 form.append("accessToken",token);//添加jwt
 axios({
     url:"http://localhost:9000/v2/answers",
     method:"post",
     data:form
 })

  loadAnswers方法:

 axios({//此处不用添加jwt令牌,因为是加载回答,与用户信息无关
     url:"http://localhost:9000/v2/answers/question/"+qid,
     method:"get"
 })

  postComment方法:

 let form=new FormData();
 form.append("answerId",answerId);
 form.append("content",content);
 form.append("accessToken",token);//添加jwt
 axios({
     url:"http://localhost:9000/v2/comments",
     method:"post",
     data:form
 })

  removeComment方法:

 axios({
     url:"http://localhost:9000/v2/comments/"+commentId+"/delete",
     method:"get",
     params:{
         accessToken:token  //添加jwt
    }
 })

  updateComment方法:

 let form=new FormData();
 form.append("answerId",answerId);
 form.append("content",content);
 form.append("accessToken",token); //添加jwt
 axios({
     url:"http://localhost:9000/v2/comments/"+commentId+"/update",
     method:"post",
     data:form
 })

  answerSolved方法:

 axios({
     url:"http://localhost:9000/v2/answers/"+answerId+"/solved",
     method:"get",
     params:{
         accessToken:token //添加jwt
    }
 })

  重启faq模块和client项目,测试上述问题详情的功能。

 

posted @ 2021-09-11 21:10  Coder_Cui  阅读(164)  评论(0编辑  收藏  举报