• 社区系统项目复盘-7



    使用Spring Security进行权限控制,对登录检查功能进行了重写。对不同的登录账号授予不同的权限,实现了置顶、加精、删除功能。使用Redis高级数据类型HyperLogLog和Bitmap实现了网站UV(独立访客)和DAU(日活跃用户)数据的统计。

    Spring Security

    • 简介:Spring Security是一个专注于为Java应用程序提供身份认证和授权的框架,它的强大之处在于它可以轻松扩展以满足自定义的需求。

    • Spring Security底层是基于Filter实现的,Filter与 DispatcherServlet的关系类似于Interceptor与Controller的关系。

      https://res.craft.do/user/full/fd148a50-4a5b-9a85-bec3-e1645571e2c7/doc/943A9F07-3719-43B2-8785-FD3439CD0658/3B033DBC-6082-41F2-B78A-FF10FCF38070_2/QyH59d1fppA6yfAdoEgdJlGHBwxWFc8Sv3wx0dcMhSwz/Image.png

    • 特征:

      • 对身份的认证和授权提供全面的、可扩展的支持。
      • 防止各种攻击,如会话固定攻击、点击劫持、csrf攻击等。
      • 支持与Servlet API、Spring MVC等Web技术集成。
    • 使用(以登录验证为例)

      • 导入依赖spring-boot-starter-security,添加完该依赖以后,spring security就已经生效了,重启项目,再次打开首页,会自动跳转到一个登录页面,这个登录页面是Spring security自带的,我们可以用自己写的登录页面替换它。Spring security会自动生成一个用户和密码,用于默认登录页面的验证。

        <dependency>
        	<groupId>org.springframework.bootgroupId>
        	<artifactId>spring-boot-starter-securityartifactId>
        dependency>
        
        • 1
        • 2
        • 3
        • 4
      • 对实体类进行处理,让跟登录相关的实体类实现UserDetails接口中的一些方法,(isAccountNonExpired:判断账号是否过期;isAccountNonLocked:判断账号是否锁定;isCredentialsNonExpired:判断凭证是否过期;isEnabled:判断账号是否可用;getAuthorities:获得用户拥有的权限)

        // 示例
        public class User implements UserDetails {
        
            private int id;
            private String username;
            private String password;
            private String salt;
            private String email;
            private int type;
            private int status;
            private String activationCode;
            private String headerUrl;
            private Date createTime;
        
        	// 此处省略 get方法、set方法、toString方法
        
            // true : 账号未过期
            @Override
            public boolean isAccountNonExpired() {
                return true;
            }
        
            // true : 账号未锁定
            @Override
            public boolean isAccountNonLocked() {
                return true;
            }
        
            // true: 凭证未过期
            @Override
            public boolean isCredentialsNonExpired() {
                return true;
            }
        
            // true: 账号可用
            @Override
            public boolean isEnabled() {
                return true;
            }
        
            // 权限
            @Override
            public Collection<? extends GrantedAuthority> getAuthorities() {
                List<GrantedAuthority> list = new ArrayList<>();
                list.add(new GrantedAuthority() {
                    @Override
                    public String getAuthority() {
                        switch (type){
                            case 1:
                                return "ADMIN";
                            default:
                                return "USER";
                        }
                    }
                });
                return list;
            }
        }
        
        • 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
      • 将业务层的相关类实现UserDetailsService接口中的loadUserByUsername方法。

        // 示例
        @Service
        public class UserService implements UserDetailsService {
        
            @Autowired
            private UserMapper userMapper;
        
            public User findUserByName(String username) {
                return userMapper.selectByName(username);
            }
        
            @Override
            public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
                return this.findUserByName(username);
            }
        }
        
        • 1
        • 2
        • 3
        • 4
        • 5
        • 6
        • 7
        • 8
        • 9
        • 10
        • 11
        • 12
        • 13
        • 14
        • 15
        • 16
      • 配置SecurityConfig配置类,该类继承WebSecurityConfigurerAdapter,重写3个config方法。包括认证的逻辑(自定义认证规则),授权的逻辑(登录相关的配置,退出相关的配置,授权配置,增加Filter,添加记住我功能等)。

        // 示例
        @Configuration
        public class SecurityConfig extends WebSecurityConfigurerAdapter {
            @Autowired
            private UserService userService;
        
            @Override
            public void configure(WebSecurity web) throws Exception {
                // 忽略静态资源的访问
                web.ignoring().antMatchers("/resources/**");
            }
        
            // 认证的逻辑
            // AuthenticationManager:认证的核心接口。
            // AuthenticationManagerBuilder:用于构建AuthenticationManager对象的工具。
            // ProviderManager:AuthenticationManager接口的默认实现类。
            @Override
            protected void configure(AuthenticationManagerBuilder auth) throws Exception {
                // 内置的认证规则
                // auth.userDetailsService(userService).passwordEncoder(new Pbkdf2PasswordEncoder("12345"));
        
                // 自定义认证规则
                // AuthenticationProvider:ProviderManager持有一组AuthenticationProvider,每个AuthenticationProvider负责一种认证。
                // 委托模式:ProviderManager将认证委托给AuthenticationProvider。
                auth.authenticationProvider(new AuthenticationProvider() {
                    // Authentication:用于封装认证信息的接口,不同的实现类代表不同类型的认证信息。
                    @Override
                    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
                        String username = authentication.getName();
                        String password = (String) authentication.getCredentials();
        
                        User user = userService.findUserByName(username);
        
                        if(user == null){
                            throw new UsernameNotFoundException("账号不存在!");
                        }
        
                        password = CommunityUtil.md5(password+user.getSalt());
                        if(!user.getPassword().equals(password)){
                            throw new BadCredentialsException("密码不正确!");
                        }
        
                        // principal:认证的主要信息; credentials:证书; authorities:权限
                        return new UsernamePasswordAuthenticationToken(user,user.getPassword(),user.getAuthorities());
                    }
        
                    //当前的AuthenticationProvider支持哪种类型的认证。
                    @Override
                    public boolean supports(Class<?> aClass) {
                        // UsernamePasswordAuthenticationToken:Authentication接口的常用的实现类,账号密码认证。
                        return UsernamePasswordAuthenticationToken.class.equals(aClass);
                    }
                });
            }
        
            // 授权的逻辑
            @Override
            protected void configure(HttpSecurity http) throws Exception {
                // 登录相关的配置
                http.formLogin()
                        .loginPage("/loginpage")
                        .loginProcessingUrl("/login")
                        .successHandler(new AuthenticationSuccessHandler() {
                            @Override
                            public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
                                // 重定向
                                response.sendRedirect(request.getContextPath()+"/index");
                            }
                        })
                        .failureHandler(new AuthenticationFailureHandler() {
                            @Override
                            public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
                                // 转发
                                request.setAttribute("error",e.getMessage());
                                request.getRequestDispatcher("/loginpage").forward(request,response);
                            }
                        });
        
                // 退出相关配置
                http.logout()
                        .logoutUrl("/logout")
                        .logoutSuccessHandler(new LogoutSuccessHandler() {
                            @Override
                            public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
                                response.sendRedirect(request.getContextPath() + "/index");
                            }
                        });
        
                // 授权配置
                http.authorizeRequests()
                        .antMatchers("/letter").hasAnyAuthority("USER","ADMIN")
                        .antMatchers("/admin").hasAnyAuthority("ADMIN")
                        .and().exceptionHandling().accessDeniedPage("/denied");
        
                // 增加Filter,处理验证码
                http.addFilterBefore(new Filter() {
                    @Override
                    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
                        HttpServletRequest request = (HttpServletRequest) servletRequest;
                        HttpServletResponse response = (HttpServletResponse) servletResponse;
                        if(request.getServletPath().equals("/login")){
                            String verifyCode = request.getParameter("verifyCode");
                            System.out.println("验证码:" + verifyCode);
                            if(verifyCode == null || !verifyCode.equalsIgnoreCase("1234")){
                                request.setAttribute("error","验证码错误!");
                                request.getRequestDispatcher("/loginpage").forward(request,response);
                                return ;
                            }
                        }
                        // 让请求继续向下执行
                        filterChain.doFilter(request,response);
                    }
                }, UsernamePasswordAuthenticationFilter.class);
        
                // 记住我
                http.rememberMe()
                        .tokenRepository(new InMemoryTokenRepositoryImpl())
                        .tokenValiditySeconds(3600 * 24)
                        .userDetailsService(userService);
            }
        }
        
        • 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

        注意⚠️
        认证成功后,结果会通过SecurityContextHolder存入SecurityContext中,访问相关信息的时候,需要SecurityContextHolder。
        Security规定退出登录功能的请求方式必须是post。
        记住我功能前端代码部分name必须是 remember-me。

        重定向与转发示意图:
        https://res.craft.do/user/full/fd148a50-4a5b-9a85-bec3-e1645571e2c7/doc/943A9F07-3719-43B2-8785-FD3439CD0658/C96EB6FA-121D-4E74-B894-C89DBAA35665_2/U667fU79mftGtD6xOZRkdiJDN7exIBPAF6g4PQVWJHAz/Image.png

    权限控制

    • 登录检查:之前采用拦截器实现了登录检查,这是简单的权限管理方案,现将其废弃。

      实现:将WebMvcConfig配置类中跟登录检查相关的代码注释掉。

    • 授权配置:对当前系统内包含的所有的请求,分配访问权限(普通用户user,版主moderator,管理员admin)。

      @Configuration
      public class SecurityConfig extends WebSecurityConfigurerAdapter implements CommunityConstant {
          @Override
          public void configure(WebSecurity web) throws Exception {
              web.ignoring().antMatchers("/resources/**");
          }
      
          @Override
          protected void configure(HttpSecurity http) throws Exception {
              // 授权
              http.authorizeRequests()
                      .antMatchers(
                              "/user/setting",
                              "/user/upload",
                              "/discuss/add",
                              "/comment/add/**",
                              "/letter/**",
                              "/notice/**",
                              "/like",
                              "/follow",
                              "/unfollow"
                      ).hasAnyAuthority(
                              AUTHORITY_USER,
                              AUTHORITY_ADMIN,
                              AUTHORITY_MODERATOR
                      ).antMatchers(
                              "/discuss/top",
                              "/discuss/wonderful"
                      )
                      .hasAnyAuthority(
                              AUTHORITY_MODERATOR
                      ).antMatchers(
                              "/discuss/delete",
                              "/data/**",
                              "/actuator/**"
                      )
                      .hasAnyAuthority(
                              AUTHORITY_ADMIN
                      )
                      .anyRequest().permitAll()
                      .and().csrf().disable();
      
              // 权限不够时的处理
              http.exceptionHandling()
                      .authenticationEntryPoint(new AuthenticationEntryPoint() {
                          // 没有登录时的处理
                          @Override
                          public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
                              String xRequestedWith = request.getHeader("x-requested-with");
                              if("XMLHttpRequest".equals(xRequestedWith)){
                                  // 异步时的处理
                                  response.setContentType("application/plain;charset=utf-8");
                                  PrintWriter writer = response.getWriter();
                                  writer.write(CommunityUtil.getJSONString(403,"你还没有登录哦!"));
                              }else{
                                  response.sendRedirect(request.getContextPath() + "/login");
                              }
                          }
                      })
                      .accessDeniedHandler(new AccessDeniedHandler() {
                          // 权限不足时的处理
                          @Override
                          public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException {
                              String xRequestedWith = request.getHeader("x-requested-with");
                              if("XMLHttpRequest".equals(xRequestedWith)){
                                  // 异步时的处理
                                  response.setContentType("application/plain;charset=utf-8");
                                  PrintWriter writer = response.getWriter();
                                  writer.write(CommunityUtil.getJSONString(403,"你没有访问此功能的权限!"));
                              }else{
                                  response.sendRedirect(request.getContextPath() + "/denied");
                              }
                          }
                      });
      
              // Security底层默认会拦截/logout请求,进行退出处理.
              // 覆盖它默认的逻辑,才能执行我们自己的退出代码。
              http.logout().logoutUrl("/securitylogout");
          }
      }
      
      • 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
    • 认证方案:绕过Security认证流程,采用系统原来的认证方案。

      在UserService业务层代码中对user进行授权

      public Collection<? extends GrantedAuthority> getAuthorities(int userId){
          User user = this.findUserById(userId);
          List<GrantedAuthority> list = new ArrayList<>();
          list.add(new GrantedAuthority() {
              @Override
              public String getAuthority() {
                  switch (user.getType()){
                      case 1:
                          return AUTHORITY_ADMIN;
                      case 2:
                          return AUTHORITY_MODERATOR;
                      default:
                          return AUTHORITY_USER;
                  }
              }
          });
          return list;
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18

      因为绕过了Security的认证流程(Serucity认证时会将认证结果存入SecurityContext中),所要构建用户认证的结果,并存入SecurityContext,以便于Security进行授权。在检查登录凭证的拦截器preHandle()方法中处理这一块逻辑。

      // 构建用户认证的结果,并存入SecurityContext,以便于Security进行授权。
      Authentication authentication = new UsernamePasswordAuthenticationToken(
              user,user.getPassword(),userService.getAuthorities(user.getId()));
      SecurityContextHolder.setContext(new SecurityContextImpl(authentication));
      
      • 1
      • 2
      • 3
      • 4

      请求结束时,将SecurityContext中保存的权限进行清理,实现:在拦截器afterCompletion()方法以及退出登录时,删除用户认证结果。

      SecurityContextHolder.clearContext();
      
      • 1
    • CSRF配置:防止CSRF攻击的基本原理,以及表单、AJAX相关的配置。

      CSRF:Cross Site Request Forgery( 跨站请求伪造)

      **CSRF攻击:**大部分的网站应用都是采用cookie或session的方式进行登入验证,当通过登入验证之后,网站就会给你一个通行证存在cookie或seesion中,代表之后的动作中都不需要重复验证身份了。当在登录一个网站以后,中途去逛了其他网页,刚好遇到了恶意网页,窃取了cookies,那么它就可以凭借该身份访问网站,这就是CSRF攻击。

      防御CSRF的方式有两种,1.检查referer栏位,http协定中就有一个referer栏位记录着这个请求是从哪个网站发出来的,从而确认请求来源。2.加入验证token,这个token由服务端产生,加密存在session中,无法仿造。Spring Security就是采用的第二种方法。

      ⚠️:Spring Security默认防御CSRF攻击,但是对于异步请求不可以,因为异步请求根本不提交表单数据,因此,我们需要单独处理。

      发布帖子就是一个异步请求,以此为例:

    • 通过标签要求服务器把csrf凭证生成在header里,发请求的时候直接从header里取。

      
      <meta name="_csrf" th:content="${_csrf.token}">
      <meta name="_csrf_header" th:content="${_csrf.headerName}">
      
      • 1
      • 2
      • 3
    • 发送AJAX请求之前,将CSRF令牌设置到请求消息头中。

      // 发送AJAX请求之前,将CSRF令牌设置到请求消息头中.
      var token = $("meta[name='_csrf']").attr("content");
      var header = $("meta[name='_csrf_header']").attr("content");
      $(document).ajaxSend(function (e,xhr,options){
      	xhr.setRequestHeader(header,token);
      });
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6

      当Spring Security启用防御CSRF后,对于所有的异步请求都需要处理。也可以禁用security防御CSRF攻击的功能,在授权时加上 .and().csrf().disable(); 即可。

      https://res.craft.do/user/full/fd148a50-4a5b-9a85-bec3-e1645571e2c7/doc/943A9F07-3719-43B2-8785-FD3439CD0658/905D6E0E-38C2-48B0-8562-95B008A1F6F3_2/Fs2FdoqqTybTC5KxAdFxmzgKH4ldDSgTEdy6ENHqx0Ez/Image.png

    置顶、加精、删除

    • 功能实现

      • 点击 “置顶”,修改帖子的类型。0-普通、1-置顶
      • 点击 “加精”、“删除”、修改帖子的状态。 0-正常、1-精华、2-拉黑
    • 权限管理

      • 版主可以执行“置顶”、“加精”操作
      • 管理员可以执行“删除”操作
    • 按钮显示

      • 版主可以看到“置顶”、“加精”按钮
      • 管理员可以看到“删除”按钮

      https://res.craft.do/user/full/fd148a50-4a5b-9a85-bec3-e1645571e2c7/doc/943A9F07-3719-43B2-8785-FD3439CD0658/AF928AB6-2E26-44E5-93D5-65ED8DD58AB3_2/m8xWcIpO1paJRtfy2zcDufmzIMQqGzwXT9reamR47eQz/Image.png

    Redis高级数据类型

    • HyperLogLog(超级日志),可以对数据统计,也可以对数据合并

      • 采用一种基数算法,用于完成独立总数的统计。
      • 占据空间小,无论统计多少个数据,只占12K的内存空间。
      • 不精确的统计算法,标准误差为0.81%。
      // 示例
      // 添加数据
      redisTemplate.opsForHyperLogLog().add(redisKey,value);
      
      // 统计数据
      Long size = redisTemplate.opsForHyperLogLog().size(redisKey);
      
      // 合并数据
      redisTemplate.opsForHyperLogLog().union(unionKey,redisKey2,redisKey3,redisKey4);
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
    • Bitmap(位图),可以统计一组数据的布尔值

      • 不是一种独立的数据结构, 实际上就是字符串。
      • 支持安慰存取数据,可以将其看成是byte数组。
      • 适合存储检索大量的连续的数据的布尔值。
      // 示例
      // 记录
      redisTemplate.opsForValue().setBit(redisKey,index,value); // value:true or false
      
      // 查询
      System.out.println(redisTemplate.opsForValue().getBit(redisKey,index));
      
      // 统计
      Object obj = redisTemplate.execute(new RedisCallback() {
          @Override
          public Object doInRedis(RedisConnection connection) throws DataAccessException {
              return connection.bitCount(redisKey.getBytes());
          }
      });
      
      System.out.println(obj);
      
      // 运算 以or为例
      Object obj = redisTemplate.execute(new RedisCallback() {
          @Override
          public Object doInRedis(RedisConnection connection) throws DataAccessException {
              connection.bitOp(RedisStringCommands.BitOperation.OR,redisKey.getBytes(),
                      redisKey2.getBytes(),redisKey3.getBytes(),redisKey4.getBytes());
              return connection.bitCount(redisKey.getBytes());
          }
      });
      
      System.out.println(obj);
      
      • 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

    网站数据统计

    • UV(Unique Visitor)

      • 独立访客,需通过用户IP排重统计数据。为什么用IP而不是用userId统计呢,因为我们希望将非注册用户(游客)也统计进来。
      • 每次访问都要进行统计。
      • HyperLogLog,性能好,且存储空间小。
    • DAU(Daily Active User)

      • 日活跃用户,需通过用户ID排重统计数据。
      • 访问过一次,则认为其活跃。
      • Bitmap,性能好、且可以统计精确的结果。

      https://res.craft.do/user/full/fd148a50-4a5b-9a85-bec3-e1645571e2c7/doc/943A9F07-3719-43B2-8785-FD3439CD0658/CCDC0F95-8D44-4EED-955B-9DFFEE5EE1AD_2/NAY3fh0DxlnEeWf10CQn8cSe73g7J0PyXmo6S80WBEkz/Image.png

    具体实现:

    1.定义RedisKey

    public class RedisKeyUtil {
        private static final String SPLIT = ":";
        private static final String PREFIX_UV = "uv";
        private static final String PREFIX_DAU = "dau";
    
        // 单日UV
        public static String getUVKey(String date){
            return PREFIX_UV + SPLIT + date;
        }
    
        // 区间UV
        public static String getUVKey(String startDate,String endDate){
            return PREFIX_UV + SPLIT + startDate + SPLIT + endDate;
        }
    
        // 单日活跃用户
        public static String getDAUKey(String date){
            return PREFIX_DAU + SPLIT + date;
        }
    
        // 区间活跃用户
        public static String getDAUKey(String startDate,String endDate){
            return PREFIX_DAU + SPLIT + startDate + SPLIT + endDate;
        }
    }
    
    • 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

    2.业务层逻辑

    @Service
    public class DataService {
    
        @Autowired
        private RedisTemplate redisTemplate;
    
        private SimpleDateFormat df = new SimpleDateFormat("yyyyMMdd");
    
        // 将指定的IP计入UV
        public void recordUV(String ip){
            String redisKey = RedisKeyUtil.getUVKey(df.format(new Date()));
            redisTemplate.opsForHyperLogLog().add(redisKey,ip);
        }
    
        // 统计指定日期范围内的UV
        public long calculateUV(Date start,Date end){
            if(start == null || end == null){
                throw new IllegalArgumentException("参数不能为空!");
            }
    
            // 整理该日期范围内的key
            List<String> keyList = new ArrayList<>();
            Calendar calendar = Calendar.getInstance();
            calendar.setTime(start);
            while (!calendar.getTime().after(end)){
                String key = RedisKeyUtil.getUVKey(df.format(calendar.getTime()));
                keyList.add(key);
                calendar.add(calendar.DATE,1);
            }
    
            // 合并这些数据
            String redisKey = RedisKeyUtil.getUVKey(df.format(start),df.format(end));
            redisTemplate.opsForHyperLogLog().union(redisKey,keyList.toArray());
    
            // 返回统计结果
            return redisTemplate.opsForHyperLogLog().size(redisKey);
        }
    
        // 将指定用户计入DAU
        public void recordDAU(int userId){
            String redisKey = RedisKeyUtil.getDAUKey(df.format(new Date()));
            redisTemplate.opsForValue().setBit(redisKey,userId,true);
        }
    
        // 统计指定日期范围内的DAU
        public long calculateDAU(Date start,Date end){
            if(start == null || end == null){
                throw new IllegalArgumentException("参数不能为空!");
            }
    
            // 整理该日期范围内的key
            List<byte[]> keyList = new ArrayList<>();
            Calendar calendar = Calendar.getInstance();
            calendar.setTime(start);
            while (!calendar.getTime().after(end)){
                String key = RedisKeyUtil.getDAUKey(df.format(calendar.getTime()));
                keyList.add(key.getBytes());
                calendar.add(calendar.DATE,1);
            }
    
            // 进行OR运算
            return (long) redisTemplate.execute(new RedisCallback() {
                @Override
                public Object doInRedis(RedisConnection connection) throws DataAccessException {
                    String redisKey = RedisKeyUtil.getDAUKey(df.format(start),df.format(end));
                    connection.bitOp(RedisStringCommands.BitOperation.OR,
                            redisKey.getBytes(),keyList.toArray(new byte[0][0]));
                    return connection.bitCount(redisKey.getBytes());
                }
            });
        }
    }
    
    • 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

    3.使用拦截器进行访问数据的记录

    • 定义拦截器DataInterceptor
    @Component
    public class DataInterceptor implements HandlerInterceptor {
        @Autowired
        private DataService dataService;
        @Autowired
        private HostHolder hostHolder;
    
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
            // 统计UV
            String ip = request.getRemoteHost();
            dataService.recordUV(ip);
    
            // 统计DAU
            User user = hostHolder.getUser();
            if(user != null){
                dataService.recordDAU(user.getId());
            }
    
            return true;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 配置拦截器WebMvcConfig
    @Configuration
    public class WebMvcConfig implements WebMvcConfigurer {
    
        @Autowired
        private DataInterceptor dataInterceptor;
    
        @Override
        public void addInterceptors(InterceptorRegistry registry) {
    
            registry.addInterceptor(dataInterceptor)
                    .excludePathPatterns("/**/*.css","/**/*.js","/**/*.png","/**/*.jpg","/**/*.jpeg");
    
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    4.表现层逻辑

    注意⚠️:@DateTimeFormat注解的作用是入参格式化,前台传string类型的时间字符串,此注解将字符串转换为Date类型

    @Controller
    public class DataController {
    
        @Autowired
        private DataService dataService;
    
        // 统计页面
        @RequestMapping(path = "/data",method = {RequestMethod.GET,RequestMethod.POST})
        public String getDataPage(){
            return "/site/admin/data";
        }
    
        // 统计网站UV
        @RequestMapping(path = "/data/uv",method = RequestMethod.POST)
        public String getUV(@DateTimeFormat(pattern = "yyyy-MM-dd") Date start,
                            @DateTimeFormat(pattern = "yyyy-MM-dd") Date end, Model model){
            long uv = dataService.calculateUV(start,end);
            model.addAttribute("uvResult",uv);
            model.addAttribute("uvStartDate",start);
            model.addAttribute("uvEndDate",end);
            return "forward:/data";
        }
    
        // 统计活跃用户
        @RequestMapping(path = "/data/dau",method = RequestMethod.POST)
        public String getDAU(@DateTimeFormat(pattern = "yyyy-MM-dd") Date start,
                            @DateTimeFormat(pattern = "yyyy-MM-dd") Date end, Model model){
            long dau = dataService.calculateDAU(start,end);
            model.addAttribute("dauResult",dau);
            model.addAttribute("dauStartDate",start);
            model.addAttribute("dauEndDate",end);
            return "forward:/data";
        }
    }
    
    • 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

    5.权限管理SecutiryConfig

    // 部分授权代码
    http.authorizeRequests()
            .antMatchers(
                    "/data/**"
            )
            .hasAnyAuthority(
                    AUTHORITY_ADMIN
            )
            .anyRequest().permitAll()
            .and().csrf().disable();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
  • 相关阅读:
    (5)SpringMVC处理携带JSON格式(“key“:value)请求数据的Ajax请求
    MySQL查询性能优化七种武器之链路追踪
    蓝桥杯打卡Day7
    Redis 备份恢复(持久化)手册
    HTML5+CSS3+Vue小实例:输入框打字放大特效
    J2EE之通用分页02
    扩展 Calcite 中的 SQL 解析语法
    赛轮集团SAILUN方程式赛车轮胎震撼登场,开启新篇章
    谈谈什么是缓存穿透,缓存击穿,缓存雪崩?怎么解决?
    RCE——远程命令执行(无字母数字篇)
  • 原文地址:https://blog.csdn.net/wangws_sb/article/details/128179924