仿牛客网社区开发——第7章 项目进阶,构建安全高效的企业服务(上)

Spring Security#

简介#

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

特征#

  • 对身份的认证授权提供全面的、可扩展的支持
  • 防止各种攻击,如会话固定攻击、点击劫持、csrf 攻击等
  • 支持与 Servlet API、Spring MVC 等 Web 技术集成

原理#

官网:https://spring.io/projects/spring-security

中文学习网址:http://www.spring4all.com/article/428

简要来说,Spring Security 底层是利用的 Java EE 的规范。底层用了很多的 Filter,不同的 Filter 负责不同的功能。

引入 Spring Security 的依赖#

引入包之后,Spring Security 权限就会生效 ,会生成随机密码,访问资源时会被拦截。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

改造 User 实体类以及 UserService #

修改 User 类#

  • 实现 UserDetails 接口
  • 重写几个方法,getAuthorities 返回用户权限,其它方法默认都返回 true
  • 一个用户可能有多个权限,但这里一个用户只需要一个权限即可,所以 getAuthorities 就不需要返回集合
public class User implements UserDetails {

    ……
        
    // 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() {
        ArrayList<GrantedAuthority> list = new ArrayList<>();
        list.add(new GrantedAuthority() {
            @Override
            public String getAuthority() {
                switch (type) {
                    case 1:
                        return "ADMIN";
                    default:
                        return "USER";
                }
            }
        });
        return list;
    }
}

修改 UserService 类#

Spring Security 需要依赖 UserDetailsService 接口查询用户,所以需要在 UserService 实现此接口,并且实现其方法,直接调用 UserService 的查询方法

@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 findUserByName(username);
    }
}

编写 Security 配置类#

  1. 前两个 configure 方法主要是认证的逻辑:
    • 静态资源不需要拦截
    • 不采用内置的认证规则,采用自定义认证规则。因为之前用的不是这个 Encoder,并且也不是这个固定的 salt,所以这个逻辑和我们目前的系统的现状不匹配
    • 自定义认证规则中,每个 AuthenticationProvider 负责一种认证,例如有账号密码认证、QQ 认证、微信认证等等。ProviderManager 持有一组 AuthenticationProvider,ProviderManager 将认证委托给 AuthenticationProvider
    • 账号不存在密码不正确,抛出各自对应的异常
    • 返回时填上 3 个对应的参数
    • supports 方法中返回当前支持的认证类型
  2. 第三个 configure 方法:
    • 进行登录和退出的相关配置:主要是路径成功和失败的处理
    • 授权配置每个路径能有哪些权限可以访问拒绝访问的跳转路径
    • 验证码配置在账号密码的 Filter 之前;如果路径为 /login,且验证码错误,则拦截;否则应当放行必须写 doFilter
    • 记住我的配置:凭证存放到何处有效时间,以及配置 userService。这里当下次访问这个网站的时候,它从内存里能根据你的凭证得到你的用户名,然后得查出用户的完整信息。怎么查呢,用这个 userService 来查,所以你得告诉它
  3. 另外注意各个地方是请求转发还是重定向
@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");
                    if (verifyCode == null || !verifyCode.equals("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);
    }
}

HomeController#

尤其注意 /index 方法,如何获取到之前存的用户信息。认证成功后,结果会通过 SecurityContextHolder 存入 SecurityContext 中

@Controller
public class HomeController {

    @RequestMapping(path = "/index", method = RequestMethod.GET)
    public String getIndexPage(Model model) {
        // 认证成功后,结果会通过SecurityContextHolder存入SecurityContext中
        Object obj = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        if (obj instanceof User) {
            model.addAttribute("loginUser", obj);
        }
        return "/index";
    }

    @RequestMapping(path = "/discuss", method = RequestMethod.GET)
    public String getDiscussPage() {
        return "/site/discuss";
    }

    @RequestMapping(path = "/letter", method = RequestMethod.GET)
    public String getLetterPage() {
        return "/site/letter";
    }

    @RequestMapping(path = "/admin", method = RequestMethod.GET)
    public String getAdminPage() {
        return "/site/admin";
    }

    @RequestMapping(path = "/loginpage", method = {RequestMethod.GET, RequestMethod.POST})
    public String getLoginPage() {
        return "/site/login";
    }

    // 拒绝访问时的提示页面
    @RequestMapping(path = "/denied", method = RequestMethod.GET)
    public String getDeniedPage() {
        return "/error/404";
    }
}

前端页面#

index.html#

Spring Security 要求退出得是 post 请求,这里改成表单,注意超链接如何起提交的效果

<h1>社区首页</h1>
<!--欢迎信息-->
<p th:if="${loginUser!=null}">
    欢迎你,<span th:text="${loginUser.username}"></span></p>

<ul>
    <li><a th:href="@{/discuss}">帖子详情</a></li>
    <li><a th:href="@{/letter}">私信列表</a></li>
    <li><a th:href="@{/loginpage}">登录</a></li>
    <!--<li><a th:href="@{/logout}">退出</a></li>-->
    <li>
        <form method="post" th:action="@{/logout}">
            <a href="javascript:document.forms[0].submit();">退出</a>
        </form>
    </li>
</ul>

login.html#

记住我的 name 固定,一定要叫 remember-me

<h1>登录社区</h1>

<form method="post" th:action="@{/login}">
    <p style="color:red;" th:text="${error}">
        <!--提示信息-->
    </p>
    <p>
        账号:<input type="text" name="username" th:value="${param.username}">
    </p>
    <p>
        密码:<input type="password" name="password" th:value="${param.password}">
    </p>
    <p>
        验证码:<input type="text" name="verifyCode"> <i>1234</i>
    </p>
    <p>
        <input type="checkbox" name="remember-me"> 记住我
    </p>
    <p>
        <input type="submit" value="登录">
    </p>
</form>

注意点:#

  1. 认证和授权需要搞清楚。认证主要是验证有没有登录,授权主要是登录后判断有没有权限
  2. Spring Security 在整个 Java EE 中所处的位置,弄清楚老师画的原理图
  3. 对 User 以及 UserService 类的改造,实现对应的接口及各自的方法
  4. 重点是 Security 的配置类,3 个 configure 方法的具体逻辑
  5. 各处用转发还是重定向搞清楚
  6. 自定义 Filter(验证码)中最后一定要写 doFilter 放行
  7. controller 中获取 principal 信息、logout 得是 post 请求,稍加注意

权限控制#

授权配置#

注释掉原来的拦截器(采用 Spring Security)#

//    @Autowired
//    private LoginRequiredInterceptor loginRequiredInterceptor;
//        registry.addInterceptor(loginRequiredInterceptor)
//                .excludePathPatterns("/**/*.css", "/**/*.js", "/**/*.jpg", "/**/*.png", "/**/*.jpeg");

增加常量#

// 权限:普通用户
String AUTHORITY_USER = "user";

// 权限:管理员
String AUTHORITY_ADMIN = "admin";

// 权限:版主
String AUTHORITY_MODERATOR = "moderator";

配置路径和权限#

  • 配置不需要拦截的路径
  • 哪些路径需要拦截,以及访问这些路径需要拥有的权限
@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",
                        "/user/updatePassword",
                        "/discuss/add",
                        "/comment/add/**",
                        "/letter/**",
                        "/notice/**",
                        "/like",
                        "/follow",
                        "/unfollow"
                )
                .hasAnyAuthority(
                        AUTHORITY_USER,
                        AUTHORITY_ADMIN,
                        AUTHORITY_MODERATOR
                )
                .anyRequest().permitAll();
        ……(接下)

处理请求失败#

  • 失败的情况分为:没有登录和登录了但权限不足两种情况
  • 请求分为普通请求和异步请求,普通请求返回登录页面,而异步请求需要返回JSON字符串
  • 根据消息头 x-requested-with 判断请求方式
        ……(接上)
        // 权限不够时的处理
        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");
                        }
                    }
                });
        ……(接下)

其中,需要在 HomeController 里添加到拒绝访问页面的方法

@GetMapping(path = "/denied")
public String getDeniedPage() {
    return "/error/404";
}

覆盖 Spring Security 的退出请求#

这是一个善意的欺骗,好让程序执行到我们的 /logout 逻辑里。

……(接上)
        // Security底层默认会拦截/logout请求,进行退出处理
        // 覆盖它默认的逻辑,才能执行我们自己的退出代码
        http.logout().logoutUrl("/securitylogout");
    }
}

认证方案#

上一节我们认证完最后会把认证的信息封装到一个 Token 里,这个 Token 会被 Security 的一个 Filter 获取到并存到 SecurityContext 里。后面在进行授权的时候,它都是通过查看 SecurityContext 里的 Token 来判断权限。现在我们走自己的认证,没有这个逻辑。没有这个东西,Security 是无法进行授权的,它不知道你的权限到底是什么。我们得想办法把结果存到 SecurityContext 里面。因为我们已经绕过了它的认证逻辑,所以没必要像上节课那样。

所以,采用如下方法:

1. 在 UserService 里添加 getAuthorities

  • 通过 userid 获取当前用户
  • 通过用户的 type 判断相应的权限
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;
}

2. 在 LoginTicketInterceptor 中将信息存入 SecurityContext

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
    // 从Cookie中获取凭证
    String ticket = CookieUtil.getValue(request, "ticket");

    if(ticket != null) {
        // 查询凭证
        LoginTicket loginTicket = userService.findLoginTicket(ticket);
        // 验证凭证是否有效
        if(loginTicket != null && loginTicket.getStatus() == 0 & loginTicket.getExpired().after(new Date())) {
            // 根据凭证查询用户
            User user = userService.findUserById(loginTicket.getUserId());
            // 从本次请求中持有用户
            hostHolder.setUser(user);
            // 构建用户认证的结果,并存入SecurityContext,以便Security进行授权
            Authentication authentication = new UsernamePasswordAuthenticationToken(
                user, user.getPassword(), userService.getAuthorities(user.getId()));
            SecurityContextHolder.setContext(new SecurityContextImpl(authentication));
        }
    }

    return true;
}

3. 退出时清理认证的信息

@GetMapping("/logout")
public String logout(@CookieValue("ticket") String ticket) {
    userService.logout(ticket);
    SecurityContextHolder.clearContext();
    return "redirect:/login";
}

关于这部分的逻辑,评论区有一个人说的很好:

核心就在于 Filter 是在拦截器之前执行的,所以“认证的进行依赖于前一次请求的拦截器处理”。登录之后会默认重定向到 index 页面,这个时候其实 SecurityContext 里是没有存入认证信息的,但是 /index 请求也不需要权限,所以直接通过 Filter,到拦截器里再存入了认证的信息。下次访问其它需要有权限的页面就能正常通过 SecurityContext 里的信息来判断权限了。

CSRF 配置#

  • 用户登录后,服务器会返回带有登录凭证的 cookie,浏览器把 cookie 存储在本地;
  • 当浏览器向服务器发送表单请求时,服务器返回表单页面;
  • 此时,保存在浏览器的 cookie 中的 ticket 被其他恶意网站窃取,并且恶意网站向服务器提交了 POST 表单的请求;
  • 如果是恶意提交(比如修改转账金额)之类的就会造成安全隐患;

Spring Security 的方法是服务器返回表单的时候,隐藏了一个 TOKEN 信息,此信息为随机字符串,当用户提交表单的时候服务器会核对 ticket 和 TOKEN。恶意网站窃取不到 TOKEN,提交的时候没有带上 TOKEN,服务器就能够检查出来。

如果是普通请求的表单,会自动在表单中添加一个 hidden 的标签带上 TOKEN

但如果是异步请求,则需要自己手动处理

以 index 首页为例:

在 index.html 的 <head> 标签中带上 TOKEN#

带上 TOKEN 和 key,以便发送异步请求时获取到

<!--访问该页面时,在此处生成CSRF令牌-->
<meta name="_csrf" th:content="${_csrf.token}">
<meta name="_csrf_header" th:content="${_csrf.headerName}">

在 index.js 中获取 TOKEN 并设置到请求的消息头中#

获取页面中的 TOKEN 和 key,在发送 AJAX 请求之前设置到请求的消息头中

function publish() {
	$("#publishModal").modal("hide");

	// 发送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);
    });

	// 获取标题和内容
	var title = $("#recipient-name").val();
	var content = $("#message-text").val();
	// 发送异步请求(POST)
	$.post(
		CONTEXT_PATH + "/discuss/add",
		{"title":title,"content":content},
		function (data) {
			data = $.parseJSON(data);
			// 在提示框中显示返回信息
			$("#hintBody").text(data.msg);
			// 显示提示框
			$("#hintModal").modal("show");
			// 2秒后,自动隐藏提示框
            setTimeout(function(){
                $("#hintModal").modal("hide");
                // 刷新页面
                if(data.code == 0) {
                    window.location.reload();
                }
            }, 2000);
        }
	);

}

也可以选择禁用 CSRF#

对于每一个异步请求都需要作如上处理,略有点麻烦,也可以选择禁用 CSRF(当然,会有风险)

http.csrf().disable();

处理一个遗留的小问题#

当三类通知信息不是都有时,会出现如下的错误

修改 MessageController 中的 getNoticeList 方法中的查询通知#

以评论类通知为例,点赞和关注完全一样。

当没有通知信息时,直接就不存 commentNotice。

// 查询评论类通知
Message message = messageService.findLatestNotice(user.getId(), TOPIC_COMMENT);
if (message != null) {
    Map<String, Object> messageVO = new HashMap<>();
    messageVO.put("message", message);

    String content = HtmlUtils.htmlUnescape(message.getContent());
    Map<String, Object> data = JSONObject.parseObject(content, HashMap.class);

    messageVO.put("user", userService.findUserById((Integer) data.get("userId")));
    messageVO.put("entityType", data.get("entityType"));
    messageVO.put("entityId", data.get("entityId"));
    messageVO.put("postId", data.get("postId"));

    int count = messageService.findNoticeCount(user.getId(), TOPIC_COMMENT);
    messageVO.put("count", count);

    int unread = messageService.findNoticeUnreadCount(user.getId(), TOPIC_COMMENT);
    messageVO.put("unread", unread);
    model.addAttribute("commentNotice", messageVO);
}

修改 notice.html 中的通知列表#

修改是否显示通知的逻辑,依旧以评论类通知为例,改为 commentNotice!=null(没有 commentNotice 就能判断为空,有 commentNotice 但没有 message 就会报错,非常奇怪。。)

<!--评论类通知-->
<li class="media pb-3 pt-3 mb-3 border-bottom position-relative" th:if="${commentNotice!=null}"...>

修改成功后效果如下:

注意点:#

  1. 处理请求失败,分两种情况:没有登录和权限不足。每种情况中还要根据消息头判断是同步还是异步请求,并分别进行处理
  2. 要“欺骗”Spring Security 的退出逻辑
  3. 不采用 Spring Security 的认证方案,要自己获取用户权限并在合适的时机将认证信息存入 SecurityContext 里,并且退出登录时要清除该信息
  4. 理清上述实际流程的逻辑
  5. CSRF 的概念,以及 Spring Security 的处理方式。并且异步请求需要自己进行处理
  6. 遗留的小问题

置顶、加精、删除#

功能实现#

– 点击置顶,修改帖子的类型(type)

– 点击加精删除,修改帖子的状态(status)

增加修改帖子类型和状态的 Mapper 和 Service 方法#

int updateType(int id, int type);

int updateStatus(int id, int status);
<update id="updateType">
    UPDATE discuss_post
    SET `type` = #{type}
    WHERE id = #{id}
</update>

<update id="updateStatus">
    UPDATE discuss_post
    SET status = #{status}
    WHERE id = #{id}
</update>
public int updateType(int id, int type) {
    return discussPostMapper.updateType(id, type);
}

public int updateStatus(int id, int status) {
    return discussPostMapper.updateStatus(id, status);
}

增加处理置顶、加精、删除请求的 Controller 方法#

  • 置顶加精因为修改了帖子的信息,需要在 Elasticsearch 中同步更新,所以要触发一个发帖事件
  • 删除帖子后,Elasticsearch 中没必要再保存该帖子的信息,所以触发删帖事件
// 置顶
@PostMapping("/top")
@ResponseBody
public String setTop(int id) {
    discussPostService.updateType(id, 1);

    // 触发发帖事件
    Event event = new Event()
        .setTopic(TOPIC_PUBLISH)
        .setUserId(hostHolder.getUser().getId())
        .setEntityType(ENTITY_TYPE_POST)
        .setEntityId(id);
    eventProducer.fireEvent(event);

    return CommunityUtil.getJSONString(0);
}

// 加精
@PostMapping("/wonderful")
@ResponseBody
public String setWonderful(int id) {
    discussPostService.updateStatus(id, 1);

    // 触发发帖事件
    Event event = new Event()
        .setTopic(TOPIC_PUBLISH)
        .setUserId(hostHolder.getUser().getId())
        .setEntityType(ENTITY_TYPE_POST)
        .setEntityId(id);
    eventProducer.fireEvent(event);

    return CommunityUtil.getJSONString(0);
}

// 删除
@PostMapping("/delete")
@ResponseBody
public String setDelete(int id) {
    discussPostService.updateStatus(id, 2);

    // 触发删帖事件
    Event event = new Event()
        .setTopic(TOPIC_DELETE)
        .setUserId(hostHolder.getUser().getId())
        .setEntityType(ENTITY_TYPE_POST)
        .setEntityId(id);
    eventProducer.fireEvent(event);

    return CommunityUtil.getJSONString(0);
}

增加消费删帖事件的方法#

  • 在 CommunityConstant 中再增加一个删帖的主题(TOPIC_DELETE),这里就不贴代码了
  • 这里也同样需要先判断消息内容是否为空,以及消息格式是否正确。然后再调用删除帖子的方法
// 消费删帖事件
@KafkaListener(topics = {TOPIC_DELETE})
public void handleDeleteMessage(ConsumerRecord record) {
    if (record == null || record.value() == null) {
        logger.error("消息的内容为空!");
        return;
    }

    Event event = JSONObject.parseObject(record.value().toString(), Event.class);
    if (event == null) {
        logger.error("消息格式错误!");
        return;
    }

    elasticsearchService.deleteDiscussPost(event.getEntityId());
}

修改 html 页面以及 js#

  • 添加隐藏标签,值为帖子的 id,以便 js 中获取到
  • js 中成功置顶或加精后,要把禁用该按钮。删除则直接重定向到首页
  • 当再次访问该页面时,需要判断该帖子是否已经置顶或加精或删除,是则也要禁用该按钮
<div class="float-right">
    <input type="hidden" id="postId" th:value="${post.id}">
    <button type="button" class="btn btn-danger btn-sm" id="topBtn"
            th:disabled="${post.type==1}">置顶</button>
    <button type="button" class="btn btn-danger btn-sm" id="wonderfulBtn"
            th:disabled="${post.status==1}">加精</button>
    <button type="button" class="btn btn-danger btn-sm" id="deleteBtn"
            th:disabled="${post.status==2}">删除</button>
</div>
$(function () {
    $("#topBtn").click(setTop);
    $("#wonderfulBtn").click(setWonderful);
    $("#deleteBtn").click(setDelete);
});

// 置顶
function setTop() {
    $.post(
        CONTEXT_PATH + "/discuss/top",
        {"id" : $("#postId").val()},
        function (data) {
            data = $.parseJSON(data);
            if(data.code == 0) {
                $("#topBtn").attr("disabled", "disabled");
            } else {
                alert(data.msg);
            }
        }
    );
}

// 加精
function setWonderful() {
    $.post(
        CONTEXT_PATH + "/discuss/wonderful",
        {"id" : $("#postId").val()},
        function (data) {
            data = $.parseJSON(data);
            if(data.code == 0) {
                $("#wonderfulBtn").attr("disabled", "disabled");
            } else {
                alert(data.msg);
            }
        }
    );
}

// 删除
function setDelete() {
    $.post(
        CONTEXT_PATH + "/discuss/delete",
        {"id" : $("#postId").val()},
        function (data) {
            data = $.parseJSON(data);
            if(data.code == 0) {
                location.href = CONTEXT_PATH + "/index";
            } else {
                alert(data.msg);
            }
        }
    );
}

权限管理#

– 版主可以执行置顶加精操作

– 管理员可以执行删除操作

在 SecurityConfig 中的授权 configure 方法中,授权配置处在第一个 hasAnyAuthority 后添加如下代码:

.antMatchers(
    "/discuss/top",
    "/discuss/wonderful"
)
.hasAnyAuthority(
    AUTHORITY_MODERATOR
)
.antMatchers(
    "/discuss/delete"
)
.hasAnyAuthority(
    AUTHORITY_ADMIN
)

按钮显示#

– 版主可以看到置顶加精按钮

– 管理员可以看到删除按钮

需要在页面上通过权限来判断是否显示。Thymeleaf 给我们提供了支持,它能够支持 Spring Security。不过这是 Thymeleaf 后来在某个版本升级出来的功能。要想用这个功能,还得单独导入这个包。

引入依赖#

<dependency>
    <groupId>org.thymeleaf.extras</groupId>
    <artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>

修改页面#

  • 头部添加 xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
<html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
  • 添加 sec:authorize="hasAnyAuthority('xxx')" 来判断权限,是否显示按钮(和后端逻辑类似,也可以写多个权限)
<div class="float-right">
    <input type="hidden" id="postId" th:value="${post.id}">
    <button type="button" class="btn btn-danger btn-sm" id="topBtn"
            th:disabled="${post.type==1}" sec:authorize="hasAnyAuthority('moderator')">置顶</button>
    <button type="button" class="btn btn-danger btn-sm" id="wonderfulBtn"
            th:disabled="${post.status==1}" sec:authorize="hasAnyAuthority('moderator')">加精</button>
    <button type="button" class="btn btn-danger btn-sm" id="deleteBtn"
            th:disabled="${post.status==2}" sec:authorize="hasAnyAuthority('admin')">删除</button>
</div>

注意点#

  1. 置顶是修改帖子的 type,加精和删除是修改帖子的 status
  2. 三种功能都需要触发事件,去更新 Elasticsearch 中的帖子信息。不过前两者是发帖事件,删除是删帖事件(ES 中没必要保存已经被删除的帖子了)
  3. 添加了删帖事件注意要去增加消费删帖事件的方法
  4. 前端的处理也需要注意:触发单击事件、添加隐藏标签、禁用按钮等
  5. 按钮是否显示的处理

Redis 高级数据类型#

HyperLogLog#

  • 采用一种基数算法,用于完成独立总数的统计
  • 占据空间小,无论统计多少个数据,只占 12K 的内存空间
  • 不精确的统计算法,标准误差为 0.81%

统计数据#

// 统计20万个重复数据的独立总数
@Test
public void testHyperLogLog() {
    String redisKey = "test:hll:01";

    for (int i = 1; i <= 10000; i++) {
        redisTemplate.opsForHyperLogLog().add(redisKey, i);
    }

    for (int i = 1; i <= 10000; i++) {
        int num = (int) (Math.random() * 10000 + 1);
        redisTemplate.opsForHyperLogLog().add(redisKey, num);
    }

    System.out.println(redisTemplate.opsForHyperLogLog().size(redisKey));
}

统计结果为 9978,在误差范围内

合并数据#

// 将3组数据合并,再统计合并后的重复数据的独立总数
@Test
public void testHyperLogLogUnion() {
    String redisKey2 = "test:hll:02";
    for (int i = 1; i <= 1000; i++) {
        redisTemplate.opsForHyperLogLog().add(redisKey2, i);
    }

    String redisKey3 = "test:hll:03";
    for (int i = 501; i <= 1500; i++) {
        redisTemplate.opsForHyperLogLog().add(redisKey3, i);
    }

    String redisKey4 = "test:hll:04";
    for (int i = 1001; i <= 2000; i++) {
        redisTemplate.opsForHyperLogLog().add(redisKey4, i);
    }

    String unionKey = "test:hll:union";
    redisTemplate.opsForHyperLogLog().union(unionKey, redisKey2, redisKey3, redisKey4);

    System.out.println(redisTemplate.opsForHyperLogLog().size(unionKey));
}

测试结果为 2005

Bitmap#

  • 不是一种独立的数据结构,实际上就是字符串
  • 支持按位存取数据,可以将其看成是 byte 数组
  • 适合存储索大量的连续的数据的布尔值

统计的是 1 true 的个数

统计数据#

  • 如上述第一点所说,所以这里还是调用 opsForValue,但是 set 和 get 方法为 setBit 和 getBit
  • 需要通过 Redis 底层的连接才能访问统计的方法
// 统计一组数据的布尔值
@Test
public void testBitMap() {
    String redisKey = "test:bm:01";

    // 记录
    redisTemplate.opsForValue().setBit(redisKey, 1, true);
    redisTemplate.opsForValue().setBit(redisKey, 4, true);
    redisTemplate.opsForValue().setBit(redisKey, 7, true);

    // 查询
    System.out.println(redisTemplate.opsForValue().getBit(redisKey, 0));
    System.out.println(redisTemplate.opsForValue().getBit(redisKey, 1));
    System.out.println(redisTemplate.opsForValue().getBit(redisKey, 2));

    // 统计
    Object obj = redisTemplate.execute(new RedisCallback() {
        @Override
        public Object doInRedis(RedisConnection connection) throws DataAccessException {
            return connection.bitCount(redisKey.getBytes());
        }
    });

    System.out.println(obj);
}

这里为什么需要 redisKey 的 byte 数组,理由如下:

主要就是 jedis 客户端在向 redis 发命令时,底层是用的字节流输出,因此发命令需要传对应的 key 的字节数组。

测试结果:

对数据作运算#

做运算同样需要获取 Redis 底层的连接,并且把各个 key 转换为 byte 数组

// 统计3组数据的布尔值,并对这3组数据做OR运算
@Test
public void testBitMapOperation() {
    String redisKey2 = "test:bm:02";
    redisTemplate.opsForValue().setBit(redisKey2, 0, true);
    redisTemplate.opsForValue().setBit(redisKey2, 1, true);
    redisTemplate.opsForValue().setBit(redisKey2, 2, true);

    String redisKey3 = "test:bm:03";
    redisTemplate.opsForValue().setBit(redisKey3, 2, true);
    redisTemplate.opsForValue().setBit(redisKey3, 3, true);
    redisTemplate.opsForValue().setBit(redisKey3, 4, true);

    String redisKey4 = "test:bm:04";
    redisTemplate.opsForValue().setBit(redisKey4, 4, true);
    redisTemplate.opsForValue().setBit(redisKey4, 5, true);
    redisTemplate.opsForValue().setBit(redisKey4, 6, true);

    String redisKey = "test:bm: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);

    System.out.println(redisTemplate.opsForValue().getBit(redisKey, 0));
    System.out.println(redisTemplate.opsForValue().getBit(redisKey, 1));
    System.out.println(redisTemplate.opsForValue().getBit(redisKey, 2));
    System.out.println(redisTemplate.opsForValue().getBit(redisKey, 3));
    System.out.println(redisTemplate.opsForValue().getBit(redisKey, 4));
    System.out.println(redisTemplate.opsForValue().getBit(redisKey, 5));
    System.out.println(redisTemplate.opsForValue().getBit(redisKey, 6));
}

测试结果:

注意点#

  1. 两种数据类型的概念与其 API 的使用
  2. 有些操作需要通过 Redis 底层的连接来调用,并且注意将 key 转换为 byte 数组。理由上面也写了

网站数据统计#

  • UV (Unique Visitor)

   - 独立访客,需通过用户 IP 排重统计数据

   - 每次访问都要进行统计

   - HyperLogLog,性能好,且存储空间小

  • DAU (Daily Active User)

   - 日活跃用户,需通过用户 ID 排重统计数据

   - 访问过一次,则认为其活跃

   - Bitmap,性能好、且可以统计精确的结果

新增两种 RedisKey#

单日区间(多日)

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;
}

编写 DataService#

  • 定义一个 SimpleDateFormat,格式为 yyyyMMdd
  • 将指定 IP/用户 计入 UV/DAU,使用单日的 redisKey
  • 统计指定日期范围内的 UV/DAU,使用区间的 redisKey。需要对日期做遍历,统计完后区间的 redisKey 被存入 Redis
  • 遍历日期借助 Calendar 类,灵活使用其 API(after 在某日期之后、add 增加一天的方法等等)
  • 把遍历的各个日期的 redisKey 存入 list。UV 中合并数据时将其转换为数组;DAU 中,要将对应的 key 转换为 byte[],list<byte[]> 转换为 byte[][]

至于这里为什么是 new byte[0][0],评论区说这是 Java 规定的一个语法,感兴趣的话可以自行查阅其底层的实现。

经测试,方括号中的数字无所谓,重要的是这个类型。sout(new byte[0][0]),输出[[Bxxx(后面的是地址),估计是这样来判断要转换的是二维 byte 数组类型的。new byte[0][0] 应该是为了节省空间。

@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());
            }
        });

    }

}

编写 DataInterceptor#

在访问该网站的任何页面或请求时,都需要将当前 IP 计入 UV,当前用户的 ID(未登录则不记录)计入 DAU。使用拦截器来实现

@Component
public class DataInterceptor implements HandlerInterceptor {

    @Autowired
    private DataService dataService;

    @Autowired
    private HostHolder hostHolder;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        // 统计UV
        String ip = request.getRemoteHost();
        dataService.recordUV(ip);

        // 统计DAU
        User user = hostHolder.getUser();
        if (user != null) {
            dataService.recordDAU(user.getId());
        }

        return true;
    }
}

不要忘了在 WebMvcConfig 中配上拦截器

registry.addInterceptor(dataInterceptor)
    .excludePathPatterns("/**/*.css", "/**/*.js", "/**/*.jpg", "/**/*.png", "/**/*.jpeg");

这里我搜了一下,配置拦截器的顺序就是拦截器拦截的顺序,最上面的拦截器最先拦截,所以这里 DataInterceptor 中获取 hostHolder 完全没有问题。

编写 DataController#

  • /data 请求 GET 和 POST 方式都可以。添加 POST 方式的主要原因是:统计网站 UV 和统计活跃用户最后都转发到 /data 统计页面的请求。因为这两个请求本身就是 POST,请求转发本质上还是同一个请求,所以转发到 /data 依旧是 POST 请求。另外注意请求转发需要加 forward:
  • Date 类型的参数需要添加 @DateTimeFormat 注解,将对应格式的请求参数转换为 Java 里的 Date 对象
@Controller
public class DataController {

    @Autowired
    private DataService dataService;

    // 统计页面
    @RequestMapping(value = "/data", method = {RequestMethod.GET, RequestMethod.POST})
    public String getDataPage() {
        return "/site/admin/data";
    }

    // 统计网站UV
    @PostMapping("/data/uv")
    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";
    }

    // 统计活跃用户
    @PostMapping("/data/dau")
    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";
    }

}

SecurityConfig 中添加授权#

配置路径 /data/**,以 /data 开头的所有路径都需要管理员权限

.antMatchers(
    "/discuss/delete",
    "/data/**"
)
.hasAnyAuthority(
    AUTHORITY_ADMIN
)

修改页面#

因为只有管理员知道这个页面,所以其它页面不需要添加到该页面的链接,由管理员自己在地址栏输入 xxx/data 路径

注意 button 按钮的 type 要改为 submit

<!-- 内容 -->
<div class="main">
    <!-- 网站UV -->
    <div class="container pl-5 pr-5 pt-3 pb-3 mt-3">
        <h6 class="mt-3"><b class="square"></b> 网站 UV</h6>
        <form class="form-inline mt-3" method="post" th:action="@{/data/uv}">
            <input type="date" class="form-control" required name="start"
                   th:value="${#dates.format(uvStartDate,'yyyy-MM-dd')}"/>
            <input type="date" class="form-control ml-3" required name="end"
                   th:value="${#dates.format(uvEndDate,'yyyy-MM-dd')}"/>
            <button type="submit" class="btn btn-primary ml-3">开始统计</button>
        </form>
        <ul class="list-group mt-3 mb-3">
            <li class="list-group-item d-flex justify-content-between align-items-center">
                统计结果
                <span class="badge badge-primary badge-danger font-size-14" th:text="${uvResult}">0</span>
            </li>
        </ul>
    </div>
    <!-- 活跃用户 -->
    <div class="container pl-5 pr-5 pt-3 pb-3 mt-4">
        <h6 class="mt-3"><b class="square"></b> 活跃用户</h6>
        <form class="form-inline mt-3" method="post" th:action="@{/data/dau}">
            <input type="date" class="form-control" required name="start"
                   th:value="${#dates.format(dauStartDate,'yyyy-MM-dd')}"/>
            <input type="date" class="form-control ml-3" required name="end"
                   th:value="${#dates.format(dauEndDate,'yyyy-MM-dd')}"/>
            <button type="submit" class="btn btn-primary ml-3">开始统计</button>
        </form>
        <ul class="list-group mt-3 mb-3">
            <li class="list-group-item d-flex justify-content-between align-items-center">
                统计结果
                <span class="badge badge-primary badge-danger font-size-14" th:text="${dauResult}">0</span>
            </li>
        </ul>
    </div>				
</div>

注意点#

  1. UV 使用 HyperLogLog,不需要特别精确;DAU 使用 Bitmap,日活跃用户需要精确
  2. 有单日和区间两种 key,前者是计入时使用的,后者是统计时使用的
  3. 对日期做遍历,注意其写法,尤其是 API 的使用。并且注意 list 存的不是日期,是该日期的 key!
  4. 统计 DAU 中 list<byte[]>要转换为 byte[][],new byte[0][0] 来指明要转换的是这个类型
  5. 要在拦截器中将 IP 和 ID 计入 UV 和 DAU。注意拦截器执行的顺序
  6. 使用 @DateTimeFormat 注解将参数转换为 Date
  7. 该功能需要添加授权
posted @   幻梦翱翔  阅读(711)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· C#/.NET/.NET Core优秀项目和框架2025年2月简报
· Manus爆火,是硬核还是营销?
· 终于写完轮子一部分:tcp代理 了,记录一下
· 【杭电多校比赛记录】2025“钉耙编程”中国大学生算法设计春季联赛(1)
点击右上角即可分享
微信分享提示
主题色彩