问答平台(7),热帖排行

问题背景

热帖排行-图示
将分数变化的帖子放入 Redis 缓存中,每5分钟定时任务刷新帖子的分数。

1
2
3
- 热帖排行功能的实现需要定时,即每隔段时间就要从数据库中查询最热门的帖子显示,所以使用定时任务来实现。
- JDK的ScheduledExecutorService 和 Spring的ThreadPoolTaskScheduler 都可以实现定时任务,但是在分布式的环境下会出现问题。
- 本项目采用Quartz, quartz实现定时任务的参数是存到数据库中(同一个DB)。

工具类

  • RedisKeyUtil: 添加内容
    1
    2
    3
    4
    5
    6
    private static final String PREFIX_POST = "post";

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

表现层

  • DiscussPostController: 修改(计算帖子分数)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    @Autowired
    private RedisTemplate redisTemplate;

    @RequestMapping(path = "/add", method = RequestMethod.POST)
    @ResponseBody
    public String addDiscussPost(String title, String content) {
    User user = hostHolder.getUser();
    if (user == null) {
    return CommunityUtil.getJSONString(403, "还没有登录!");
    }

    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();
    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: 修改
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    @Autowired
    private RedisTemplate redisTemplate;

    @RequestMapping(path = "/add/{discussPostId}", method = RequestMethod.POST)
    public String addComment(@PathVariable("discussPostId") int discussPostId, Comment comment) {
    comment.setUserId(hostHolder.getUser().getId());
    comment.setStatus(0);
    comment.setCreateTime(new Date());
    commentService.addComment(comment);

    // 触发评论事件
    Event event = new Event()
    .setTopic(TOPIC_COMMENT)
    .setUserId(hostHolder.getUser().getId())
    .setEntityType(comment.getEntityType())
    .setEntityId(comment.getEntityId())
    .setData("postId", discussPostId);
    if (comment.getEntityType() == ENTITY_TYPE_POST) {
    DiscussPost target = discussPostService.findDiscussPostById(comment.getEntityId());
    event.setEntityUserId(target.getUserId());
    } else if (comment.getEntityType() == ENTITY_TYPE_COMMENT) {
    Comment target = commentService.findCommentById(comment.getEntityId());
    event.setEntityUserId(target.getUserId());
    }
    eventProducer.fireEvent(event);

    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);
    }

    return "redirect:/discuss/detail/" + discussPostId;
    }
  • LikeController: 修改
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    @Autowired
    private RedisTemplate redisTemplate;

    @RequestMapping(path = "/like", method = RequestMethod.POST)
    @ResponseBody
    public String like(int entityType, int entityId, int entityUserId, int postId) {
    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);

    // 触发点赞事件
    if (likeStatus == 1) {
    Event event = new Event()
    .setTopic(TOPIC_LIKE)
    .setUserId(hostHolder.getUser().getId())
    .setEntityType(entityType)
    .setEntityId(entityId)
    .setEntityUserId(entityUserId)
    .setData("postId", postId);
    eventProducer.fireEvent(event);
    }

    if (entityType == ENTITY_TYPE_POST) {
    // 计算帖子分数
    String redisKey = RedisKeyUtil.getPostScoreKey();
    redisTemplate.opsForSet().add(redisKey, postId);
    }

    return CommunityUtil.getJSONString(0, null, map);
    }

quartz

  • PostScoreRefreshJob: 新增
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    // 定时任务
    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 * 60 * 60 * 24);
    // 更新帖子分数
    discussPostService.updateScore(postId, score);
    // 同步搜索数据
    post.setScore(score);
    elasticsearchService.saveDiscussPost(post);
    }
    }

config

  • QuartzConfig: 添加内容
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    // 刷新帖子分数任务- 将分数变化的帖子丢到Redis缓存中,每5分钟计算一次帖子的分数。
    @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;
    }

    @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;
    }

数据访问层

  • DiscussPostMapper: 重构 selectDiscussPosts
  • orderMode: 排序模式(0默认,1热度)
    1
    2
    int updateScore(int id, double score);
    List<DiscussPost> selectDiscussPosts(int userId, int offset, int limit, int orderMode);
  • discusspost-mapper.xml: 重构
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    <update id="updateScore">
    update discuss_post set score = #{score} where id = #{id}
    </update>

    <select id="selectDiscussPosts" resultType="DiscussPost">
    select
    <include refid="selectFields"></include>
    from discuss_post
    where status != 2
    <if test="userId != 0">
    and user_id = #{userId}
    </if>
    <if test="orderMode==0">
    order by type desc, create_time desc
    </if>
    <if test="orderMode==1">
    order by type desc, score desc, create_time desc
    </if>
    limit #{offset}, #{limit}
    </select>

业务层

  • DiscussPostService: 重构 findDiscussPosts
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public int updateScore(int id, double score) {
    return discussPostMapper.updateScore(id, score);
    }

    public List<DiscussPost> findDiscussPosts(int userId, int offset, int limit, int orderMode) {
    if (userId == 0 && orderMode == 1) {
    return postListCache.get(offset + ":" + limit);
    }

    logger.debug("load post list from DB.");
    return discussPostMapper.selectDiscussPosts(userId, offset, limit, orderMode);
    }

表现层

  • HomeController: 重构 getIndexPage
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    @RequestMapping(path = "/index", method = RequestMethod.GET)
    public String getIndexPage(Model model, Page page,
    @RequestParam(name = "orderMode", defaultValue = "0") int orderMode) {
    // 方法调用前,SpringMVC会自动实例化Model和Page,并将Page注入Model
    // 所以,在thymeleaf中可以直接访问Page对象中的数据
    page.setRows(discussPostService.findDiscussPostRows(0));
    page.setPath("/index?orderMode=" + orderMode);

    List<DiscussPost> list = discussPostService
    .findDiscussPosts(0, page.getOffset(), page.getLimit(), orderMode);
    List<Map<String, Object>> discussPosts = new ArrayList<>();
    if (list != null) {
    for (DiscussPost post : list) {
    Map<String, Object> map = new HashMap<>();
    map.put("post", post);
    User user = userService.findUserById(post.getUserId());
    map.put("user", user);

    long likeCount = likeService.findEntityLikeCount(ENTITY_TYPE_POST, post.getId());
    map.put("likeCount", likeCount);

    discussPosts.add(map);
    }
    }
    model.addAttribute("discussPosts", discussPosts);
    model.addAttribute("orderMode", orderMode);

    return "/index";
    }

页面

  • index.html: 修改
    1
    2
    3
    4
    5
    6
    7
    8
    <!-- 内容 -->
    <!-- 筛选条件 -->
    <li class="nav-item">
    <a th:class="|nav-link ${orderMode==0?'active':''}|" th:href="@{/index(orderMode=0)}">最新</a>
    </li>
    <li class="nav-item">
    <a th:class="|nav-link ${orderMode==1?'active':''}|" th:href="@{/index(orderMode=1)}">最热</a>
    </li>

结果展示

热帖排行展示-图示


问答平台(7),热帖排行
https://lcf163.github.io/2020/06/17/问答平台(7),热帖排行/
作者
乘风的小站
发布于
2020年6月17日
许可协议