仿牛客网社区开发——第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 配置类#
- 前两个 configure 方法主要是认证的逻辑:
- 静态资源不需要拦截
- 不采用内置的认证规则,采用自定义认证规则。因为之前用的不是这个 Encoder,并且也不是这个固定的 salt,所以这个逻辑和我们目前的系统的现状不匹配
- 自定义认证规则中,每个 AuthenticationProvider 负责一种认证,例如有账号密码认证、QQ 认证、微信认证等等。ProviderManager 持有一组 AuthenticationProvider,ProviderManager 将认证委托给 AuthenticationProvider
- 账号不存在或密码不正确,抛出各自对应的异常
- 返回时填上 3 个对应的参数
- supports 方法中返回当前支持的认证类型
- 第三个 configure 方法:
- 进行登录和退出的相关配置:主要是路径、成功和失败的处理。
- 授权配置:每个路径能有哪些权限可以访问、拒绝访问的跳转路径
- 验证码配置:在账号密码的 Filter 之前;如果路径为 /login,且验证码错误,则拦截;否则应当放行,必须写 doFilter
- 记住我的配置:凭证存放到何处、有效时间,以及配置 userService。这里当下次访问这个网站的时候,它从内存里能根据你的凭证得到你的用户名,然后得查出用户的完整信息。怎么查呢,用这个 userService 来查,所以你得告诉它
- 另外注意各个地方是请求转发还是重定向
@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>
注意点:#
- 认证和授权需要搞清楚。认证主要是验证有没有登录,授权主要是登录后判断有没有权限
- Spring Security 在整个 Java EE 中所处的位置,弄清楚老师画的原理图
- 对 User 以及 UserService 类的改造,实现对应的接口及各自的方法
- 重点是 Security 的配置类,3 个 configure 方法的具体逻辑
- 各处用转发还是重定向搞清楚
- 自定义 Filter(验证码)中最后一定要写 doFilter 放行
- 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}"...>
修改成功后效果如下:
注意点:#
- 处理请求失败,分两种情况:没有登录和权限不足。每种情况中还要根据消息头判断是同步还是异步请求,并分别进行处理
- 要“欺骗”Spring Security 的退出逻辑
- 不采用 Spring Security 的认证方案,要自己获取用户权限并在合适的时机将认证信息存入 SecurityContext 里,并且退出登录时要清除该信息
- 理清上述实际流程的逻辑
- CSRF 的概念,以及 Spring Security 的处理方式。并且异步请求需要自己进行处理
- 遗留的小问题
置顶、加精、删除#
功能实现#
– 点击置顶,修改帖子的类型(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>
注意点#
- 置顶是修改帖子的 type,加精和删除是修改帖子的 status
- 三种功能都需要触发事件,去更新 Elasticsearch 中的帖子信息。不过前两者是发帖事件,删除是删帖事件(ES 中没必要保存已经被删除的帖子了)
- 添加了删帖事件注意要去增加消费删帖事件的方法
- 前端的处理也需要注意:触发单击事件、添加隐藏标签、禁用按钮等
- 按钮是否显示的处理
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));
}
测试结果:
注意点#
- 两种数据类型的概念与其 API 的使用
- 有些操作需要通过 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>
注意点#
- UV 使用 HyperLogLog,不需要特别精确;DAU 使用 Bitmap,日活跃用户需要精确
- 有单日和区间两种 key,前者是计入时使用的,后者是统计时使用的
- 对日期做遍历,注意其写法,尤其是 API 的使用。并且注意 list 存的不是日期,是该日期的 key!
- 统计 DAU 中 list<byte[]>要转换为 byte[][],new byte[0][0] 来指明要转换的是这个类型
- 要在拦截器中将 IP 和 ID 计入 UV 和 DAU。注意拦截器执行的顺序
- 使用 @DateTimeFormat 注解将参数转换为 Date
- 该功能需要添加授权
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· C#/.NET/.NET Core优秀项目和框架2025年2月简报
· Manus爆火,是硬核还是营销?
· 终于写完轮子一部分:tcp代理 了,记录一下
· 【杭电多校比赛记录】2025“钉耙编程”中国大学生算法设计春季联赛(1)