# community **Repository Path**: jiaming-chen/community ## Basic Information - **Project Name**: community - **Description**: 基于springboot的论坛社区项目 - **Primary Language**: Java - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 1 - **Created**: 2021-09-26 - **Last Updated**: 2021-09-26 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # Community 论坛项目 > 转载请附带原文链接: ## 1. 环境搭建与技术栈说明 ### 1.0 项目架构图 ![在这里插入图片描述](https://img-blog.csdnimg.cn/20201204163028259.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MzU5MTk4MA==,size_16,color_FFFFFF,t_70) ### 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项目: ![在这里插入图片描述](https://img-blog.csdnimg.cn/20201120221656485.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MzU5MTk4MA==,size_16,color_FFFFFF,t_70#pic_center) #### 初始化后的pom.xml: ```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 ``` #### 项目初始结构:![在这里插入图片描述](https://img-blog.csdnimg.cn/20201124083836256.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MzU5MTk4MA==,size_16,color_FFFFFF,t_70#pic_center) ### 1.3 数据库设计 #### 数据库表sql ```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服务 ![在这里插入图片描述](https://img-blog.csdnimg.cn/2020112412470190.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MzU5MTk4MA==,size_16,color_FFFFFF,t_70#pic_center) ### 2.2 引入依赖 **pom.xml中引入依赖** ```xml org.springframework.boot spring-boot-starter-mail ``` ### 2.3 参数配置 **邮箱参数配置(我使用的是网易邮箱)** ```yaml # 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 邮件发送工具类 ```java /** * @Auther: csp1999 * @Date: 2020/11/24/14:29 * @Description: 邮件发送客户端 */ @Component public 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 测试发送 ```java @Autowired private MailClient mailClient; @Test void test02(){ mailClient.sendMail("11xxxxxxx@qq.com","TEST","测试邮件发送!"); } ``` ![在这里插入图片描述](https://img-blog.csdnimg.cn/20201124144653126.png#pic_center) 测试发送邮件成功! ### 2.6 使用Thymleaf模板引擎发送html格式的邮件 ```java ... // 激活邮件发送 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); ... ``` ![在这里插入图片描述](https://img-blog.csdnimg.cn/20201124164256902.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MzU5MTk4MA==,size_16,color_FFFFFF,t_70#pic_center) ## 3. 登录与注册功能 * 登录注册功能的验证码目前是存放在**Session**中,之后要存入**Redis**,提高性能,同时也可以解决分布式部署时的**Session**共享问题! * 注册功能的邮件发送,比较费时,用户只能干等待邮件发送成功,这种方式不太友好,因此在后端以多线程的方式,分一个线程去处理邮件发送,进而不影响客户端正常给用户的响应问题,不用让用户在页面卡太长时间! * 对于登录用户信息判定(比如,账号密码是否错误,用户名是否存在,用户是否激活)等问题,如果每次都查询数据库,效率比较低,为此我们在客户端发送请求——>后端调用数据库,之间加一层 Redis 缓存,来验证用户登录信息是否合法! ### 3.1 登录功能 ![在这里插入图片描述](https://img-blog.csdnimg.cn/20201124203937440.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MzU5MTk4MA==,size_16,color_FFFFFF,t_70#pic_center) ### 3.2 注册功能 ![在这里插入图片描述](https://img-blog.csdnimg.cn/20201124203808469.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MzU5MTk4MA==,size_16,color_FFFFFF,t_70#pic_center) ## 4.通过cookie获取user登录信息 **客户端通过cookie携带登录凭证向服务器换取user信息**,流程如图: ![在这里插入图片描述](https://img-blog.csdnimg.cn/20201124210416346.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MzU5MTk4MA==,size_16,color_FFFFFF,t_70#pic_center) ==这一流程需要借助拦截器**LoginTicketInterceptor 和 LoginRequiredInterceptor**实现==! ### **LoginTicketInterceptor.java** 登录凭证拦截器 ```java /** * @Auther: csp1999 * @Date: 2020/11/24/20:54 * @Description: 登录凭证拦截器 */ @Component public 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** 登录请求拦截器 ```java /** * @Auther: csp1999 * @Date: 2020/11/24/21:27 * @Description: 登录请求拦截器 */ @Component public 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容器中 ```java /** * @Auther: csp1999 * @Date: 2020/11/24/20:53 * @Description: 拦截器配置类 */ @Configuration public 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 效果展示 上传头像: ![在这里插入图片描述](https://img-blog.csdnimg.cn/20201125135819535.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MzU5MTk4MA==,size_16,color_FFFFFF,t_70#pic_center) 头像上传成功: ![在这里插入图片描述](https://img-blog.csdnimg.cn/20201125135902126.png#pic_center) ### 5.2 阿里云OSS文件存储 入门参考文章:[springboot操作阿里云OSS实现文件上传,下载,删除(附源码)](https://csp1999.blog.csdn.net/article/details/109412083) **AliyunOssConfig** ```java /** * @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** ```java /** * @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** 类 ```java /** * @Auther: csp1999 * @Date: 2020/11/25/10:56 * @Description: 敏感词过滤组件 */ @Component public 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); } } } ``` 效果如下图: ![在这里插入图片描述](https://img-blog.csdnimg.cn/2020112513570330.png#pic_center) ## 7.帖子发布与帖子评论 ### 7.1 帖子发布 ![在这里插入图片描述](https://img-blog.csdnimg.cn/20201125150945380.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MzU5MTk4MA==,size_16,color_FFFFFF,t_70#pic_center) ### 7.2 帖子评论 ![在这里插入图片描述](https://img-blog.csdnimg.cn/20201125151051276.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MzU5MTk4MA==,size_16,color_FFFFFF,t_70#pic_center) ## 8. 私信列表与私信会话聊天 ### 8.1 效果如图 #### 私信列表 ![在这里插入图片描述](https://img-blog.csdnimg.cn/2020112618250882.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MzU5MTk4MA==,size_16,color_FFFFFF,t_70#pic_center) #### 私信详情 ![在这里插入图片描述](https://img-blog.csdnimg.cn/20201126183009854.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MzU5MTk4MA==,size_16,color_FFFFFF,t_70#pic_center) #### 私信发送 ![在这里插入图片描述](https://img-blog.csdnimg.cn/20201126190635383.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MzU5MTk4MA==,size_16,color_FFFFFF,t_70#pic_center) ### 8.2 DAO层代码 #### Mapper接口 ```java /** * @Auther: csp1999 * @Date: 2020/11/26/16:29 * @Description: */ @Repository public 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能力的时候到了==(∩_∩)! ```xml 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 ```java /** * @Auther: csp1999 * @Date: 2020/11/26/17:42 * @Description: */ @Controller public 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页面展示 ![](https://img-blog.csdnimg.cn/20201126202500103.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MzU5MTk4MA==,size_16,color_FFFFFF,t_70#pic_center) ### 错误页面展示 ![在这里插入图片描述](https://img-blog.csdnimg.cn/20201126202542437.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MzU5MTk4MA==,size_16,color_FFFFFF,t_70#pic_center) ### 统一异常处理 > 相关注解介绍 * `@ControllerAdvice` * 用于==修饰类==,表示该类是**Controller** 的全局适配类。 * 在此类中,可以对**Controller** 进行如下三种全局配置: * :aerial_tramway: 异常处理方案 * :baby: 绑定数据方案 * :baby_chick: 绑定参数方案 * `@ExceptionHandler`(我们实例中使用则个注解修饰方法) * 用于==修饰方法==,该方法会在**Controller** 出现异常后被调用,用于处理捕获到的异常。 * `@ModelAttribute` * 用于==修饰方法==,该方法回在**Controller** 方法执行前被调用,用于为**Model** 对象绑定参数。 * `@DataBinder` * 用于==修饰方法==,该方法回在**Controller** 方法执行前被调用,用于绑定参数的转换器。 > 实例代码 ```java /** * @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"); } } } ``` ### 切面统一记录日志 ```java /** * @Auther: csp1999 * @Date: 2020/11/26/21:12 * @Description: 自定义日志处理组件 */ @Component @Aspect public 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的工具类: ```java /** * @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 ```java /** * @Auther: csp1999 * @Date: 2020/11/27/20:39 * @Description: 点赞Service 存入Redis 缓存 */ @Service public 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 ```java /** * @Auther: csp1999 * @Date: 2020/11/28/9:41 * @Description: 关注Service 存入Redis 缓存 */ @Service public 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 ```java /** * @Auther: csp1999 * @Date: 2020/11/27/20:59 * @Description: 点赞controller */ @Controller public 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 ```java /** * @Auther: csp1999 * @Date: 2020/11/28/9:48 * @Description: 关注Controller */ @Controller public 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** 的下载与安装,参考 [Linux环境下Kafka的安装与使用](https://csp1999.blog.csdn.net/article/details/110310012) ![在这里插入图片描述](https://img-blog.csdnimg.cn/20201129133653827.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MzU5MTk4MA==,size_16,color_FFFFFF,t_70) 效果如图: ![在这里插入图片描述](https://img-blog.csdnimg.cn/2020113013371967.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MzU5MTk4MA==,size_16,color_FFFFFF,t_70) ## 12. ElasticSearch 实现社区全局搜索功能 ### 12.1 ES 介绍 ![在这里插入图片描述](https://img-blog.csdnimg.cn/20201130134121566.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MzU5MTk4MA==,size_16,color_FFFFFF,t_70) * 官网地址:[https://www.elastic.co/cn/](https://www.elastic.co/cn/) * 官方文档地址:[https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html](https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html) * 下载地址:[https://www.elastic.co/cn/downloads/past-releases/elasticsearch-7-6-1](https://www.elastic.co/cn/downloads/past-releases/elasticsearch-7-6-1) ES 入门参考文章:[Linux环境下ElasticSearch的安装与使用(SpringBoot整合云服务器上的ElasticSearch)](https://csp1999.blog.csdn.net/article/details/110392198) ### 12.2 论坛搜索的功能实现 ![在这里插入图片描述](https://img-blog.csdnimg.cn/20201201093015216.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MzU5MTk4MA==,size_16,color_FFFFFF,t_70) ### 12.3 效果展示 ![在这里插入图片描述](https://img-blog.csdnimg.cn/20201201132012370.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MzU5MTk4MA==,size_16,color_FFFFFF,t_70) ## 13. Spring Security 权限控制 ### 13.1 简介 [Spring Security官方文档](https://spring.io/projects/spring-security) ![在这里插入图片描述](https://img-blog.csdnimg.cn/20201201135223617.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MzU5MTk4MA==,size_16,color_FFFFFF,t_70) * 入门参考文章:[SpringBoot2.3整合SpringSecurity](https://blog.csdn.net/fangchao2011/article/details/106501059) * 进阶参考文章:[http://www.spring4all.com/article/428][http://www.spring4all.com/article/428] * 案例Demo参考文章:[SpringBoot整合SpringSecurity做认证和权限控制案例(含Demo代码)](https://csp1999.blog.csdn.net/article/details/110497775) ### 13.2 项目中的权限控制介绍 ![在这里插入图片描述](https://img-blog.csdnimg.cn/20201201171437750.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MzU5MTk4MA==,size_16,color_FFFFFF,t_70) ### 13.3 使用Spring Security 替代原来的登录拦截器 #### 13.3.1 pom.xml 中新增相关依赖 ```xml org.springframework.boot spring-boot-starter-security ``` #### 13.3.2 SecurityConfig 配置类 首先先从**WebMvcConfig.java** 中 注释掉原来自定义的登录拦截器注册:**LoginRequiredInterceptor** 然后再进行**Spring Security** 相关配置: ```java /** * @Auther: csp1999 * @Date: 2020/12/03/9:57 * @Description: Spring Security 配置类 */ @Configuration public 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** ![在这里插入图片描述](https://img-blog.csdnimg.cn/20201203120707254.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MzU5MTk4MA==,size_16,color_FFFFFF,t_70) ### 14.1 引入依赖 ```java org.thymeleaf.extras thymeleaf-extras-springsecurity5 3.0.4.RELEASE ``` ### 14.2 DiscussPostController 中新增三个接口 对应Mapper 和 Service 层的修改直接看完整代码即可! ```java /** * 帖子置顶操作 * * @param id * @return */ @RequestMapping(path = "/top", method = RequestMethod.POST) @ResponseBody public 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) @ResponseBody public 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) @ResponseBody public 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 效果展示 ![在这里插入图片描述](https://img-blog.csdnimg.cn/20201203125612471.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MzU5MTk4MA==,size_16,color_FFFFFF,t_70) ## 15. 网站数据统计 ![在这里插入图片描述](https://img-blog.csdnimg.cn/202012031756035.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MzU5MTk4MA==,size_16,color_FFFFFF,t_70) > 声明:**HyperLogLog** 和 **Bitmap** 都是 Redis 中的高级数据类型! ### 15.1 RedisKeyUtil 中添加行营的key 和方法 ```java 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 ```java /** * @Auther: csp1999 * @Date: 2020/12/03/18:05 * @Description: 网站数据统计相关的Service */ @Service public 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拦截器来做访客统计 ```java /** * @Auther: csp1999 * @Date: 2020/12/03/18:34 * @Description: 访问统计UV(独立访客)/DAI(日活跃用户)的拦截器 */ @Component public class DataInterceptor implements HandlerInterceptor { @Autowired private DataService dataService; @Autowired private HostHolder hostHolder; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 统计UV(独立访客) // 获得访客的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 ```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 ```properties # QuartzProperties # quartz 分布式定时任务调度相关配置 spring.quartz.job-store-type=jdbc spring.quartz.scheduler-name=communityScheduler spring.quartz.properties.org.quartz.scheduler.instanceId=AUTO spring.quartz.properties.org.quartz.jobStore.class=org.quartz.impl.jdbcjobstore.JobStoreTX spring.quartz.properties.org.quartz.jobStore.driverDelegateClass=org.quartz.impl.jdbcjobstore.StdJDBCDelegate spring.quartz.properties.org.quartz.jobStore.isClustered=true spring.quartz.properties.org.quartz.threadPool.class=org.quartz.simpl.SimpleThreadPool spring.quartz.properties.org.quartz.threadPool.threadCount=5 ``` ### 16.3 PostScoreRefreshJob定时任务类 ```java /** * @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 配置类 ```java /** * @Auther: csp1999 * @Date: 2020/12/04/16:05 * @Description: quartz 分布式定时任务调度相关配置类 *

* 作用: * 1. -> 仅仅当第一次访问时读取该配置 * 2. -> 并将该配置封装的信息初始化到数据库数据库 * 3. -> 以后每次quartz是访问数据去调用,而不再访问该配置类! */ @Configuration public 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. 热帖排行 ![在这里插入图片描述](https://img-blog.csdnimg.cn/20201204164138799.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MzU5MTk4MA==,size_16,color_FFFFFF,t_70) ### 17.1 PostScoreRefreshJob ```java /** * @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.生成长图 ![在这里插入图片描述](https://img-blog.csdnimg.cn/20201204200107371.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MzU5MTk4MA==,size_16,color_FFFFFF,t_70) * 使用工具:**tkhtmltopdf** * 官网下载地址:[https://wkhtmltopdf.org/downloads.html](https://wkhtmltopdf.org/downloads.html) * 入门介绍参考文章:[wkhtmltopdf工具将网站转换成pdf或图片](https://csp1999.blog.csdn.net/article/details/110716381) ### 18.1 ShareController ```java /** * @Auther: csp1999 * @Date: 2020/12/05/19:41 * @Description: 图片生成并分享 */ @Controller public 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 事件消费者中添加如下代码 ```java // 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 测试生成长图 我们测试执行:[http://localhost:8080/community/share?htmlUrl=https://www.baidu.com](),得到如下结果: ![在这里插入图片描述](https://img-blog.csdnimg.cn/2020120520115741.png) 我们通过访问该返回的图片路径测试,可以得到该图片: ![在这里插入图片描述](https://img-blog.csdnimg.cn/20201205201242754.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MzU5MTk4MA==,size_16,color_FFFFFF,t_70) 本地目录中查看生成的图片: ![在这里插入图片描述](https://img-blog.csdnimg.cn/20201205201331112.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MzU5MTk4MA==,size_16,color_FFFFFF,t_70) ## 19. 项目性能优化 ![在这里插入图片描述](https://img-blog.csdnimg.cn/20201206114720407.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MzU5MTk4MA==,size_16,color_FFFFFF,t_70) ### 19.1 pom.xml导入caffeine依赖 ```java com.github.ben-manes.caffeine caffeine 2.7.0 ``` ### 19.2 application.properties 配置文件中添加配置 ```properties # caffeine 本地缓存相关配置 # 缓存的帖子列表(max-size:表示本地缓存空间内最多能缓存的数据条数 15条) caffeine.posts.max-size=15 # 缓存的帖子列表(expire-seconds:表示本地缓存数据的过期时间 180s) caffeine.posts.expire-seconds=180 ``` ### 19.3 优化Service层相关的代码 为Service层相关的代码加上本地缓存: **DiscussPostService** 中添加缓存相关代码 ```java @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 之后执行 */ @PostConstruct public 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 压力测试 我们使用测试工具,测试缓存是否生效,访问首页的热门帖子: ![在这里插入图片描述](https://img-blog.csdnimg.cn/20201206135058287.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MzU5MTk4MA==,size_16,color_FFFFFF,t_70) 我们模仿100个线程访问[http://localhost:8080/community/index?orderMode=1]() 接口,可以看到控制台只有第一次访问的时候打印sql(从DB中查询数据),其他时候都是走本地缓存获取数据!这样就能提高热点页面访问速度! ## 20. 项目部署 ### 20.1 服务器配置要求(我用的阿里云服务器) * 2核4G(或者2个1核1G) * CentOS 7.X ### 20.2 需要部署的内容 * MySQL * Redis * Kafka * ElasticSearch * Wktmltopdf * Nginx * Tomcat * JDK8 * 项目压缩包