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 介绍
- 官网地址:www.elastic.co/cn/
- 官方文档地址:www.elastic.co/guide/en/el…
- 下载地址:www.elastic.co/cn/download…
ES 入门参考文章:Linux环境下ElasticSearch的安装与使用(SpringBoot整合云服务器上的ElasticSearch)
12.2 论坛搜索的功能实现
12.3 效果展示
13. Spring Security 权限控制
13.1 简介
- 入门参考文章:SpringBoot2.3整合SpringSecurity
- 进阶参考文章:[www.spring4all.com/article/428…]
- 案例Demo参考文章:SpringBoot整合SpringSecurity做认证和权限控制案例(含Demo代码)
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. 网站数据统计
声明:HyperLogLog 和 Bitmap 都是 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 线程池
ExecutorServiceScheduledExecutorService
-
Spring 线程池
ThreadPoolTaskExecutorThreadPoolTaskScheduler
-
分布式定时任务
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.生成长图
- 使用工具:tkhtmltopdf
- 官网下载地址:wkhtmltopdf.org/downloads.h…
- 入门介绍参考文章:wkhtmltopdf工具将网站转换成pdf或图片
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
- 项目压缩包