SpringBoot仿牛客论坛项目实战

7,943 阅读6分钟

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:
<!--thymleaf-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!--web-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--mybatis-->
<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>2.1.4</version>
</dependency>
<!--热部署-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-devtools</artifactId>
    <scope>runtime</scope>
    <optional>true</optional>
</dependency>
<!--mysql-->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>runtime</scope>
</dependency>
<!--lombok-->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>
<!--test-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
    <exclusions>
        <exclusion>
            <groupId>org.junit.vintage</groupId>
            <artifactId>junit-vintage-engine</artifactId>
        </exclusion>
    </exclusions>
</dependency>
项目初始结构:

在这里插入图片描述

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中引入依赖

 <!--引入邮件发送依赖-->
 <dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-mail</artifactId>
 </dependency>

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: 邮件发送客户端
 */
@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 测试发送

@Autowired
private MailClient mailClient;

@Test
void 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: 登录凭证拦截器
 */
@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 登录请求拦截器

/**
 * @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容器中

/**
 * @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 效果展示

上传头像:

在这里插入图片描述

头像上传成功:

在这里插入图片描述

5.2 阿里云OSS文件存储

入门参考文章:springboot操作阿里云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: 敏感词过滤组件
 */
@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<Character, TrieNode> 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:
 */
@Repository
public interface MessageMapper {

    /**
     * 查询当前用户的会话列表,针对每个会话只返回一条最新的私信.
     * @param userId
     * @param offset
     * @param limit
     * @return
     */
    List<Message> 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<Message> 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<Integer> ids,
                     @Param("status")int status);
}
SQL实现

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

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.haust.community.mapper.MessageMapper">
    <sql id="selectFields">
        id, from_id, to_id, conversation_id, content, status, create_time
    </sql>

    <sql id="insertFields">
        from_id, to_id, conversation_id, content, status, create_time
    </sql>

    <!-- 查询当前用户的会话列表,针对每个会话只返回一条最新的私信. -->
    <select id="selectConversations" resultType="com.haust.community.pojo.Message">
        select
        <include refid="selectFields"></include>
        from message
        where id in (
        select max(id) from message
        where status != 2
        and from_id != 1
        and (from_id = #{userId} or to_id = #{userId})
        group by conversation_id
        )
        order by id desc
        limit #{offset}, #{limit}
    </select>

    <!-- 查询当前用户的会话数量. -->
    <select id="selectConversationCount" resultType="java.lang.Integer">
        select count(m.maxid) from (
            select max(id) as maxid from message
            where status != 2
            and from_id != 1
            and (from_id = #{userId} or to_id = #{userId})
            group by conversation_id
        ) as m
    </select>

    <!-- 询某个会话所包含的私信列表. -->
    <select id="selectLetters" resultType="com.haust.community.pojo.Message">
        select
        <include refid="selectFields"></include>
        from message
        where status != 2
        and from_id != 1
        and conversation_id = #{conversationId}
        order by id desc
        limit #{offset}, #{limit}
    </select>

    <!-- 查询某个会话所包含的私信数量. -->
    <select id="selectLetterCount" resultType="java.lang.Integer">
        select count(id)
        from message
        where status != 2
        and from_id != 1
        and conversation_id = #{conversationId}
    </select>

    <!-- 查询未读私信的数量. -->
    <select id="selectLetterUnreadCount" resultType="java.lang.Integer">
        select count(id)
        from message
        where status = 0
        and from_id != 1
        and to_id = #{userId}
        <if test="conversationId!=null">
            and conversation_id = #{conversationId}
        </if>
    </select>

    <!-- 新增消息. -->
    <insert id="insertMessage" parameterType="com.haust.community.pojo.Message" keyProperty="id">
        insert into message(<include refid="insertFields"></include>)
        values(#{fromId},#{toId},#{conversationId},#{content},#{status},#{createTime})
    </insert>

    <!-- 修改消息的状态. -->
    <update id="updateStatus">
        update message set status = #{status}
        where id in
        <foreach collection="ids" item="id" open="(" separator="," close=")">
            #{id}
        </foreach>
    </update>
</mapper>
Controller API
/**
 * @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<Message> conversationList = messageService.findConversations(
                user.getId(), page.getOffset(), page.getLimit());
        List<Map<String, Object>> conversations = new ArrayList<>();
        if (conversationList != null) {
            for (Message message : conversationList) {
                Map<String, Object> 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<Message> letterList = messageService.findLetters(conversationId, 
                                                              page.getOffset(), page.getLimit());
        List<Map<String, Object>> letters = new ArrayList<>();
        if (letterList != null) {
            for (Message message : letterList) {
                Map<String, Object> 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<Integer> 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<Integer> getLetterIds(List<Message> letterList) {
        List<Integer> 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
@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的工具类:
/**
 * @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
     * <p>
     * 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
     * <p>
     * key ---> like:user:userId -> int
     *
     * @param userId
     * @return
     */
    public static String getUserLikeKey(int userId) {
        return PREFIX_USER_LIKE + SPLIT + userId;
    }

    /**
     * 某个用户关注某个实体的集合key
     * <p>
     * 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
     * <p>
     * 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 缓存
 */
@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

/**
 * @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<Map<String, Object>> findFollowees(int userId, int offset, int limit) {
        // 某个用户所关注的用户集合key
        String followeeKey = RedisKeyUtil.getFolloweeKey(userId, ENTITY_TYPE_USER);

        // 分页
        Set<Integer> targetIds = 
            redisTemplate.opsForZSet().reverseRange(followeeKey, offset, offset + limit - 1);

        // 判空
        if (targetIds == null) {
            return null;
        }

        // 结果拼接封装成list
        List<Map<String, Object>> list = new ArrayList<>();
        for (Integer targetId : targetIds) {
            Map<String, Object> 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<Map<String, Object>> findFollowers(int userId, int offset, int limit) {

        // 某个实体拥有的粉丝集合key
        String followerKey = RedisKeyUtil.getFollowerKey(ENTITY_TYPE_USER, userId);

        // 分页
        Set<Integer> targetIds = 
            redisTemplate.opsForZSet().reverseRange(followerKey, offset, offset + limit - 1);

        // 判空
        if (targetIds == null) {
            return null;
        }

        // 结果拼接封装成list
        List<Map<String, Object>> list = new ArrayList<>();
        for (Integer targetId : targetIds) {
            Map<String, Object> 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
 */
@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<String, Object> 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
 */
@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<Map<String, Object>> userList = 
            followService.findFollowees(userId, page.getOffset(), page.getLimit());
        // 循环list 并拼接每条用户信息
        if (userList != null) {
            for (Map<String, Object> 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<Map<String, Object>> userList = 
            followService.findFollowers(userId, page.getOffset(), page.getLimit());
        // 循环list 并拼接每条用户信息
        if (userList != null) {
            for (Map<String, Object> 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的安装与使用

在这里插入图片描述

效果如图:

在这里插入图片描述

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

12.1 ES 介绍

在这里插入图片描述

ES 入门参考文章:Linux环境下ElasticSearch的安装与使用(SpringBoot整合云服务器上的ElasticSearch)

12.2 论坛搜索的功能实现

在这里插入图片描述

12.3 效果展示

在这里插入图片描述

13. Spring Security 权限控制

13.1 简介

Spring Security官方文档

在这里插入图片描述

13.2 项目中的权限控制介绍

在这里插入图片描述

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

13.3.1 pom.xml 中新增相关依赖
<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-security -->
<!--spring security-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
13.3.2 SecurityConfig 配置类

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

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

/**
 * @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

在这里插入图片描述

14.1 引入依赖

<!-- https://mvnrepository.com/artifact/org.thymeleaf.extras/thymeleaf-extras-springsecurity5 -->
<dependency>
    <groupId>org.thymeleaf.extras</groupId>
    <artifactId>thymeleaf-extras-springsecurity5</artifactId>
    <version>3.0.4.RELEASE</version>
</dependency>

14.2 DiscussPostController 中新增三个接口

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

/**
 * 帖子置顶操作
 *
 * @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 效果展示

在这里插入图片描述

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
 */
@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<String> 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<byte[]> 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(日活跃用户)的拦截器
 */
@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

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=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定时任务类

/**
 * @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 分布式定时任务调度相关配置类
 * <p>
 * 作用:
 * 1. -> 仅仅当第一次访问时读取该配置
 * 2. -> 并将该配置封装的信息初始化到数据库数据库
 * 3. -> 以后每次quartz是访问数据去调用,而不再访问该配置类!
 */
@Configuration
public class QuartzConfig {

    /**
     * FactoryBean可简化Bean的实例化过程:
     * <p>
     * 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.生成长图

在这里插入图片描述

18.1 ShareController

/**
 * @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<String, Object> 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 测试生成长图

我们测试执行:http://localhost:8080/community/share?htmlUrl=https://www.baidu.com,得到如下结果:

在这里插入图片描述

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

在这里插入图片描述

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

在这里插入图片描述

19. 项目性能优化

在这里插入图片描述

19.1 pom.xml导入caffeine依赖

<!-- https://mvnrepository.com/artifact/com.github.ben-manes.caffeine/caffeine -->
<!--caffeine-->
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>2.7.0</version>
</dependency>

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<String, List<DiscussPost>> discussPostListCache;

// 帖子总数缓存
private LoadingCache<Integer, Integer> discussPostRowsCache;

/**
 * 当该类被实例化或者被调用时,
 * 该init() 方法在构造函数以及@Autowired 之后执行
 */
@PostConstruct
public void init() {
    // 初始化帖子列表缓存
    discussPostListCache = Caffeine.newBuilder()
            // 最大本地缓存数据的条数
            .maximumSize(caffeineCacheMaxSize)
            // 本地缓存数据的过期时间
            .expireAfterWrite(caffeineCacheExpireSeconds, TimeUnit.SECONDS)
            .build(new CacheLoader<String, List<DiscussPost>>() {
                @Override
                public @Nullable List<DiscussPost> 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<Integer, Integer>() {
                @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<DiscussPost> 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个线程访问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
  • 项目压缩包