其他分享
首页 > 其他分享> > SpringBoot仿牛客论坛项目实战

SpringBoot仿牛客论坛项目实战

作者:互联网

Community 论坛项目

转载请附带原文链接:

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

1.0 项目架构图

在这里插入图片描述

1.1 技术要求

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. 登录与注册功能

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. 敏感词过滤

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

/**
 * @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页面展示

错误页面展示

在这里插入图片描述

统一异常处理

相关注解介绍