17.热帖排行 + 生成长图

发布时间:2024年01月04日

目录

1.热帖排行

2.生成长图


1.热帖排行

帖子分数计算公式:log(精华分 + 评论分*10 + 点赞数*2)+ (发布时间 - 成立时间)

将分数存入 Redis 中:打开 RedisKeyUtil 类,新增前缀用来存储帖子,并且新增方法(返回统计帖子分数的 key)

    //新增前缀用来存储帖子
    private static final String PREFIX_POST = "post";

    // 帖子分数
    public static String getPostScoreKey() {
        return PREFIX_POST + SPLIT + "score";
    }

在影响帖子分数操作发生的时候把帖子 id 放在空间中(加精、评论、点赞

打开 discussPostController 类:

  • 在 Redis 中存入,需要注入 RedisTemplete
  • 新增帖子方法中需要计算帖子分数:在触发发帖事件之后需要 计算帖子分数,获取帖子分数的 key,将帖子 id 存入 Redis 的 set 中
  • 加精的时候计算帖子分数:同理上述操作
    @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 类):

  • 注入 RedisTemplete
  • 添加评论的时候帖子分数就发生变化,类似于上述操作:如果对帖子进行评论才去计算分数,获取 key,存入 Redis 中
    //帖子分数存入 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 类):

  • 注入 RedisTemplete
  • 点赞的时候帖子分数就发生变化,类似于上述操作:如果对帖子进行点赞才去计算分数,获取 key,存入 Redis 中
    //帖子分数存入 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 类(帖子分数刷新任务):

  • 实现 Job,CommunityConstant 接口
  • 实例化日志,如果出现问题记录日志
  • 注入 RedisTemplate
  • 计算的时候查询帖子点赞数量,把最新的数据同步到搜索引擎,注入 DiscusspostService、LikeService、ElasticsearchService
  • 声明成立时间
  • 添加 Job 实现方法,实现定时任务:定义 key,需要计算每一个 key,即反复进行操作,使用 BoundSetOperations 进行处理;判断 key 的空间中是否有数据(某一时刻没有人做任何操作就不用计算)
  • 如果没有数据,记录日志(任务取消,没有需要刷新的帖子);如果存在数据,也记录日志(任务开始,正在刷新帖子分数),当 Redis 中有数据的时候就进行计算刷新
  • 添加刷新方法:传入帖子 id,根据帖子 id 查询此帖子——如果帖子为空,记录日志(该帖子不存在);
  • 如果不为空进一步操作:是否加精、评论数量、点赞数量(帖子分数计算公式:log(精华分 + 评论数*10 + 点赞数*2)+ (发布时间 - 成立时间))
  • 利用上述公式计算权重、分数 = 帖子权重 + 距离天数;在这里需要注意的是,对权重取log之后不直接使用权重,如果权重为小数,则取log变成负数了,在这里应该取得是 Math.max(权重,1)。天数先换算成毫秒相减计算,之后在换算成天数(1000*3600*24)
  • 最后更新帖子分数、同步搜索数据
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;
    }

}

最后进行前端页面修改

2.生成长图

?生成长图使用 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 类,处理前端请求生成图片

  • 实例化日志
  • 提供一个方法:前端访问则生成图片,是一个异步的请求方式,使用事件驱动,Controller 只需要将事件丢给 kafka,后续由 kafka 异步实现,注入 EventProducer
  • 还需要使用到应用程序的域名、项目访问路径,也需要注入
  • 传入存放图片的位置
  • 添加分享请求方法:首先声明访问路径,异步请求添加 @ResponseBody
  • 接下来实现方法,传入 htmlUrl参数,生成随机的文件名,然后使用异步生成长图(包括主题、html路径、文件名、后缀 ),触发事件
  • 主题需要在常量接口中声明,然后实现这个接口

在 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());
        }
    }
  • ?然后需要给客户端返回访问路径(返回 JSON 字符串)
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());
        }
    }
文章来源:https://blog.csdn.net/m0_72161237/article/details/135231637
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。