SpringBoot仿牛客论坛项目实战
作者:互联网
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页面展示
错误页面展示
统一异常处理
相关注解介绍