目录
帖子分数计算公式:log(精华分 + 评论分*10 + 点赞数*2)+ (发布时间 - 成立时间)
将分数存入 Redis 中:打开 RedisKeyUtil 类,新增前缀用来存储帖子,并且新增方法(返回统计帖子分数的 key)
//新增前缀用来存储帖子
private static final String PREFIX_POST = "post";
// 帖子分数
public static String getPostScoreKey() {
return PREFIX_POST + SPLIT + "score";
}
在影响帖子分数操作发生的时候把帖子 id 放在空间中(加精、评论、点赞)
打开 discussPostController 类:
@Autowired
private RedisTemplate redisTemplate;
@RequestMapping(path = "/add", method = RequestMethod.POST)
@ResponseBody //添加 @ResponseBody 表示返回字符串
//添加方法,返回字符串,传入标题(title)和内容(context)
public String addDiscussPost(String title, String content) {
//发帖子前提是登陆:在 HostHolder 中取对象,如果取到的对象为空(未登录),给页面一个 403 提示(异步的,是 json 格式的数据)
User user = hostHolder.getUser();
if (user == null) {
return CommunityUtil.getJSONString(403, "你还没有登陆");
}
//登陆成功之后需要调用 DiscussPostService 保存帖子:构造实体:创建 post 对象,传入 UserId、title、content、处理时间
DiscussPost post = new DiscussPost();
post.setUserId(user.getId());
post.setTitle(title);
post.setContent(content);
post.setCreateTime(new Date());
discussPostService.addDiscussPost(post);
// 触发发帖事件
Event event = new Event()
.setTopic(TOPIC_PUBLISH)
.setUserId(user.getId())
.setEntityType(ENTITY_TYPE_POST)
.setEntityId(post.getId());
eventProducer.fireEvent(event);
//计算帖子分数
String redisKey = RedisKeyUtil.getPostScoreKey();
//将帖子 id 存入 Redis 的 set 中
redisTemplate.opsForSet().add(redisKey, post.getId());
// 报错的情况,将来统一处理.
return CommunityUtil.getJSONString(0, "发布成功!");
}
//加精
@RequestMapping(path = "/wonderful", method = RequestMethod.POST)
@ResponseBody
public String setWonderful(int id) {
discussPostService.updateStatus(id, 1);
// 触发发帖事件
Event event = new Event()
.setTopic(TOPIC_PUBLISH)
.setUserId(hostHolder.getUser().getId())
.setEntityType(ENTITY_TYPE_POST)
.setEntityId(id);
eventProducer.fireEvent(event);
String redisKey = RedisKeyUtil.getPostScoreKey();
redisTemplate.opsForSet().add(redisKey, id);
return CommunityUtil.getJSONString(0);
}
处理评论(打开 CommentController 类):
//帖子分数存入 redis 中
@Autowired
private RedisTemplate redisTemplate;
if (comment.getEntityType() == ENTITY_TYPE_POST) {
// 触发发帖事件
event = new Event()
.setTopic(TOPIC_PUBLISH)
.setUserId(comment.getUserId())
.setEntityType(ENTITY_TYPE_POST)
.setEntityId(discussPostId);
eventProducer.fireEvent(event);
//计算帖子分数
String redisKey = RedisKeyUtil.getPostScoreKey();
redisTemplate.opsForSet().add(redisKey, discussPostId);
}
点赞处理(打开 LikeController 类):
//帖子分数存入 redis 中
@Autowired
private RedisTemplate redisTemplate;
public String like(int entityType, int entityId, int entityUserId, int postId) {
//获取当前用户
User user = hostHolder.getUser();
//实现点赞:调用 LikeService
likeService.like(user.getId(), entityType, entityId, entityUserId);
//统计点赞数量、点赞状态返回页面,页面根据返回值做数量和状态显示
// 数量
long likeCount = likeService.findEntityLikeCount(entityType, entityId);
// 状态
int likeStatus = likeService.findEntityLikeStatus(user.getId(), entityType, entityId);
// 返回的结果(用 Map 封装)
Map<String, Object> map = new HashMap<>();
map.put("likeCount", likeCount);
map.put("likeStatus", likeStatus);
// 触发点赞事件
//点赞进行通知(likeStatus == 1),取消赞则不需要通知
if (likeStatus == 1) {
Event event = new Event()
.setTopic(TOPIC_LIKE)
.setUserId(hostHolder.getUser().getId())
.setEntityType(entityType)
.setEntityId(entityId)
.setEntityUserId(entityUserId)
.setData("postId", postId);//得到帖子 id,需要重构点赞方法:传入帖子 id
//触发事件
eventProducer.fireEvent(event);
}
if (entityType == ENTITY_TYPE_POST) {
//计算帖子分数
String redisKey = RedisKeyUtil.getPostScoreKey();
redisTemplate.opsForSet().add(redisKey, postId);
}
//返回页面
return CommunityUtil.getJSONString(0, null, map);
}
到目前为止项目中能够影响帖子分数的地方都把帖子 id 放在了集合中,接下来就要根据每隔时间段计算,需要使用到定时任务
新建 quartz 包新建 PostScoreRefreshJob 类(帖子分数刷新任务):
package com.example.demo.quartz;
import com.example.demo.entity.DiscussPost;
import com.example.demo.service.DiscussPostService;
import com.example.demo.service.ElasticsearchService;
import com.example.demo.service.LikeService;
import com.example.demo.util.CommunityConstant;
import com.example.demo.util.RedisKeyUtil;
import org.quartz.Job;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.BoundSetOperations;
import org.springframework.data.redis.core.RedisTemplate;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
/**
* 帖子分数刷新任务
*/
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("2014-08-01 00:00:00");
} catch (ParseException e) {
throw new RuntimeException("初始化牛客纪元失败!", e);
}
}
@Override
public void execute(JobExecutionContext context) throws JobExecutionException {
String redisKey = RedisKeyUtil.getPostScoreKey();
BoundSetOperations operations = redisTemplate.boundSetOps(redisKey);
if (operations.size() == 0) {
logger.info("[任务取消] 没有需要刷新的帖子!");
return;
}
logger.info("[任务开始] 正在刷新帖子分数: " + operations.size());
while (operations.size() > 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);
// 同步搜索数据
post.setScore(score);
elasticsearchService.saveDiscussPost(post);
}
}
目前没有更新帖子分数的方法,打开 DiscussPostMapper 类,添加更新帖子分数的方法:
//更新帖子分数
int updateScore(int id, double score);
打开 discusspost-mapper.xml 实现上述方法:
<update id="updateScore">
update discuss_post set score = #{score} where id = #{id}
</update>
同理需要在 DiscussPostService 中添加方法:
//更新帖子分数
public int updateScore(int id, double score) {
return discussPostMapper.updateScore(id, score);
}
接下来配置任务:在 config 包下新建 QuartzConfig 类:
package com.example.demo.config;
import com.example.demo.quartz.PostScoreRefreshJob;
import org.quartz.JobDataMap;
import org.quartz.JobDetail;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.quartz.JobDetailFactoryBean;
import org.springframework.scheduling.quartz.SimpleTriggerFactoryBean;
// 配置 -> 数据库 -> 调用
@Configuration
public class QuartzConfig {
// FactoryBean可简化Bean的实例化过程:
// 1.通过FactoryBean封装Bean的实例化过程.
// 2.将FactoryBean装配到Spring容器里.
// 3.将FactoryBean注入给其他的Bean.
// 4.该Bean得到的是FactoryBean所管理的对象实例.
// 刷新帖子分数任务
// 配置JobDetai
@Bean
public JobDetailFactoryBean postScoreRefreshJobDetail() {
JobDetailFactoryBean factoryBean = new JobDetailFactoryBean();
factoryBean.setJobClass(PostScoreRefreshJob.class);
factoryBean.setName("postScoreRefreshJob");
factoryBean.setGroup("communityJobGroup");
factoryBean.setDurability(true);
factoryBean.setRequestsRecovery(true);
return factoryBean;
}
// 配置Trigger(SimpleTriggerFactoryBean, CronTriggerFactoryBean)
@Bean
public SimpleTriggerFactoryBean postScoreRefreshTrigger(JobDetail postScoreRefreshJobDetail) {
SimpleTriggerFactoryBean factoryBean = new SimpleTriggerFactoryBean();
factoryBean.setJobDetail(postScoreRefreshJobDetail);
factoryBean.setName("postScoreRefreshTrigger");
factoryBean.setGroup("communityTriggerGroup");
factoryBean.setRepeatInterval(1000 * 60 * 5);
factoryBean.setJobDataMap(new JobDataMap());
return factoryBean;
}
}
最后进行前端页面修改
?生成长图使用 WK<html>TOpdf 工具
?在 application.properties 中配置 wk:
# wk
wk.image.command=d:/work/wkhtmltopdf/bin/wkhtmltoimage
wk.image.storage=e:/work/data/wk-images
确保没有路径的时候会自动创建路径,在 config 包下新建 WKConfig 类:
package com.example.demo.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import javax.annotation.PostConstruct;
import java.io.File;
@Configuration
public class WkConfig {
private static final Logger logger = LoggerFactory.getLogger(WkConfig.class);
@Value("${wk.image.storage}")
private String wkImageStorage;
@PostConstruct
public void init() {
// 创建WK图片目录
File file = new File(wkImageStorage);
if (!file.exists()) {
file.mkdir();
logger.info("创建WK图片目录: " + wkImageStorage);
}
}
}
模拟开发分享功能:打开 controller 包新建 ShareController 类,处理前端请求生成图片
在 CommunityConstant 类中添加常量
/** * 主题: 分享 */ String TOPIC_SHARE = "share";
处理异步请求(消费分享事件): 打开 EventConsumer 类
- 首先注入 wk.image.command 和 wk.image.storage
- 添加方法消费分享事件:添加注解 @KafkaListener(topics = TOPIC_SHARE), 类似于消费删除事件。如果获取到参数(htmlUrl、fileName、suffix),拼接参数命令执行(拼接参数:wkImageCommand + " --quality 75 " + htmlUrl + " " + wkImageStorage + "/" + fileName + suffix )
@Value("${wk.image.command}") private String wkImageCommand; @Value("${wk.image.storage}") private String wkImageStorage; // 消费分享事件 @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; } //获取 html、文件名、后缀 String htmlUrl = (String) event.getData().get("htmlUrl"); String fileName = (String) event.getData().get("fileName"); String suffix = (String) event.getData().get("suffix"); //拼接参数 String cmd = wkImageCommand + " --quality 75 " + htmlUrl + " " + wkImageStorage + "/" + fileName + suffix; //执行命令 try { Runtime.getRuntime().exec(cmd); logger.info("生成长图成功: " + cmd); } catch (IOException e) { logger.error("生成长图失败: " + e.getMessage()); } }
package com.example.demo.controller;
import com.example.demo.entity.Event;
import com.example.demo.event.EventProducer;
import com.example.demo.util.CommunityConstant;
import com.example.demo.util.CommunityUtil;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.util.HashMap;
import java.util.Map;
@Controller
public class ShareController implements CommunityConstant {
private static final Logger logger = LoggerFactory.getLogger(ShareController.class);
@Autowired
private EventProducer eventProducer;
@Value("${community.path.domain}")
private String domain;
@Value("${server.servlet.context-path}")
private String contextPath;
@Value("${wk.image.storage}")
private String wkImageStorage;
@RequestMapping(path = "/share", method = RequestMethod.GET)
@ResponseBody
public String share(String htmlUrl) {
// 文件名
String fileName = CommunityUtil.generateUUID();
// 异步生成长图
Event event = new Event()
.setTopic(TOPIC_SHARE)
.setData("htmlUrl", htmlUrl)
.setData("fileName", fileName)
.setData("suffix", ".png");
//触发事件
eventProducer.fireEvent(event);
// 返回访问路径
Map<String, Object> map = new HashMap<>();
map.put("shareUrl", domain + contextPath + "/share/image/" + fileName);
return CommunityUtil.getJSONString(0, null, map);
}
}
上述写了消费分享事件代码,只是生成了图片,给客户返回访问路径还没有处理
// 获取长图
@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());
}
}