SpringBoot仿牛客论坛项目实战
发布日期:2021-06-29 18:10:32 浏览次数:3 分类:技术文章

本文共 76978 字,大约阅读时间需要 256 分钟。

Community 论坛项目

转载请附带原文链接:

1. 环境搭建与技术栈说明

1.0 项目架构图

在这里插入图片描述

1.1 技术要求

  • 熟悉快速开发框架:SpringBoot2.3.x 整合 SpringMVC + Mybatis
  • 熟悉版本控制:Maven3.6.X + Git
  • 数据库以及文件存储:MySQL + 文件存储阿里云OSS
  • 熟悉页面模板引擎:Thymleaf3.x
  • 第三方工具:网页长图生成工具Wkhtmltopdf + 验证码生成工具kaptcha
  • 中间件:分布式缓存Redis + 全文检索ElasticSearch + Kafka + 本地缓存Caffeine
  • 权限框架:Spring Securtiy + Spring Actuator
  • 熟悉前端:Ajax + Vue + BootStrap + HTML + jQuery

1.2 环境搭建

初始化SpringBoot项目:

在这里插入图片描述

初始化后的pom.xml:

org.springframework.boot
spring-boot-starter-thymeleaf
org.springframework.boot
spring-boot-starter-web
org.mybatis.spring.boot
mybatis-spring-boot-starter
2.1.4
org.springframework.boot
spring-boot-devtools
runtime
true
mysql
mysql-connector-java
runtime
org.projectlombok
lombok
true
org.springframework.boot
spring-boot-starter-test
test
org.junit.vintage
junit-vintage-engine

项目初始结构:

在这里插入图片描述

1.3 数据库设计

数据库表sql

SET NAMES utf8 ;---- Table structure for table `comment`--DROP TABLE IF EXISTS `comment`; SET character_set_client = utf8mb4 ;CREATE TABLE `comment` (  `id` int(11) NOT NULL AUTO_INCREMENT,  `user_id` int(11) DEFAULT NULL,  `entity_type` int(11) DEFAULT NULL,  `entity_id` int(11) DEFAULT NULL,  `target_id` int(11) DEFAULT NULL,  `content` text,  `status` int(11) DEFAULT NULL,  `create_time` timestamp NULL DEFAULT NULL,  PRIMARY KEY (`id`),  KEY `index_user_id` (`user_id`) /*!80000 INVISIBLE */,  KEY `index_entity_id` (`entity_id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8;---- Table structure for table `discuss_post`--DROP TABLE IF EXISTS `discuss_post`; SET character_set_client = utf8mb4 ;CREATE TABLE `discuss_post` (  `id` int(11) NOT NULL AUTO_INCREMENT,  `user_id` varchar(45) DEFAULT NULL,  `title` varchar(100) DEFAULT NULL,  `content` text,  `type` int(11) DEFAULT NULL COMMENT '0-普通; 1-置顶;',  `status` int(11) DEFAULT NULL COMMENT '0-正常; 1-精华; 2-拉黑;',  `create_time` timestamp NULL DEFAULT NULL,  `comment_count` int(11) DEFAULT NULL,  `score` double DEFAULT NULL,  PRIMARY KEY (`id`),  KEY `index_user_id` (`user_id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8;---- Table structure for table `login_ticket`--DROP TABLE IF EXISTS `login_ticket`; SET character_set_client = utf8mb4 ;CREATE TABLE `login_ticket` (  `id` int(11) NOT NULL AUTO_INCREMENT,  `user_id` int(11) NOT NULL,  `ticket` varchar(45) NOT NULL,  `status` int(11) DEFAULT '0' COMMENT '0-有效; 1-无效;',  `expired` timestamp NOT NULL,  PRIMARY KEY (`id`),  KEY `index_ticket` (`ticket`(20))) ENGINE=InnoDB DEFAULT CHARSET=utf8;---- Table structure for table `message`--DROP TABLE IF EXISTS `message`; SET character_set_client = utf8mb4 ;CREATE TABLE `message` (  `id` int(11) NOT NULL AUTO_INCREMENT,  `from_id` int(11) DEFAULT NULL,  `to_id` int(11) DEFAULT NULL,  `conversation_id` varchar(45) NOT NULL,  `content` text,  `status` int(11) DEFAULT NULL COMMENT '0-未读;1-已读;2-删除;',  `create_time` timestamp NULL DEFAULT NULL,  PRIMARY KEY (`id`),  KEY `index_from_id` (`from_id`),  KEY `index_to_id` (`to_id`),  KEY `index_conversation_id` (`conversation_id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8;---- Table structure for table `user`--DROP TABLE IF EXISTS `user`; SET character_set_client = utf8mb4 ;CREATE TABLE `user` (  `id` int(11) NOT NULL AUTO_INCREMENT,  `username` varchar(50) DEFAULT NULL,  `password` varchar(50) DEFAULT NULL,  `salt` varchar(50) DEFAULT NULL,  `email` varchar(100) DEFAULT NULL,  `type` int(11) DEFAULT NULL COMMENT '0-普通用户; 1-超级管理员; 2-版主;',  `status` int(11) DEFAULT NULL COMMENT '0-未激活; 1-已激活;',  `activation_code` varchar(100) DEFAULT NULL,  `header_url` varchar(200) DEFAULT NULL,  `create_time` timestamp NULL DEFAULT NULL,  PRIMARY KEY (`id`),  KEY `index_username` (`username`(20)),  KEY `index_email` (`email`(20))) ENGINE=InnoDB AUTO_INCREMENT=101 DEFAULT CHARSET=utf8;

之后会提供一些

2. 邮件发送功能

2.1 发送者邮箱中打开SMTP服务

首先在自己的邮箱(网易、QQ…均可)设置中开启SMTP服务

在这里插入图片描述

2.2 引入依赖

pom.xml中引入依赖

org.springframework.boot
spring-boot-starter-mail

2.3 参数配置

邮箱参数配置(我使用的是网易邮箱)

# spring 相关配置spring:  # 发送者邮箱相关配置  mail:    # SMTP服务器域名    host: smtp.163.com    # 编码集    default-encoding: UTF-8    # 邮箱用户名    username: csp******@163.com    # 授权码(注意不是邮箱密码!)    password: WDS*******XCQA    # 协议:smtps    protocol: smtps    # 详细配置    properties:      mail:        smtp:          # 设置是否需要认证,如果为true,那么用户名和密码就必须的,          # 如果设置false,可以不设置用户名和密码          # (前提要知道对接的平台是否支持无密码进行访问的)          auth: true          # STARTTLS[1]  是对纯文本通信协议的扩展。          # 它提供一种方式将纯文本连接升级为加密连接(TLS或SSL)          # 而不是另外使用一个端口作加密通信。          starttls:            enable: true            required: true

2.4 邮件发送工具类

/** * @Auther: csp1999 * @Date: 2020/11/24/14:29 * @Description: 邮件发送客户端 */@Componentpublic class MailClient {
private static final Logger logger = LoggerFactory.getLogger(MailClient.class); @Autowired private JavaMailSender mailSender; @Value("${spring.mail.username}") private String from; /** * 发送邮件 * @param to 收件人 * @param subject 邮件主题 * @param content 邮件内容 */ public void sendMail(String to,String subject,String content){
try {
MimeMessage message = mailSender.createMimeMessage(); MimeMessageHelper helper = new MimeMessageHelper(message); helper.setFrom(from);// 发送者 helper.setTo(to);// 接收者 helper.setSubject(subject);// 邮件主题 helper.setText(content,true);// 邮件内容,第二个参数true表示支持html格式 mailSender.send(helper.getMimeMessage()); } catch (MessagingException e) {
logger.error("发送邮件失败: " + e.getMessage()); } }}

2.5 测试发送

@Autowiredprivate MailClient mailClient;@Testvoid test02(){
mailClient.sendMail("11xxxxxxx@qq.com","TEST","测试邮件发送!");}

在这里插入图片描述

测试发送邮件成功!

2.6 使用Thymleaf模板引擎发送html格式的邮件

...    	// 激活邮件发送    Context context = new Context();// org.thymeleaf.context.Context 包下    context.setVariable("email", user.getEmail());    // http://csp1999.natapp1.cc/community/activation/用户id/激活码    String url = path + contextPath + "/activation/" + user.getId() + "/" + user.getActivatio    context.setVariable("url", url);    String content = templateEngine.process("/mail/activation", context);    mailClient.sendMail(user.getEmail(), "激活账号", content);...

在这里插入图片描述

3. 登录与注册功能

  • 登录注册功能的验证码目前是存放在Session中,之后要存入Redis,提高性能,同时也可以解决分布式部署时的Session共享问题!
  • 注册功能的邮件发送,比较费时,用户只能干等待邮件发送成功,这种方式不太友好,因此在后端以多线程的方式,分一个线程去处理邮件发送,进而不影响客户端正常给用户的响应问题,不用让用户在页面卡太长时间!
  • 对于登录用户信息判定(比如,账号密码是否错误,用户名是否存在,用户是否激活)等问题,如果每次都查询数据库,效率比较低,为此我们在客户端发送请求——>后端调用数据库,之间加一层 Redis 缓存,来验证用户登录信息是否合法!

3.1 登录功能

在这里插入图片描述

3.2 注册功能

在这里插入图片描述

4.通过cookie获取user登录信息

客户端通过cookie携带登录凭证向服务器换取user信息,流程如图:

在这里插入图片描述

这一流程需要借助拦截器LoginTicketInterceptor 和 LoginRequiredInterceptor实现

LoginTicketInterceptor.java 登录凭证拦截器

/** * @Auther: csp1999 * @Date: 2020/11/24/20:54 * @Description: 登录凭证拦截器 */@Componentpublic class LoginTicketInterceptor implements HandlerInterceptor {
@Autowired private UserService userService; @Autowired private HostHolder hostHolder; /** * 请求开始前 * @param request * @param response * @param handler * @return * @throws Exception */ @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 从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()); // 在本次请求中(当前线程)持有该用户信息(要考虑多线程并发的情况,所以借助ThreadLocal) hostHolder.setUser(user); } } return true; } /** * 执行请求时 * @param request * @param response * @param handler * @param modelAndView * @throws Exception */ @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
// 从ThreadLocal 中得到当前线程持有的user User user = hostHolder.getUser(); if (user != null && modelAndView != null) {
// 登录用户的信息存入modelAndView modelAndView.addObject("loginUser", user); } } /** * 请求结束后 * @param request * @param response * @param handler * @param ex * @throws Exception */ @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 从ThreadLocal清除数据 hostHolder.clear(); }}

LoginRequiredInterceptor.java 登录请求拦截器

/** * @Auther: csp1999 * @Date: 2020/11/24/21:27 * @Description: 登录请求拦截器 */@Componentpublic class LoginRequiredInterceptor implements HandlerInterceptor {
@Autowired private HostHolder hostHolder; /** * 请求开始前 * * @param request * @param response * @param handler * @return * @throws Exception */ @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 判断handler 是否是 HandlerMethod 类型 if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler; // 获取到方法实例 Method method = handlerMethod.getMethod(); // 从方法实例中获得其 LoginRequired 注解 LoginRequired loginRequired = method.getAnnotation(LoginRequired.class); // 如果方法实例上标注有 LoginRequired 注解,但 hostHandler中没有 用户信息则拦截 if (loginRequired != null && hostHolder.getUser() == null) {
response.sendRedirect(request.getContextPath() + "/login"); return false; } } return true; }

将拦截器注册到spring容器中

/** * @Auther: csp1999 * @Date: 2020/11/24/20:53 * @Description: 拦截器配置类 */@Configurationpublic class WebMvcConfig implements WebMvcConfigurer {
@Autowired private LoginTicketInterceptor loginTicketInterceptor; @Autowired private LoginRequiredInterceptor loginRequiredInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginTicketInterceptor) // 除了静态资源不拦截,其他都拦截 .excludePathPatterns("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg"); registry.addInterceptor(loginRequiredInterceptor) // 除了静态资源不拦截,其他都拦截 .excludePathPatterns("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg"); }}

5. 文件/头像上传服务器

5.1 效果展示

上传头像:

在这里插入图片描述

头像上传成功:

在这里插入图片描述

5.2 阿里云OSS文件存储

入门参考文章:

AliyunOssConfig

/** * @Auther: csp1999 * @Date: 2020/10/31/13:33 * @Description: 阿里云 OSS 基本配置 */// 声明配置类,放入Spring容器@Configuration// 指定配置文件位置@PropertySource(value = {
"classpath:application-aliyun-oss.properties"})// 指定配置文件中自定义属性前缀@ConfigurationProperties(prefix = "aliyun")@Data// lombok@Accessors(chain = true)// 开启链式调用public class AliyunOssConfig {
private String endPoint;// 地域节点 private String accessKeyId; private String accessKeySecret; private String bucketName;// OSS的Bucket名称 private String urlPrefix;// Bucket 域名 private String fileHost;// 目标文件夹 // 将OSS 客户端交给Spring容器托管 @Bean public OSS OSSClient() {
return new OSSClient(endPoint, accessKeyId, accessKeySecret); }}

FileUploadService

/** * @Auther: csp1999 * @Date: 2020/10/31/14:30 * @Description: 文件上传Service (为节省文章中的代码篇幅,不再做接口实现类处理) */@Service("fileUploadService")public class FileUploadService {
// 允许上传文件(图片)的格式 private static final String[] IMAGE_TYPE = new String[]{
".bmp", ".jpg", ".jpeg", ".gif", ".png"}; private static final Logger logger = LoggerFactory.getLogger(FileUploadService.class); @Autowired private OSS ossClient;// 注入阿里云oss文件服务器客户端 @Autowired private AliyunOssConfig aliyunOssConfig;// 注入阿里云OSS基本配置类 /** * 文件上传 * 注:阿里云OSS文件上传官方文档链接:https://help.aliyun.com/document_detail/84781.html?spm=a2c4g.11186623.6.749.11987a7dRYVSzn * * @param: uploadFile * @return: string * @create: 2020/10/31 14:36 * @author: csp1999 */ public String upload(MultipartFile uploadFile) {
// 获取oss的Bucket名称 String bucketName = aliyunOssConfig.getBucketName(); // 获取oss的地域节点 String endpoint = aliyunOssConfig.getEndPoint(); // 获取oss的AccessKeySecret String accessKeySecret = aliyunOssConfig.getAccessKeySecret(); // 获取oss的AccessKeyId String accessKeyId = aliyunOssConfig.getAccessKeyId(); // 获取oss目标文件夹 String filehost = aliyunOssConfig.getFileHost(); // 返回图片上传后返回的url String returnImgeUrl = ""; // 校验图片格式 boolean isLegal = false; for (String type : IMAGE_TYPE) {
if (StringUtils.endsWithIgnoreCase(uploadFile.getOriginalFilename(), type)) {
isLegal = true; break; } } if (!isLegal) {
// 如果图片格式不合法 logger.info("图片格式不符合要求..."); } // 获取文件原名称 String originalFilename = uploadFile.getOriginalFilename(); // 获取文件类型 String fileType = originalFilename.substring(originalFilename.lastIndexOf(".")); // 新文件名称 String newFileName = UUID.randomUUID().toString() + fileType; // 构建日期路径, 例如:OSS目标文件夹/2020/10/31/文件名 String filePath = new SimpleDateFormat("yyyy/MM/dd").format(new Date()); // 文件上传的路径地址 String uploadImgeUrl = filehost + "/" + filePath + "/" + newFileName; // 获取文件输入流 InputStream inputStream = null; try {
inputStream = uploadFile.getInputStream(); } catch (IOException e) {
e.printStackTrace(); } /** * 下面两行代码是重点坑: * 现在阿里云OSS 默认图片上传ContentType是image/jpeg * 也就是说,获取图片链接后,图片是下载链接,而并非在线浏览链接, * 因此,这里在上传的时候要解决ContentType的问题,将其改为image/jpg */ ObjectMetadata meta = new ObjectMetadata(); meta.setContentType("image/jpg"); //文件上传至阿里云OSS ossClient.putObject(bucketName, uploadImgeUrl, inputStream, meta); /** * 注意:在实际项目中,文件上传成功后,数据库中存储文件地址 */ // 获取文件上传后的图片返回地址 returnImgeUrl = "http://" + bucketName + "." + endpoint + "/" + uploadImgeUrl; return returnImgeUrl; }}

6. 敏感词过滤

使用前缀树的数据结构,来进行敏感词过滤:

  • 第一步:在resource 目录下新建 sensitive-words.txt 敏感词文本文件
  • 第二步:新建一个敏感词过滤组件 SensitiveFilter
/** * @Auther: csp1999 * @Date: 2020/11/25/10:56 * @Description: 敏感词过滤组件 */@Componentpublic class SensitiveFilter {
private static final Logger logger = LoggerFactory.getLogger(SensitiveFilter.class); // 替换符 private static final String REPLACEMENT = "***"; // 根节点 private TrieNode rootNode = new TrieNode(); @PostConstruct public void init() {
try ( InputStream is = this.getClass().getClassLoader().getResourceAsStream("sensitive-words.txt"); BufferedReader reader = new BufferedReader(new InputStreamReader(is)); ) {
String keyword; while ((keyword = reader.readLine()) != null) {
// 添加到前缀树 this.addKeyword(keyword); } } catch (IOException e) {
logger.error("加载敏感词文件失败: " + e.getMessage()); } } // 将一个敏感词添加到前缀树中 private void addKeyword(String keyword) {
TrieNode tempNode = rootNode; for (int i = 0; i < keyword.length(); i++) {
char c = keyword.charAt(i); TrieNode subNode = tempNode.getSubNode(c); if (subNode == null) {
// 初始化子节点 subNode = new TrieNode(); tempNode.addSubNode(c, subNode); } // 指向子节点,进入下一轮循环 tempNode = subNode; // 设置结束标识 if (i == keyword.length() - 1) {
tempNode.setKeywordEnd(true); } } } /** * 过滤敏感词 * * @param text 待过滤的文本 * @return 过滤后的文本 */ public String filter(String text) {
if (StringUtils.isBlank(text)) {
return null; } // 指针1 TrieNode tempNode = rootNode; // 指针2 int begin = 0; // 指针3 int position = 0; // 结果 StringBuilder sb = new StringBuilder(); while (position < text.length()) {
char c = text.charAt(position); // 跳过符号 if (isSymbol(c)) {
// 若指针1处于根节点,将此符号计入结果,让指针2向下走一步 if (tempNode == rootNode) {
sb.append(c); begin++; } // 无论符号在开头或中间,指针3都向下走一步 position++; continue; } // 检查下级节点 tempNode = tempNode.getSubNode(c); if (tempNode == null) {
// 以begin开头的字符串不是敏感词 sb.append(text.charAt(begin)); // 进入下一个位置 position = ++begin; // 重新指向根节点 tempNode = rootNode; } else if (tempNode.isKeywordEnd()) {
// 发现敏感词,将begin~position字符串替换掉 sb.append(REPLACEMENT); // 进入下一个位置 begin = ++position; // 重新指向根节点 tempNode = rootNode; } else {
// 检查下一个字符 position++; } } // 将最后一批字符计入结果 sb.append(text.substring(begin)); return sb.toString(); } // 判断是否为符号 private boolean isSymbol(Character c) {
// 0x2E80~0x9FFF 是东亚文字范围 return !CharUtils.isAsciiAlphanumeric(c) && (c < 0x2E80 || c > 0x9FFF); } // 前缀树 private class TrieNode {
// 关键词结束标识 private boolean isKeywordEnd = false; // 子节点(key是下级字符,value是下级节点) private Map
subNodes = new HashMap<>(); public boolean isKeywordEnd() {
return isKeywordEnd; } public void setKeywordEnd(boolean keywordEnd) {
isKeywordEnd = keywordEnd; } // 添加子节点 public void addSubNode(Character c, TrieNode node) {
subNodes.put(c, node); } // 获取子节点 public TrieNode getSubNode(Character c) {
return subNodes.get(c); } }}

效果如下图:

在这里插入图片描述

7.帖子发布与帖子评论

7.1 帖子发布

在这里插入图片描述

7.2 帖子评论

在这里插入图片描述

8. 私信列表与私信会话聊天

8.1 效果如图

私信列表

在这里插入图片描述

私信详情

在这里插入图片描述

私信发送

在这里插入图片描述

8.2 DAO层代码

Mapper接口

/** * @Auther: csp1999 * @Date: 2020/11/26/16:29 * @Description: */@Repositorypublic interface MessageMapper {
/** * 查询当前用户的会话列表,针对每个会话只返回一条最新的私信. * @param userId * @param offset * @param limit * @return */ List
selectConversations(@Param("userId") int userId, @Param("offset")int offset, @Param("limit") int limit); /** * 查询当前用户的会话数量. * @param userId * @return */ int selectConversationCount(@Param("userId")int userId); /** * 查询某个会话所包含的私信列表. * @param conversationId * @param offset * @param limit * @return */ List
selectLetters(@Param("conversationId")String conversationId, @Param("offset")int offset, @Param("limit")int limit); /** * 查询某个会话所包含的私信数量. * @param conversationId * @return */ int selectLetterCount(@Param("conversationId")String conversationId); /** * 查询未读私信的数量 * @param userId * @param conversationId * @return */ int selectLetterUnreadCount(@Param("userId")int userId, @Param("conversationId")String conversationId); /** * 新增消息 * @param message * @return */ int insertMessage(Message message); /** * 修改消息的状态 * @param ids * @param status * @return */ int updateStatus(@Param("ids")List
ids, @Param("status")int status);}

SQL实现

考验sql能力的时候到了(∩_∩)!

id, from_id, to_id, conversation_id, content, status, create_time
from_id, to_id, conversation_id, content, status, create_time
insert into message(
) values(#{fromId},#{toId},#{conversationId},#{content},#{status},#{createTime})
update message set status = #{status} where id in
#{id}

Controller API

/** * @Auther: csp1999 * @Date: 2020/11/26/17:42 * @Description: */@Controllerpublic class MessageController {
@Autowired private MessageService messageService; @Autowired private HostHolder hostHolder; @Autowired private UserService userService; /** * 获取用户私信列表(支持分页) api * * @param model * @param page * @return */ @RequestMapping(path = "/letter/list", method = RequestMethod.GET) public String getLetterList(Model model, Page page) {
User user = hostHolder.getUser(); // 分页信息 page.setLimit(5); page.setPath("/letter/list"); page.setRows(messageService.findConversationCount(user.getId())); // 会话列表 List
conversationList = messageService.findConversations( user.getId(), page.getOffset(), page.getLimit()); List
> conversations = new ArrayList<>(); if (conversationList != null) {
for (Message message : conversationList) {
Map
map = new HashMap<>(); // 会话 map.put("conversation", message); // 会话中的消息数量 map.put("letterCount", messageService.findLetterCount(message.getConversationId())); // 会话中的未读消息数量 map.put("unreadCount", messageService.findLetterUnreadCount(user.getId(), message.getConversationId())); int targetId = user.getId() == message.getFromId() ? message.getToId() : message.getFromId(); // 目标id(消息接收者id) map.put("target", userService.findUserById(targetId)); // 该会话加入会话列表 conversations.add(map); } } // 会话列表加入model中 model.addAttribute("conversations", conversations); // 查询未读消息数量 int letterUnreadCount = messageService.findLetterUnreadCount(user.getId(), null); // 未读消息数量加入model中 model.addAttribute("letterUnreadCount", letterUnreadCount); return "/site/letter"; } /** * 私信详情 api * @param conversationId * @param page * @param model * @return */ @RequestMapping(path = "/letter/detail/{conversationId}", method = RequestMethod.GET) public String getLetterDetail( @PathVariable("conversationId") String conversationId, Page page, Model model) {
// 分页信息 page.setLimit(5); page.setPath("/letter/detail/" + conversationId); page.setRows(messageService.findLetterCount(conversationId)); // 私信列表 List
letterList = messageService.findLetters(conversationId, page.getOffset(), page.getLimit()); List
> letters = new ArrayList<>(); if (letterList != null) { for (Message message : letterList) { Map
map = new HashMap<>(); // 会话消息 map.put("letter", message); // 消息发送者信息 map.put("fromUser", userService.findUserById(message.getFromId())); letters.add(map); } } // 会话消息列表存入model model.addAttribute("letters", letters); // 私信目标存入model model.addAttribute("target", getLetterTarget(conversationId)); // 设置已读 List
ids = getLetterIds(letterList); if (!ids.isEmpty()) { messageService.readMessage(ids); } return "/site/letter-detail"; } // 获取私信目标信息 private User getLetterTarget(String conversationId) { // 分割conversationId eg: 111_112 ---> [111,222] String[] ids = conversationId.split("_"); int id0 = Integer.parseInt(ids[0]); int id1 = Integer.parseInt(ids[1]); if (hostHolder.getUser().getId() == id0) { return userService.findUserById(id1); } else { return userService.findUserById(id0); } } // 根据会话消息id集合批量签收(读取)多条消息 private List
getLetterIds(List
letterList) { List
ids = new ArrayList<>(); if (letterList != null) { for (Message message : letterList) { if (hostHolder.getUser().getId() == message.getToId() && message.getStatus() == 0) { ids.add(message.getId()); } } } return ids; } /** * 私信发送操作 * @param toName * @param content * @return */ @RequestMapping(path = "/letter/send", method = RequestMethod.POST) @ResponseBody public String sendLetter(String toName, String content) { User target = userService.findUserByName(toName); if (target == null) { return CommunityUtil.getJSONString(1, "目标用户不存在!"); } // 开始构建会话消息对象 Message message = new Message(); message.setFromId(hostHolder.getUser().getId()); message.setToId(target.getId()); if (message.getFromId() < message.getToId()) { message.setConversationId(message.getFromId() + "_" + message.getToId()); } else { message.setConversationId(message.getToId() + "_" + message.getFromId()); } message.setContent(content); message.setCreateTime(new Date()); messageService.addMessage(message); return CommunityUtil.getJSONString(0); }}

9. 全局异常捕获与处理

404页面展示

错误页面展示

在这里插入图片描述

统一异常处理

相关注解介绍

  • @ControllerAdvice
    • 用于修饰类,表示该类是Controller 的全局适配类。
    • 在此类中,可以对Controller 进行如下三种全局配置:
      • 🚡 异常处理方案
      • 👶 绑定数据方案
      • 🐤 绑定参数方案
  • @ExceptionHandler(我们实例中使用则个注解修饰方法)
    • 用于修饰方法,该方法会在Controller 出现异常后被调用,用于处理捕获到的异常
  • @ModelAttribute
    • 用于修饰方法,该方法回在Controller 方法执行前被调用,用于Model 对象绑定参数
  • @DataBinder
    • 用于修饰方法,该方法回在Controller 方法执行前被调用,用于绑定参数的转换器

实例代码

/** * @Auther: csp1999 * @Date: 2020/11/26/20:39 * @Description: 异常通知类 */// 所有带 @Controller 注解的类都会被扫描到@ControllerAdvice(annotations = Controller.class)public class ExceptionAdvice {
// 声明日志工厂 private static final Logger logger = LoggerFactory.getLogger(ExceptionAdvice.class); /** * 自定义异常处理器,覆盖spring boot原来的异常处理器 * @param e 异常对象 * @param request 请求 * @param response 响应 * @throws IOException */ @ExceptionHandler({
Exception.class})// 标识该方法是用来做异常处理的,处理的异常级别为Exception public void handleException(Exception e, HttpServletRequest request, HttpServletResponse response) throws IOException {
// 记录异常信息 logger.error("请求 URL : {} , 异常信息 : {}",request.getRequestURL(),e); // 逐条记录错误日志 for (StackTraceElement element : e.getStackTrace()) {
logger.error(element.toString()); } 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(1, "服务器异常!")); } else {
response.sendRedirect(request.getContextPath() + "/error"); } }}

切面统一记录日志

/** * @Auther: csp1999 * @Date: 2020/11/26/21:12 * @Description: 自定义日志处理组件 */@Component@Aspectpublic class ServiceLogAspect {
// 获取日志工厂 private static final Logger logger = LoggerFactory.getLogger(ServiceLogAspect.class); /** * 切入点 */ @Pointcut("execution(* com.haust.community.service.*.*(..))") public void pointcut() {
} /** * 前置通知,在切面之前执行 * * @param joinPoint */ @Before("pointcut()") public void before(JoinPoint joinPoint) {
// 用户[1.2.3.4],在[xxx],访问了[com.nowcoder.community.service.xxx()]. ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); HttpServletRequest request = attributes.getRequest(); String ip = request.getRemoteHost(); String now = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()); String target = joinPoint.getSignature().getDeclaringTypeName() + "." + joinPoint.getSignature().getName(); logger.info(String.format("用户[%s],在[%s],访问了[%s].", ip, now, target)); } /** * 后置通知,在切面之后执行 */ @After("pointcut()") public void doAfter() {
logger.info("----------doAfter----------"); } /** * 切入点拦截的方法执行结束后,捕获返回内容 * * @param result */ @AfterReturning(returning = "result", pointcut = "pointcut()") public void doAfterRuturn(Object result) {
logger.info("捕获返回内容 : {}", result); }}

10. Redis 实现点赞/关注/粉丝列表

生成Redis Key的工具类:

/** * @Auther: csp1999 * @Date: 2020/11/27/20:34 * @Description: Redis Key的工具类 */public class RedisKeyUtil {
private static final String SPLIT = ":"; private static final String PREFIX_ENTITY_LIKE = "like:entity"; private static final String PREFIX_USER_LIKE = "like:user"; private static final String PREFIX_FOLLOWEE = "followee";// 关注某人 private static final String PREFIX_FOLLOWER = "follower";// 某人关注我 private static final String PREFIX_KAPTCHA = "kaptcha";// 验证码key的前缀 private static final String PREFIX_TICKET = "ticket";// 登录凭证key的前缀 private static final String PREFIX_USER = "user";// 登录用户key的前缀 /** * 某个实体赞的key *

* key ---> like:entity:entityType:entityId -> set(userId) * * @param entityType * @param entityId * @return */ public static String getEntityLikeKey(int entityType, int entityId) {

return PREFIX_ENTITY_LIKE + SPLIT + entityType + SPLIT + entityId; } /** * 某个用户累计的赞的key *

* key ---> like:user:userId -> int * * @param userId * @return */ public static String getUserLikeKey(int userId) {

return PREFIX_USER_LIKE + SPLIT + userId; } /** * 某个用户关注某个实体的集合key *

* key * ---> followee:userId:entityType * ---> zset(entityId,now),zset为有序集合,以now作为排序分数 * now表示当前时间的时间数,可以根据时间大小排序 * * * @param userId * @param entityType * @return */ public static String getFolloweeKey(int userId, int entityType) {

return PREFIX_FOLLOWEE + SPLIT + userId + SPLIT + entityType; } /** * 某个实体拥有的粉丝集合key *

* key ---> follower:entityType:entityId -> zset(userId,now) * * @param entityType * @param entityId * @return */ public static String getFollowerKey(int entityType, int entityId) {

return PREFIX_FOLLOWER + SPLIT + entityType + SPLIT + entityId; } /** * 登录验证码的key * * @param owner 验证码所属者 * @return */ public static String getKaptchaKey(String owner) {
return PREFIX_KAPTCHA + SPLIT + owner; } /** * 登录的凭证的key * * @param ticket * @return */ public static String getTicketKey(String ticket) {
return PREFIX_TICKET + SPLIT + ticket; } /** * 用户的key * * @param userId * @return */ public static String getUserKey(int userId) {
return PREFIX_USER + SPLIT + userId; }}

Service层:

LikeService.java

/** * @Auther: csp1999 * @Date: 2020/11/27/20:39 * @Description: 点赞Service 存入Redis 缓存 */@Servicepublic class LikeService {
@Autowired private RedisTemplate redisTemplate; /** * 点赞操作 * * @param userId 点赞用户的id * @param entityType 点赞实体的类型 * @param entityId 点赞实体的id * @param entityUserId 被点赞实体的用户id */ public void like(int userId, int entityType, int entityId, int entityUserId) {
redisTemplate.execute(new SessionCallback() {
@Override public Object execute(RedisOperations operations) throws DataAccessException {
// 某帖子实体点赞集合的key String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId); // 某个用户累计的赞的key String userLikeKey = RedisKeyUtil.getUserLikeKey(entityUserId); // 查询entityLikeKey对应的集合中是否已经存在当前userId boolean isMember = operations.opsForSet().isMember(entityLikeKey, userId); // 开启redis事务 operations.multi(); // userId 已经存在,即当前用户已经为该帖子点过赞 if (isMember) {
// 取消这个赞 operations.opsForSet().remove(entityLikeKey, userId); // 被点赞用户获赞数减少1 operations.opsForValue().decrement(userLikeKey); } else {
// userId 不存在,即当前用户还没为该帖子点过赞,直接点赞即可 operations.opsForSet().add(entityLikeKey, userId); // 被点赞用户获赞数增加1 operations.opsForValue().increment(userLikeKey); } // 执行redis事务 return operations.exec(); } }); } /** * 查询某帖子实体点赞的数量 * * @param entityType * @param entityId * @return */ public long findEntityLikeCount(int entityType, int entityId) {
// 某帖子实体点赞集合的key String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId); // 统计该帖子实体的点赞集合中的数据数量,即点赞数 return redisTemplate.opsForSet().size(entityLikeKey); } /** * 查询某人对某帖子实体的点赞状态 * 0未点赞/1已点赞 * * @param userId * @param entityType * @param entityId * @return */ public int findEntityLikeStatus(int userId, int entityType, int entityId) {
// 某帖子实体点赞集合的key String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId); return redisTemplate.opsForSet().isMember(entityLikeKey, userId) ? 1 : 0; } /** * 查询某个用户累计获得的赞 * * @param userId * @return */ public int findUserLikeCount(int userId) {
String userLikeKey = RedisKeyUtil.getUserLikeKey(userId); Integer count = (Integer) redisTemplate.opsForValue().get(userLikeKey); return count == null ? 0 : count.intValue(); }}

FollowService.java

/** * @Auther: csp1999 * @Date: 2020/11/28/9:41 * @Description: 关注Service 存入Redis 缓存 */@Servicepublic class FollowService implements CommunityConstant {
@Autowired private RedisTemplate redisTemplate; @Autowired private UserService userService; /** * 用户关注了某个实体 * * @param userId * @param entityType * @param entityId */ public void follow(int userId, int entityType, int entityId) {
redisTemplate.execute(new SessionCallback() {
@Override public Object execute(RedisOperations operations) throws DataAccessException {
// 某个用户所关注的实体的集合key String followeeKey = RedisKeyUtil.getFolloweeKey(userId, entityType); // 某个实体拥有的粉丝集合key String followerKey = RedisKeyUtil.getFollowerKey(entityType, entityId); // 开启redis事务 operations.multi(); // 用户所关注的实体的集合新增一条关注数据 operations.opsForZSet().add(followeeKey, entityId, System.currentTimeMillis()); // 实体拥有的粉丝集合新增一个粉丝 operations.opsForZSet().add(followerKey, userId, System.currentTimeMillis()); // 执行redis事务 return operations.exec(); } }); } /** * 用户取消关注了某个实体 * * @param userId * @param entityType * @param entityId */ public void unfollow(int userId, int entityType, int entityId) {
redisTemplate.execute(new SessionCallback() {
@Override public Object execute(RedisOperations operations) throws DataAccessException {
// 某个用户所关注的实体的集合key String followeeKey = RedisKeyUtil.getFolloweeKey(userId, entityType); // 某个实体拥有的粉丝集合key String followerKey = RedisKeyUtil.getFollowerKey(entityType, entityId); // 开启redis事务 operations.multi(); // 用户关注所关注的实体的集合减少一条关注数据 operations.opsForZSet().remove(followeeKey, entityId); // 实体拥有的粉丝集合减少一个粉丝 operations.opsForZSet().remove(followerKey, userId); // 执行redis事务 return operations.exec(); } }); } /** * 查询关注的实体的数量 * * @param userId * @param entityType * @return */ public long findFolloweeCount(int userId, int entityType) {
// 某个用户所关注的实体的集合key String followeeKey = RedisKeyUtil.getFolloweeKey(userId, entityType); return redisTemplate.opsForZSet().zCard(followeeKey); } /** * 查询实体的粉丝的数量 * * @param entityType * @param entityId * @return */ public long findFollowerCount(int entityType, int entityId) {
// 某个实体拥有的粉丝集合key String followerKey = RedisKeyUtil.getFollowerKey(entityType, entityId); return redisTemplate.opsForZSet().zCard(followerKey); } /** * 查询当前用户是否已关注该实体 * * @param userId * @param entityType * @param entityId * @return */ public boolean hasFollowed(int userId, int entityType, int entityId) {
// 某个用户所关注的实体的集合key String followeeKey = RedisKeyUtil.getFolloweeKey(userId, entityType); return redisTemplate.opsForZSet().score(followeeKey, entityId) != null; } /** * 查询某用户关注的人 * * @param userId * @param offset * @param limit * @return */ public List
> findFollowees(int userId, int offset, int limit) {
// 某个用户所关注的用户集合key String followeeKey = RedisKeyUtil.getFolloweeKey(userId, ENTITY_TYPE_USER); // 分页 Set
targetIds = redisTemplate.opsForZSet().reverseRange(followeeKey, offset, offset + limit - 1); // 判空 if (targetIds == null) {
return null; } // 结果拼接封装成list List
> list = new ArrayList<>(); for (Integer targetId : targetIds) {
Map
map = new HashMap<>(); User user = userService.findUserById(targetId); // 存入用户信息 map.put("user", user); Double score = redisTemplate.opsForZSet().score(followeeKey, targetId); System.out.println(score+"====================================================="); // 存入时间(score中 存入的是日期毫秒数,Double类型) map.put("followTime", new Date(score.longValue())); list.add(map); } return list; } /** * 查询某用户的粉丝 * * @param userId * @param offset * @param limit * @return */ public List
> findFollowers(int userId, int offset, int limit) { // 某个实体拥有的粉丝集合key String followerKey = RedisKeyUtil.getFollowerKey(ENTITY_TYPE_USER, userId); // 分页 Set
targetIds = redisTemplate.opsForZSet().reverseRange(followerKey, offset, offset + limit - 1); // 判空 if (targetIds == null) { return null; } // 结果拼接封装成list List
> list = new ArrayList<>(); for (Integer targetId : targetIds) { Map
map = new HashMap<>(); User user = userService.findUserById(targetId); map.put("user", user); Double score = redisTemplate.opsForZSet().score(followerKey, targetId); map.put("followTime", new Date(score.longValue())); list.add(map); } return list; }}

controller层:

LikeController.java

/** * @Auther: csp1999 * @Date: 2020/11/27/20:59 * @Description: 点赞controller */@Controllerpublic class LikeController {
@Autowired private LikeService likeService; @Autowired private HostHolder hostHolder; /** * 用户点赞操作 * @param entityType 帖子实体类型 * @param entityId 帖子实体id * @param entityUserId 被点赞实体的用户id * @return */ @RequestMapping(path = "/like", method = RequestMethod.POST) @ResponseBody public String like(int entityType, int entityId, int entityUserId) {
// 获取登录用户 User user = hostHolder.getUser(); // 进行点赞 likeService.like(user.getId(), entityType, entityId, entityUserId); // 点赞的数量 long likeCount = likeService.findEntityLikeCount(entityType, entityId); // 点赞的状态 int likeStatus = likeService.findEntityLikeStatus(user.getId(), entityType, entityId); // 返回的结果 Map
map = new HashMap<>(); map.put("likeCount", likeCount); map.put("likeStatus", likeStatus); return CommunityUtil.getJSONString(0, null, map); }}

FllowController.java

/** * @Auther: csp1999 * @Date: 2020/11/28/9:48 * @Description: 关注Controller */@Controllerpublic class FollowController implements CommunityConstant {
@Autowired private FollowService followService; @Autowired private HostHolder hostHolder; @Autowired private UserService userService; /** * 关注操作 * * @param entityType * @param entityId * @return */ @RequestMapping(path = "/follow", method = RequestMethod.POST) @ResponseBody public String follow(int entityType, int entityId) {
// 获取当前用户 User user = hostHolder.getUser(); // 关注 followService.follow(user.getId(), entityType, entityId); return CommunityUtil.getJSONString(0, "已关注!"); } /** * 取消关注操作 * * @param entityType * @param entityId * @return */ @RequestMapping(path = "/unfollow", method = RequestMethod.POST) @ResponseBody public String unfollow(int entityType, int entityId) {
// 获取当前用户 User user = hostHolder.getUser(); // 取消关注 followService.unfollow(user.getId(), entityType, entityId); return CommunityUtil.getJSONString(0, "已取消关注!"); } /** * 查询"我"关注的人 * * @param userId * @param page * @param model * @return */ @RequestMapping(path = "/followees/{userId}", method = RequestMethod.GET) public String getFollowees(@PathVariable("userId") int userId, Page page, Model model) {
// 查询用户信息 User user = userService.findUserById(userId); if (user == null) {
throw new RuntimeException("该用户不存在!"); } // user 存入 model model.addAttribute("user", user); page.setLimit(5); page.setPath("/followees/" + userId); page.setRows((int) followService.findFolloweeCount(userId, ENTITY_TYPE_USER)); // 查询用户关注的人的 list 列表 List
> userList = followService.findFollowees(userId, page.getOffset(), page.getLimit()); // 循环list 并拼接每条用户信息 if (userList != null) {
for (Map
map : userList) {
User u = (User) map.get("user"); // 判断当前用户是否已经关注了某个用户 map.put("hasFollowed", hasFollowed(u.getId())); } } // userList 存入 model model.addAttribute("users", userList); return "/site/followee"; } /** * 查询关注"我"的人(粉丝) * * @param userId * @param page * @param model * @return */ @RequestMapping(path = "/followers/{userId}", method = RequestMethod.GET) public String getFollowers(@PathVariable("userId") int userId, Page page, Model model) {
// 查询用户信息 User user = userService.findUserById(userId); if (user == null) {
throw new RuntimeException("该用户不存在!"); } // user 存入 model model.addAttribute("user", user); page.setLimit(5); page.setPath("/followers/" + userId); page.setRows((int) followService.findFollowerCount(ENTITY_TYPE_USER, userId)); // 查询用户粉丝的 list 列表 List
> userList = followService.findFollowers(userId, page.getOffset(), page.getLimit()); // 循环list 并拼接每条用户信息 if (userList != null) {
for (Map
map : userList) { User u = (User) map.get("user"); // 判断当前用户是否已经关注了某个用户 map.put("hasFollowed", hasFollowed(u.getId())); } } // userList 存入 model model.addAttribute("users", userList); return "/site/follower"; } // 判断当前用户是否已经关注了某个用户 private boolean hasFollowed(int userId) { // 判断当前用户是否登录 if (hostHolder.getUser() == null) { return false; } // 判断当前用户是否已经关注了某个用户 return followService.hasFollowed(hostHolder.getUser().getId(), ENTITY_TYPE_USER, userId); }}

11. Kafka 构建异步消息系统(消息发送和通知)

Kafka 的下载与安装,参考

在这里插入图片描述

效果如图:

在这里插入图片描述

12. ElasticSearch 实现社区全局搜索功能

12.1 ES 介绍

在这里插入图片描述

  • 官网地址:

  • 官方文档地址:

  • 下载地址:

ES 入门参考文章:

12.2 论坛搜索的功能实现

在这里插入图片描述

12.3 效果展示

在这里插入图片描述

13. Spring Security 权限控制

13.1 简介

在这里插入图片描述

  • 入门参考文章:
  • 进阶参考文章:[http://www.spring4all.com/article/428][http://www.spring4all.com/article/428]
  • 案例Demo参考文章:

13.2 项目中的权限控制介绍

在这里插入图片描述

13.3 使用Spring Security 替代原来的登录拦截器

13.3.1 pom.xml 中新增相关依赖

org.springframework.boot
spring-boot-starter-security

13.3.2 SecurityConfig 配置类

首先先从WebMvcConfig.java 中 注释掉原来自定义的登录拦截器注册:LoginRequiredInterceptor

然后再进行Spring Security 相关配置:

/** * @Auther: csp1999 * @Date: 2020/12/03/9:57 * @Description: Spring Security 配置类 */@Configurationpublic class SecurityConfig extends WebSecurityConfigurerAdapter implements CommunityConstant {
/** * 对要拦截的目标资源进行配置 * * @param web * @throws Exception */ @Override public void configure(WebSecurity web) throws Exception {
// 忽略拦截 resources 下的所有静态资源 web.ignoring().antMatchers("/resources/**"); } /** * 用于对授权进行处理(核心) * * @param http * @throws Exception */ @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/**" ) // 只有具有以下列出的权限才可以访问 .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 {
// 如果请求x-requested-with 中头包含XMLHttpRequest 说明是异步请求 String xRequestedWith = request.getHeader("x-requested-with"); if ("XMLHttpRequest".equals(xRequestedWith)) {
// 设置响应体是json 格式(因为是异步请求,所以返回内容要是json格式) 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)) {
// 设置响应体是json 格式(因为是异步请求,所以返回内容要是json格式) 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"); }}

14. 帖子的置顶、加精、删除

技术介绍:Thymleaf Extras SpringSecurity5

在这里插入图片描述

14.1 引入依赖

org.thymeleaf.extras
thymeleaf-extras-springsecurity5
3.0.4.RELEASE

14.2 DiscussPostController 中新增三个接口

对应Mapper 和 Service 层的修改直接看完整代码即可!

/** * 帖子置顶操作 * * @param id * @return */@RequestMapping(path = "/top", method = RequestMethod.POST)@ResponseBodypublic String setTop(int id) {
discussPostService.updateType(id, 1); // 触发发帖事件 // 帖子帖子后,触发事件:将刚帖子帖子的消息通知订阅的消费者 // 消费者在消费帖子类型事件时,会将帖子信息 传递到 ES 服务器存储/更新数据 Event event = new Event() .setTopic(TOPIC_PUBLISH)// 主题: 发帖 .setUserId(hostHolder.getUser().getId())// 登录用户id .setEntityType(ENTITY_TYPE_POST)// 实体类型: 帖子 .setEntityId(id);// 实体id eventProducer.fireEvent(event); return CommunityUtil.getJSONString(0);}/** * 帖子加精操作 * * @param id * @return */@RequestMapping(path = "/wonderful", method = RequestMethod.POST)@ResponseBodypublic String setWonderful(int id) {
discussPostService.updateStatus(id, 1); // 触发发帖事件 // 加精帖子后,触发事件:将刚加精帖子的消息通知订阅的消费者 // 消费者在消费帖子类型事件时,会将帖子信息 传递到 ES 服务器存储/更新数据 Event event = new Event() .setTopic(TOPIC_PUBLISH)// 主题: 发帖 .setUserId(hostHolder.getUser().getId())// 登录用户id .setEntityType(ENTITY_TYPE_POST)// 实体类型: 帖子 .setEntityId(id);// 实体id eventProducer.fireEvent(event); return CommunityUtil.getJSONString(0);}/** * 帖子删除操作 * * @param id * @return */@RequestMapping(path = "/delete", method = RequestMethod.POST)@ResponseBodypublic String setDelete(int id) {
discussPostService.updateStatus(id, 2); // 触发删帖事件 // 删除帖子后,触发事件:将刚删除帖子的消息通知订阅的消费者 // 消费者在消费帖子类型事件时,会将帖子信息 传递到 ES 服务器存储/更新数据 Event event = new Event() .setTopic(TOPIC_DELETE)// 主题: 删帖 .setUserId(hostHolder.getUser().getId())// 登录用户id .setEntityType(ENTITY_TYPE_POST)// 实体类型: 帖子 .setEntityId(id);// 实体id eventProducer.fireEvent(event); return CommunityUtil.getJSONString(0);}

14.3 效果展示

在这里插入图片描述

15. 网站数据统计

在这里插入图片描述

声明:HyperLogLogBitmap 都是 Redis 中的高级数据类型!

15.1 RedisKeyUtil 中添加行营的key 和方法

private static final String PREFIX_UV = "uv";// 独立访客(通过用户IP地址排重统计)private static final String PREFIX_DAU = "dau";// 日活跃用户(通过ID排重统计)/** * 获取单日UV集合(HyperLogLog)的key * @param date * @return */public static String getUVKey(String date) {
return PREFIX_UV + SPLIT + date;}/** * 获取区间UV(两个日期之间统计的UV)集合(HyperLogLog)的key * @param startDate * @param endDate * @return */public static String getUVKey(String startDate, String endDate) {
return PREFIX_UV + SPLIT + startDate + SPLIT + endDate;}/** * 获取单日活跃用户集合(Bitmap)的key * @param date * @return */public static String getDAUKey(String date) {
return PREFIX_DAU + SPLIT + date;}/** * 获取区间活跃用户(两个日期之间统计的活跃用户)集合(Bitmap)的key * @param startDate * @param endDate * @return */public static String getDAUKey(String startDate, String endDate) {
return PREFIX_DAU + SPLIT + startDate + SPLIT + endDate;}

15.2 DataService

/** * @Auther: csp1999 * @Date: 2020/12/03/18:05 * @Description: 网站数据统计相关的Service */@Servicepublic class DataService {
@Autowired private RedisTemplate redisTemplate; private SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMdd"); /** * 将指定的IP计入UV * * @param ip */ public void recordUV(String ip) {
// 获取单日UV集合(HyperLogLog)的key String redisKey = RedisKeyUtil.getUVKey(simpleDateFormat.format(new Date())); // 将数据记录到指定redisKey的HyperLogLog中 redisTemplate.opsForHyperLogLog().add(redisKey, ip); } /** * 统计指定日期时间段范围内的UV * * @param start * @param end * @return */ public long calculateUV(Date start, Date end) {
if (start == null || end == null) {
throw new IllegalArgumentException("参数不能为空!"); } // keyList 用于整理该日期范围内的key List
keyList = new ArrayList<>(); // Calendar 用于对日期进行运算 Calendar calendar = Calendar.getInstance(); calendar.setTime(start); // !calendar.getTime().after(end) 当前时间的不晚于 end的时间时,进行while循环 while (!calendar.getTime().after(end)) {
// 获取单日UV集合(HyperLogLog)的key String key = RedisKeyUtil.getUVKey(simpleDateFormat.format(calendar.getTime())); // 将key 存入集合 keyList.add(key); // 日期时间向后推一天 calendar.add(Calendar.DATE, 1); } // 获取区间UV(两个日期之间统计的UV)集合(HyperLogLog)的key String redisKey = RedisKeyUtil.getUVKey(simpleDateFormat.format(start), simpleDateFormat.format(end)); // 合并redisKey对应的HyperLogLog集合和keyList集合 redisTemplate.opsForHyperLogLog().union(redisKey, keyList.toArray()); // 返回HyperLogLog中统计的数量 return redisTemplate.opsForHyperLogLog().size(redisKey); } /** * 将指定用户计入DAU * * @param userId */ public void recordDAU(int userId) {
// 获取单日活跃用户集合(Bitmap)的key String redisKey = RedisKeyUtil.getDAUKey(simpleDateFormat.format(new Date())); // 将数据记录到指定redisKey的Bitmap中,第三个参数表示是否活跃,true表示活跃 redisTemplate.opsForValue().setBit(redisKey, userId, true); } /** * 统计指定日期范围内的DAU * * @param start * @param end * @return */ public long calculateDAU(Date start, Date end) {
if (start == null || end == null) {
throw new IllegalArgumentException("参数不能为空!"); } // keyList 用于整理该日期范围内的key List
keyList = new ArrayList<>(); // Calendar 用于对日期进行运算 Calendar calendar = Calendar.getInstance(); calendar.setTime(start); // !calendar.getTime().after(end) 当前时间的不晚于 end的时间时,进行while循环 while (!calendar.getTime().after(end)) {
// 获取单日活跃用户集合(Bitmap)的key String key = RedisKeyUtil.getDAUKey(simpleDateFormat.format(calendar.getTime())); // 将key 存入集合(参数为key的byte数组) 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(simpleDateFormat.format(start), simpleDateFormat.format(end)); connection.bitOp(RedisStringCommands.BitOperation.OR, redisKey.getBytes(), keyList.toArray(new byte[0][0])); return connection.bitCount(redisKey.getBytes()); } }); }}

15.3 使用DataInterceptor拦截器来做访客统计

/** * @Auther: csp1999 * @Date: 2020/12/03/18:34 * @Description: 访问统计UV(独立访客)/DAI(日活跃用户)的拦截器 */@Componentpublic 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(独立访客) // 获得访客的IP String ip = request.getRemoteHost(); // 将指定的IP计入UV dataService.recordUV(ip); // 统计DAU(日活跃用户) // 获取登录用户对象 User user = hostHolder.getUser(); if (user != null) {
// 将指定用户计入DAU dataService.recordDAU(user.getId()); } return true; }}

拦截器写好后,将其注册的 WebMvcConfig 中去!

16. 任务执行和任务调度

实现方式可以选择以下三种方式

  • JDK 线程池
    • ExecutorService
    • ScheduledExecutorService
  • Spring 线程池
    • ThreadPoolTaskExecutor
    • ThreadPoolTaskScheduler
  • 分布式定时任务
    • Spring Quartz

16.1 数据库sql

DROP TABLE IF EXISTS QRTZ_FIRED_TRIGGERS;DROP TABLE IF EXISTS QRTZ_PAUSED_TRIGGER_GRPS;DROP TABLE IF EXISTS QRTZ_SCHEDULER_STATE;DROP TABLE IF EXISTS QRTZ_LOCKS;DROP TABLE IF EXISTS QRTZ_SIMPLE_TRIGGERS;DROP TABLE IF EXISTS QRTZ_SIMPROP_TRIGGERS;DROP TABLE IF EXISTS QRTZ_CRON_TRIGGERS;DROP TABLE IF EXISTS QRTZ_BLOB_TRIGGERS;DROP TABLE IF EXISTS QRTZ_TRIGGERS;DROP TABLE IF EXISTS QRTZ_JOB_DETAILS;DROP TABLE IF EXISTS QRTZ_CALENDARS;CREATE TABLE QRTZ_JOB_DETAILS(SCHED_NAME VARCHAR(120) NOT NULL,JOB_NAME VARCHAR(190) NOT NULL,JOB_GROUP VARCHAR(190) NOT NULL,DESCRIPTION VARCHAR(250) NULL,JOB_CLASS_NAME VARCHAR(250) NOT NULL,IS_DURABLE VARCHAR(1) NOT NULL,IS_NONCONCURRENT VARCHAR(1) NOT NULL,IS_UPDATE_DATA VARCHAR(1) NOT NULL,REQUESTS_RECOVERY VARCHAR(1) NOT NULL,JOB_DATA BLOB NULL,PRIMARY KEY (SCHED_NAME,JOB_NAME,JOB_GROUP))ENGINE=InnoDB;CREATE TABLE QRTZ_TRIGGERS (SCHED_NAME VARCHAR(120) NOT NULL,TRIGGER_NAME VARCHAR(190) NOT NULL,TRIGGER_GROUP VARCHAR(190) NOT NULL,JOB_NAME VARCHAR(190) NOT NULL,JOB_GROUP VARCHAR(190) NOT NULL,DESCRIPTION VARCHAR(250) NULL,NEXT_FIRE_TIME BIGINT(13) NULL,PREV_FIRE_TIME BIGINT(13) NULL,PRIORITY INTEGER NULL,TRIGGER_STATE VARCHAR(16) NOT NULL,TRIGGER_TYPE VARCHAR(8) NOT NULL,START_TIME BIGINT(13) NOT NULL,END_TIME BIGINT(13) NULL,CALENDAR_NAME VARCHAR(190) NULL,MISFIRE_INSTR SMALLINT(2) NULL,JOB_DATA BLOB NULL,PRIMARY KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP),FOREIGN KEY (SCHED_NAME,JOB_NAME,JOB_GROUP)REFERENCES QRTZ_JOB_DETAILS(SCHED_NAME,JOB_NAME,JOB_GROUP))ENGINE=InnoDB;CREATE TABLE QRTZ_SIMPLE_TRIGGERS (SCHED_NAME VARCHAR(120) NOT NULL,TRIGGER_NAME VARCHAR(190) NOT NULL,TRIGGER_GROUP VARCHAR(190) NOT NULL,REPEAT_COUNT BIGINT(7) NOT NULL,REPEAT_INTERVAL BIGINT(12) NOT NULL,TIMES_TRIGGERED BIGINT(10) NOT NULL,PRIMARY KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP),FOREIGN KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP)REFERENCES QRTZ_TRIGGERS(SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP))ENGINE=InnoDB;CREATE TABLE QRTZ_CRON_TRIGGERS (SCHED_NAME VARCHAR(120) NOT NULL,TRIGGER_NAME VARCHAR(190) NOT NULL,TRIGGER_GROUP VARCHAR(190) NOT NULL,CRON_EXPRESSION VARCHAR(120) NOT NULL,TIME_ZONE_ID VARCHAR(80),PRIMARY KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP),FOREIGN KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP)REFERENCES QRTZ_TRIGGERS(SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP))ENGINE=InnoDB;CREATE TABLE QRTZ_SIMPROP_TRIGGERS  (    SCHED_NAME VARCHAR(120) NOT NULL,    TRIGGER_NAME VARCHAR(190) NOT NULL,    TRIGGER_GROUP VARCHAR(190) NOT NULL,    STR_PROP_1 VARCHAR(512) NULL,    STR_PROP_2 VARCHAR(512) NULL,    STR_PROP_3 VARCHAR(512) NULL,    INT_PROP_1 INT NULL,    INT_PROP_2 INT NULL,    LONG_PROP_1 BIGINT NULL,    LONG_PROP_2 BIGINT NULL,    DEC_PROP_1 NUMERIC(13,4) NULL,    DEC_PROP_2 NUMERIC(13,4) NULL,    BOOL_PROP_1 VARCHAR(1) NULL,    BOOL_PROP_2 VARCHAR(1) NULL,    PRIMARY KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP),    FOREIGN KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP)    REFERENCES QRTZ_TRIGGERS(SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP))ENGINE=InnoDB;CREATE TABLE QRTZ_BLOB_TRIGGERS (SCHED_NAME VARCHAR(120) NOT NULL,TRIGGER_NAME VARCHAR(190) NOT NULL,TRIGGER_GROUP VARCHAR(190) NOT NULL,BLOB_DATA BLOB NULL,PRIMARY KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP),INDEX (SCHED_NAME,TRIGGER_NAME, TRIGGER_GROUP),FOREIGN KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP)REFERENCES QRTZ_TRIGGERS(SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP))ENGINE=InnoDB;CREATE TABLE QRTZ_CALENDARS (SCHED_NAME VARCHAR(120) NOT NULL,CALENDAR_NAME VARCHAR(190) NOT NULL,CALENDAR BLOB NOT NULL,PRIMARY KEY (SCHED_NAME,CALENDAR_NAME))ENGINE=InnoDB;CREATE TABLE QRTZ_PAUSED_TRIGGER_GRPS (SCHED_NAME VARCHAR(120) NOT NULL,TRIGGER_GROUP VARCHAR(190) NOT NULL,PRIMARY KEY (SCHED_NAME,TRIGGER_GROUP))ENGINE=InnoDB;CREATE TABLE QRTZ_FIRED_TRIGGERS (SCHED_NAME VARCHAR(120) NOT NULL,ENTRY_ID VARCHAR(95) NOT NULL,TRIGGER_NAME VARCHAR(190) NOT NULL,TRIGGER_GROUP VARCHAR(190) NOT NULL,INSTANCE_NAME VARCHAR(190) NOT NULL,FIRED_TIME BIGINT(13) NOT NULL,SCHED_TIME BIGINT(13) NOT NULL,PRIORITY INTEGER NOT NULL,STATE VARCHAR(16) NOT NULL,JOB_NAME VARCHAR(190) NULL,JOB_GROUP VARCHAR(190) NULL,IS_NONCONCURRENT VARCHAR(1) NULL,REQUESTS_RECOVERY VARCHAR(1) NULL,PRIMARY KEY (SCHED_NAME,ENTRY_ID))ENGINE=InnoDB;CREATE TABLE QRTZ_SCHEDULER_STATE (SCHED_NAME VARCHAR(120) NOT NULL,INSTANCE_NAME VARCHAR(190) NOT NULL,LAST_CHECKIN_TIME BIGINT(13) NOT NULL,CHECKIN_INTERVAL BIGINT(13) NOT NULL,PRIMARY KEY (SCHED_NAME,INSTANCE_NAME))ENGINE=InnoDB;CREATE TABLE QRTZ_LOCKS (SCHED_NAME VARCHAR(120) NOT NULL,LOCK_NAME VARCHAR(40) NOT NULL,PRIMARY KEY (SCHED_NAME,LOCK_NAME))ENGINE=InnoDB;CREATE INDEX IDX_QRTZ_J_REQ_RECOVERY ON QRTZ_JOB_DETAILS(SCHED_NAME,REQUESTS_RECOVERY);CREATE INDEX IDX_QRTZ_J_GRP ON QRTZ_JOB_DETAILS(SCHED_NAME,JOB_GROUP);CREATE INDEX IDX_QRTZ_T_J ON QRTZ_TRIGGERS(SCHED_NAME,JOB_NAME,JOB_GROUP);CREATE INDEX IDX_QRTZ_T_JG ON QRTZ_TRIGGERS(SCHED_NAME,JOB_GROUP);CREATE INDEX IDX_QRTZ_T_C ON QRTZ_TRIGGERS(SCHED_NAME,CALENDAR_NAME);CREATE INDEX IDX_QRTZ_T_G ON QRTZ_TRIGGERS(SCHED_NAME,TRIGGER_GROUP);CREATE INDEX IDX_QRTZ_T_STATE ON QRTZ_TRIGGERS(SCHED_NAME,TRIGGER_STATE);CREATE INDEX IDX_QRTZ_T_N_STATE ON QRTZ_TRIGGERS(SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP,TRIGGER_STATE);CREATE INDEX IDX_QRTZ_T_N_G_STATE ON QRTZ_TRIGGERS(SCHED_NAME,TRIGGER_GROUP,TRIGGER_STATE);CREATE INDEX IDX_QRTZ_T_NEXT_FIRE_TIME ON QRTZ_TRIGGERS(SCHED_NAME,NEXT_FIRE_TIME);CREATE INDEX IDX_QRTZ_T_NFT_ST ON QRTZ_TRIGGERS(SCHED_NAME,TRIGGER_STATE,NEXT_FIRE_TIME);CREATE INDEX IDX_QRTZ_T_NFT_MISFIRE ON QRTZ_TRIGGERS(SCHED_NAME,MISFIRE_INSTR,NEXT_FIRE_TIME);CREATE INDEX IDX_QRTZ_T_NFT_ST_MISFIRE ON QRTZ_TRIGGERS(SCHED_NAME,MISFIRE_INSTR,NEXT_FIRE_TIME,TRIGGER_STATE);CREATE INDEX IDX_QRTZ_T_NFT_ST_MISFIRE_GRP ON QRTZ_TRIGGERS(SCHED_NAME,MISFIRE_INSTR,NEXT_FIRE_TIME,TRIGGER_GROUP,TRIGGER_STATE);CREATE INDEX IDX_QRTZ_FT_TRIG_INST_NAME ON QRTZ_FIRED_TRIGGERS(SCHED_NAME,INSTANCE_NAME);CREATE INDEX IDX_QRTZ_FT_INST_JOB_REQ_RCVRY ON QRTZ_FIRED_TRIGGERS(SCHED_NAME,INSTANCE_NAME,REQUESTS_RECOVERY);CREATE INDEX IDX_QRTZ_FT_J_G ON QRTZ_FIRED_TRIGGERS(SCHED_NAME,JOB_NAME,JOB_GROUP);CREATE INDEX IDX_QRTZ_FT_JG ON QRTZ_FIRED_TRIGGERS(SCHED_NAME,JOB_GROUP);CREATE INDEX IDX_QRTZ_FT_T_G ON QRTZ_FIRED_TRIGGERS(SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP);CREATE INDEX IDX_QRTZ_FT_TG ON QRTZ_FIRED_TRIGGERS(SCHED_NAME,TRIGGER_GROUP);commit;

16.2 application.properties 中配置 quartz

# QuartzProperties# quartz 分布式定时任务调度相关配置spring.quartz.job-store-type=jdbcspring.quartz.scheduler-name=communitySchedulerspring.quartz.properties.org.quartz.scheduler.instanceId=AUTOspring.quartz.properties.org.quartz.jobStore.class=org.quartz.impl.jdbcjobstore.JobStoreTXspring.quartz.properties.org.quartz.jobStore.driverDelegateClass=org.quartz.impl.jdbcjobstore.StdJDBCDelegatespring.quartz.properties.org.quartz.jobStore.isClustered=truespring.quartz.properties.org.quartz.threadPool.class=org.quartz.simpl.SimpleThreadPoolspring.quartz.properties.org.quartz.threadPool.threadCount=5

16.3 PostScoreRefreshJob定时任务类

/** * @Auther: csp1999 * @Date: 2020/12/04/16:08 * @Description: 定时任务类(要实现Job接口) *//** * @Auther: csp1999 * @Date: 2020/12/04/16:08 * @Description: 定时任务类(要实现Job接口) */public class AlphaJob implements Job {
@Override public void execute(JobExecutionContext context) throws JobExecutionException {
System.out.println(Thread.currentThread().getName() + ": execute a quartz job..."); }}

16.4 QuartzConfig 配置类

/** * @Auther: csp1999 * @Date: 2020/12/04/16:05 * @Description: quartz 分布式定时任务调度相关配置类 * 

* 作用: * 1. -> 仅仅当第一次访问时读取该配置 * 2. -> 并将该配置封装的信息初始化到数据库数据库 * 3. -> 以后每次quartz是访问数据去调用,而不再访问该配置类! */@Configurationpublic class QuartzConfig {

/** * FactoryBean可简化Bean的实例化过程: *

* 1.通过FactoryBean封装Bean的实例化过程. * 2.将FactoryBean装配到Spring容器里. * 3.将FactoryBean注入给其他的Bean. * 4.该Bean得到的是FactoryBean所管理的对象实例. */ // 配置JobDetail @Bean public JobDetailFactoryBean alphaJobDetail() {

JobDetailFactoryBean factoryBean = new JobDetailFactoryBean(); factoryBean.setJobClass(AlphaJob.class); factoryBean.setName("alphaJob"); factoryBean.setGroup("alphaJobGroup"); factoryBean.setDurability(true); factoryBean.setRequestsRecovery(true); return factoryBean; } // 配置Trigger(SimpleTriggerFactoryBean, CronTriggerFactoryBean) @Bean public SimpleTriggerFactoryBean alphaTrigger(JobDetail alphaJobDetail) {
SimpleTriggerFactoryBean factoryBean = new SimpleTriggerFactoryBean(); factoryBean.setJobDetail(alphaJobDetail); factoryBean.setName("alphaTrigger"); factoryBean.setGroup("alphaTriggerGroup"); factoryBean.setRepeatInterval(3000); factoryBean.setJobDataMap(new JobDataMap()); return factoryBean; }}

17. 热帖排行

在这里插入图片描述

17.1 PostScoreRefreshJob

/** * @Auther: csp1999 * @Date: 2020/12/04/18:48 * @Description: 作用:定时对帖子分数进行刷新 */public class PostScoreRefreshJob implements Job, CommunityConstant {
private static final Logger logger = LoggerFactory.getLogger(PostScoreRefreshJob.class); @Autowired private RedisTemplate redisTemplate; @Autowired private DiscussPostService discussPostService; @Autowired private LikeService likeService; @Autowired private ElasticSearchService elasticSearchService; // 牛客纪元(常量) private static final Date epoch; // 静态代码块,随着类的加载而加载 static {
try {
epoch = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse("2020-11-11 00:00:00"); } catch (ParseException e) {
throw new RuntimeException("初始化牛客纪元失败!", e); } } /** * 定时任务:定时对帖子分数进行刷新 * @param context * @throws JobExecutionException */ @Override public void execute(JobExecutionContext context) throws JobExecutionException {
// 获取帖子分数集合的key String redisKey = RedisKeyUtil.getPostScoreKey(); BoundSetOperations operations = redisTemplate.boundSetOps(redisKey); if (operations.size() == 0) {
logger.info("[任务取消] 没有需要刷新的帖子!"); return; } logger.info("[任务开始] 正在刷新帖子分数: " + operations.size()); while (operations.size() > 0) {
// 批量从 operations 弹出帖子id,并刷新计算帖子的分数,直到operations=0结束 this.refresh((Integer) operations.pop()); } logger.info("[任务结束] 帖子分数刷新完毕!"); } // 刷新计算帖子分数的方法 private void refresh(int postId) {
DiscussPost post = discussPostService.findDiscussPostById(postId); if (post == null) {
logger.error("该帖子不存在: id = " + postId); return; } // 是否精华 boolean wonderful = post.getStatus() == 1; // 评论数量 int commentCount = post.getCommentCount(); // 点赞数量 long likeCount = likeService.findEntityLikeCount(ENTITY_TYPE_POST, postId); // 计算权重 double w = (wonderful ? 75 : 0) + commentCount * 10 + likeCount * 2; // 分数 = 帖子权重 + 距离牛客纪元的天数 double score = Math.log10(Math.max(w, 1)) + (post.getCreateTime().getTime() - epoch.getTime()) / (1000 * 3600 * 24); // 更新帖子分数 discussPostService.updateScore(postId, score); // 同步ES中的搜索数据 post.setScore(score); elasticSearchService.saveDiscussPost(post); }}

18.生成长图

在这里插入图片描述

  • 使用工具:tkhtmltopdf

  • 官网下载地址:

  • 入门介绍参考文章:

18.1 ShareController

/** * @Auther: csp1999 * @Date: 2020/12/05/19:41 * @Description: 图片生成并分享 */@Controllerpublic class ShareController implements CommunityConstant {
private static final Logger logger = LoggerFactory.getLogger(ShareController.class); @Autowired private EventProducer eventProducer; // 项目域名 @Value("${community.path}") private String path; // 项目名称 @Value("${server.servlet.context-path}") private String contextPath; // 图片存储地址 @Value("${wk.image.storage}") private String wkImageStorage; /** * 分享操作 * @param htmlUrl * @return */ @RequestMapping(path = "/share", method = RequestMethod.GET) @ResponseBody public String share(String htmlUrl) {
// 文件名 String fileName = CommunityUtil.generateUUID(); // kafka 消息生产者通知消息消费者异步生成长图 Event event = new Event() .setTopic(TOPIC_SHARE) .setData("htmlUrl", htmlUrl) .setData("fileName", fileName) .setData("suffix", ".png"); eventProducer.fireEvent(event); // 返回的访问路径放入map Map
map = new HashMap<>(); map.put("shareUrl", path + contextPath + "/share/image/" + fileName); return CommunityUtil.getJSONString(0, null, map); } /** * 获取长图 * @param fileName * @param response */ @RequestMapping(path = "/share/image/{fileName}", method = RequestMethod.GET) public void getShareImage(@PathVariable("fileName") String fileName, HttpServletResponse response) {
if (StringUtils.isBlank(fileName)) {
throw new IllegalArgumentException("文件名不能为空!"); } response.setContentType("image/png"); File file = new File(wkImageStorage + "/" + fileName + ".png"); try {
OutputStream os = response.getOutputStream(); FileInputStream fis = new FileInputStream(file); byte[] buffer = new byte[1024]; int b = 0; while ((b = fis.read(buffer)) != -1) {
os.write(buffer, 0, b); } } catch (IOException e) {
logger.error("获取长图失败: " + e.getMessage()); } }}

18.2 在Kafka 事件消费者中添加如下代码

// wk 工具的路径(生成图片)@Value("${wk.image.command}")private String wkImageCommand;// 图片存储位置@Value("${wk.image.storage}")private String wkImageStorage;/** * 消费图片分享事件 * * @param record */@KafkaListener(topics = TOPIC_SHARE)public void handleShareMessage(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; } String htmlUrl = (String) event.getData().get("htmlUrl"); String fileName = (String) event.getData().get("fileName"); String suffix = (String) event.getData().get("suffix"); // cmd 命令 String cmd = wkImageCommand + " --quality 75 " + htmlUrl + " " + wkImageStorage + "/" + fileName + suffix; try {
// 操作系统执行cmd 命令 Runtime.getRuntime().exec(cmd); logger.info("生成长图成功: " + cmd); } catch (IOException e) {
logger.error("生成长图失败: " + e.getMessage()); }}

18.3 测试生成长图

我们测试执行:,得到如下结果:

在这里插入图片描述

我们通过访问该返回的图片路径测试,可以得到该图片:

在这里插入图片描述

本地目录中查看生成的图片:

在这里插入图片描述

19. 项目性能优化

在这里插入图片描述

19.1 pom.xml导入caffeine依赖

com.github.ben-manes.caffeine
caffeine
2.7.0

19.2 application.properties 配置文件中添加配置

# caffeine 本地缓存相关配置# 缓存的帖子列表(max-size:表示本地缓存空间内最多能缓存的数据条数 15条)caffeine.posts.max-size=15# 缓存的帖子列表(expire-seconds:表示本地缓存数据的过期时间 180s)caffeine.posts.expire-seconds=180

19.3 优化Service层相关的代码

为Service层相关的代码加上本地缓存:

DiscussPostService 中添加缓存相关代码

@Value("${caffeine.posts.max-size}")private int caffeineCacheMaxSize;// 最大本地缓存数据的条数@Value("${caffeine.posts.expire-seconds}")private int caffeineCacheExpireSeconds;// 本地缓存数据的过期时间/** * Caffeine 核心接口:Cache , LoadingCache , AsyncLoadingCache */// 帖子列表缓存private LoadingCache
> discussPostListCache;// 帖子总数缓存private LoadingCache
discussPostRowsCache;/** * 当该类被实例化或者被调用时, * 该init() 方法在构造函数以及@Autowired 之后执行 */@PostConstructpublic void init() {
// 初始化帖子列表缓存 discussPostListCache = Caffeine.newBuilder() // 最大本地缓存数据的条数 .maximumSize(caffeineCacheMaxSize) // 本地缓存数据的过期时间 .expireAfterWrite(caffeineCacheExpireSeconds, TimeUnit.SECONDS) .build(new CacheLoader
>() {
@Override public @Nullable List
load(@NonNull String key) throws Exception {
// 判断获取缓存的key 是否为空 if (key == null || key.length() == 0) {
throw new IllegalArgumentException("key为空..."); } // 分割key 获得参数(limit 和 offset) String[] params = key.split(":"); if (params == null || params.length != 2) {
throw new IllegalArgumentException("参数错误..."); } int offset = Integer.valueOf(params[0]); int limit = Integer.valueOf(params[1]); // 扩展:可以自己再加一个二级缓存 Redis -> Mysql // 从数据库查数据,获取后将数据放入本地缓存 logger.info("从DB中获取帖子列表数据..."); return discussPostMapper.selectDiscussPosts(0, offset, limit, 1); } }); // 初始化帖子总数缓存 discussPostRowsCache = Caffeine.newBuilder() // 最大本地缓存数据的条数 .maximumSize(caffeineCacheMaxSize) // 本地缓存数据的过期时间 .expireAfterWrite(caffeineCacheExpireSeconds, TimeUnit.SECONDS) .build(new CacheLoader
() {
@Override public @Nullable Integer load(@NonNull Integer key) throws Exception {
// 从数据库查数据,获取后将数据放入本地缓存 logger.info("从DB中获取帖子总数量..."); return discussPostMapper.selectDiscussPostRows(key); } });}/** * 查询用户发布的所有帖子(分页) * * @param userId 用户id * @param offset 起始位置 * @param limit 每一页的数量 * @return */public List
findDiscussPosts(int userId, int offset, int limit, // 当用户id为0 且 orderMode为1即热门帖子 if (userId == 0 && orderMode == 1) { String cacheKey = offset + ":" + limit; // 从本地缓存中获取数据 return discussPostListCache.get(cacheKey); } // 不满足以上条件,则从数据库查数据 logger.info("从DB中获取帖子列表数据..."); return discussPostMapper.selectDiscussPosts(userId, offset, limit, order} /** * 根据userid 查询该用户发布的所有帖子数量 * * @param userId 用户id * @return */public int findDiscussPostRows(int userId) { // 当用户id为0时 if (userId == 0) { Integer cacheKey = userId; // 从本地缓存中获取数据 return discussPostRowsCache.get(cacheKey); } // 不满足以上条件,则从数据库查数据 logger.info("从DB中获取帖子数据的总数量..."); return discussPostMapper.selectDiscussPostRows(userId);}

19.4 jemeter 压力测试

我们使用测试工具,测试缓存是否生效,访问首页的热门帖子:

在这里插入图片描述

我们模仿100个线程访问 接口,可以看到控制台只有第一次访问的时候打印sql(从DB中查询数据),其他时候都是走本地缓存获取数据!这样就能提高热点页面访问速度!

20. 项目部署

20.1 服务器配置要求(我用的阿里云服务器)

  • 2核4G(或者2个1核1G)
  • CentOS 7.X

20.2 需要部署的内容

  • MySQL
  • Redis
  • Kafka
  • ElasticSearch
  • Wktmltopdf
  • Nginx
  • Tomcat
  • JDK8
  • 项目压缩包

转载地址:https://csp1999.blog.csdn.net/article/details/110739626 如侵犯您的版权,请留言回复原文章的地址,我们会给您删除此文章,给您带来不便请您谅解!

上一篇:SpringBoot配置CORS处理前后端分离的跨域问题
下一篇:wkhtmltopdf工具将网站转换成pdf或图片

发表评论

最新留言

路过按个爪印,很不错,赞一个!
[***.219.124.196]2024年04月07日 23时30分35秒