博客新增每日推荐功能

每日推荐弹窗功能实现

最近给博客加了个功能:用户进入网站首页时,弹出每日推荐弹窗,每天随机推荐一篇文章。同一天内不会重复显示,提升用户体验。

一、功能需求

  • 用户进入网站首页时,弹出每日推荐弹窗
  • 每天随机推荐一篇文章(同一天返回同一篇)
  • 展示文章封面、标题、日期和阅读按钮
  • 同一天内只显示一次(localStorage 记录)

二、技术选型

  • 后端:Spring Boot + Redis + MyBatis-Plus
  • 前端:Vue 3 + TypeScript + Axios + localStorage

三、后端实现

1. Redis 常量

RedisConstant.java 中添加:

java 复制代码
package com.ican.constant;

/**
 * Redis常量
 *
 * @author ican
 */
public class RedisConstant {

    // ... 其他常量 ...

    /**
     * 每日推荐文章
     */
    public static final String DAILY_ARTICLE = "daily_article:";
}

2. 控制器添加接口

ArticleController.java 中添加:

java 复制代码
package com.ican.controller;

import com.ican.model.vo.Result;
import com.ican.model.vo.response.ArticleRecommendResp;
import com.ican.service.ArticleService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

/**
 * 文章控制器
 *
 * @author ican
 */
@Api(tags = "文章模块")
@RestController
public class ArticleController {

    @Autowired
    private ArticleService articleService;

    /**
     * 每日推荐文章
     *
     * @return {@link Result<ArticleRecommendResp>} 每日推荐文章
     */
    @ApiOperation(value = "每日推荐文章")
    @GetMapping("/article/daily")
    public Result<ArticleRecommendResp> getDailyArticle() {
        return Result.success(articleService.getDailyArticle());
    }
}

接口放在 /article/ 路径下,不需要登录(前台功能)。

3. 服务类添加方法

ArticleService.java 中添加:

java 复制代码
package com.ican.service;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.ican.constant.CommonConstant;
import com.ican.constant.RedisConstant;
import com.ican.entity.Article;
import com.ican.enums.ArticleStatusEnum;
import com.ican.mapper.ArticleMapper;
import com.ican.model.vo.response.ArticleRecommendResp;
import com.ican.service.RedisService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Random;

@Service
public class ArticleService {

    @Autowired
    private ArticleMapper articleMapper;

    @Autowired
    private RedisService redisService;

    /**
     * 每日推荐文章(同一天返回同一篇,基于 Redis 缓存)
     *
     * @return 推荐文章信息
     */
    public ArticleRecommendResp getDailyArticle() {
        String todayKey = RedisConstant.DAILY_ARTICLE + LocalDate.now();
        
        // 尝试从缓存获取
        Object cachedId = redisService.getObject(todayKey);
        if (cachedId != null) {
            Integer articleId = Integer.parseInt(cachedId.toString());
            Article article = articleMapper.selectOne(new LambdaQueryWrapper<Article>()
                    .select(Article::getId, Article::getArticleTitle, Article::getArticleCover, Article::getCreateTime)
                    .eq(Article::getId, articleId)
                    .eq(Article::getIsDelete, CommonConstant.FALSE)
                    .eq(Article::getStatus, ArticleStatusEnum.PUBLIC.getStatus()));
            if (article != null) {
                // 返回缓存的文章
                ArticleRecommendResp resp = new ArticleRecommendResp();
                resp.setId(article.getId());
                resp.setArticleTitle(article.getArticleTitle());
                resp.setArticleCover(article.getArticleCover());
                resp.setCreateTime(article.getCreateTime());
                return resp;
            }
        }
        
        // 随机选一篇公开文章
        Long count = articleMapper.selectCount(new LambdaQueryWrapper<Article>()
                .eq(Article::getIsDelete, CommonConstant.FALSE)
                .eq(Article::getStatus, ArticleStatusEnum.PUBLIC.getStatus()));
        if (count == 0) {
            return null;
        }
        
        // 随机偏移
        int offset = new Random().nextInt(count.intValue());
        List<Article> articles = articleMapper.selectList(new LambdaQueryWrapper<Article>()
                .select(Article::getId, Article::getArticleTitle, Article::getArticleCover, Article::getCreateTime)
                .eq(Article::getIsDelete, CommonConstant.FALSE)
                .eq(Article::getStatus, ArticleStatusEnum.PUBLIC.getStatus())
                .last("LIMIT 1 OFFSET " + offset));
        if (articles.isEmpty()) {
            return null;
        }
        
        Article article = articles.get(0);
        
        // 缓存到 Redis,过期时间到当天结束
        long secondsUntilMidnight = Duration.between(
                LocalDateTime.now(),
                LocalDate.now().plusDays(1).atStartOfDay()
        ).getSeconds();
        redisService.setObject(todayKey, article.getId(), secondsUntilMidnight, TimeUnit.SECONDS);
        
        ArticleRecommendResp resp = new ArticleRecommendResp();
        resp.setId(article.getId());
        resp.setArticleTitle(article.getArticleTitle());
        resp.setArticleCover(article.getArticleCover());
        resp.setCreateTime(article.getCreateTime());
        return resp;
    }
}

关键点说明

  • LocalDate.now() 作为 Redis key 的一部分,确保同一天返回同一篇文章
  • 先尝试从缓存获取,有则直接返回
  • 没有缓存则随机选一篇公开文章
  • 随机方法:先查总数,再用 OFFSET 随机偏移(性能比 ORDER BY RAND() 好)
  • 缓存过期时间:计算到第二天 0 点的秒数,确保当天结束前有效

4. 响应实体类

确保有 ArticleRecommendResp.java

java 复制代码
package com.ican.model.vo.response;

import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

import java.time.LocalDateTime;

/**
 * 推荐文章Response
 *
 * @author ican
 */
@Data
@ApiModel(description = "推荐文章Response")
public class ArticleRecommendResp {

    @ApiModelProperty(value = "文章id")
    private Integer id;

    @ApiModelProperty(value = "文章标题")
    private String articleTitle;

    @ApiModelProperty(value = "文章缩略图")
    private String articleCover;

    @ApiModelProperty(value = "发布时间")
    private LocalDateTime createTime;
}

四、前端实现

1. API 函数

api/article/index.ts 中添加:

typescript 复制代码
import { Result } from "@/model";
import request from "@/utils/request";
import { AxiosPromise } from "axios";
import { ArticleRecommend } from "./types";

/**
 * 每日推荐文章
 * @returns 推荐文章
 */
export function getDailyArticle(): AxiosPromise<Result<ArticleRecommend>> {
  return request({
    url: "/article/daily",
    method: "get",
  });
}

2. 创建每日推荐组件

创建 components/DailyRecommend/index.vue

vue 复制代码
<template>
    <Teleport to="body">
        <Transition name="daily-fade">
            <div class="daily-overlay" v-if="visible" @click.self="close">
                <div class="daily-card">
                    <button class="daily-close" @click="close">
                        <svg-icon icon-class="close" size="1rem"></svg-icon>
                    </button>
                    <div class="daily-badge">每日推荐</div>
                    <div class="daily-cover" v-if="article">
                        <router-link :to="`/article/${article.id}`" @click="close">
                            <img :src="article.articleCover" alt="article cover" />
                        </router-link>
                    </div>
                    <div class="daily-info" v-if="article">
                        <router-link
                            :to="`/article/${article.id}`"
                            class="daily-title"
                            @click="close"
                        >
                            {{ article.articleTitle }}
                        </router-link>
                        <div class="daily-date">
                            <svg-icon
                                icon-class="calendar"
                                size="0.8rem"
                                style="margin-right: 0.3rem"
                            ></svg-icon>
                            {{ formatDate(article.createTime) }}
                        </div>
                    </div>
                    <div class="daily-loading" v-else>
                        加载中...
                    </div>
                    <div class="daily-footer">
                        <router-link
                            v-if="article"
                            :to="`/article/${article.id}`"
                            class="daily-read-btn"
                            @click="close"
                        >
                            阅读文章
                        </router-link>
                    </div>
                </div>
            </div>
        </Transition>
    </Teleport>
</template>

<script setup lang="ts">
import { getDailyArticle } from "@/api/article";
import { ArticleRecommend } from "@/api/article/types";
import { formatDate } from "@/utils/date";

const visible = ref(false);
const article = ref<ArticleRecommend | null>(null);

onMounted(() => {
    // 检查今天是否已经展示过
    const today = new Date().toISOString().slice(0, 10);
    const lastShown = localStorage.getItem("daily_recommend_date");
    if (lastShown === today) return;

    getDailyArticle()
        .then(({ data }) => {
            if (data.flag && data.data) {
                article.value = data.data;
                visible.value = true;
                localStorage.setItem("daily_recommend_date", today);
            }
        })
        .catch(() => {
            // 静默失败,不影响页面正常显示
        });
});

const close = () => {
    visible.value = false;
};
</script>

<style lang="scss" scoped>
.daily-fade-enter-active,
.daily-fade-leave-active {
    transition: opacity 0.3s ease;
}

.daily-fade-enter-from,
.daily-fade-leave-to {
    opacity: 0;
}

.daily-overlay {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: rgba(0, 0, 0, 0.45);
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 9999;
    backdrop-filter: blur(4px);
}

.daily-card {
    position: relative;
    width: 380px;
    max-width: 90vw;
    background: var(--grey-0);
    border-radius: 1rem;
    overflow: hidden;
    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
    animation: slideUp 0.4s ease;
}

@keyframes slideUp {
    from {
        transform: translateY(30px);
        opacity: 0;
    }
    to {
        transform: translateY(0);
        opacity: 1;
    }
}

.daily-close {
    position: absolute;
    top: 0.75rem;
    right: 0.75rem;
    z-index: 10;
    background: rgba(0, 0, 0, 0.3);
    border: none;
    border-radius: 50%;
    width: 2rem;
    height: 2rem;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    color: #fff;
    transition: background 0.2s;

    &:hover {
        background: rgba(0, 0, 0, 0.5);
    }
}

.daily-badge {
    position: absolute;
    top: 0.75rem;
    left: 0.75rem;
    z-index: 10;
    background: linear-gradient(135deg, #667eea, #764ba2);
    color: #fff;
    padding: 0.25rem 0.75rem;
    border-radius: 1rem;
    font-size: 0.8rem;
    font-weight: 600;
    letter-spacing: 0.05rem;
}

.daily-cover {
    width: 100%;
    height: 200px;
    overflow: hidden;

    img {
        width: 100%;
        height: 100%;
        object-fit: cover;
        transition: transform 0.3s;

        &:hover {
            transform: scale(1.05);
        }
    }
}

.daily-info {
    padding: 1rem 1.25rem 0.5rem;
}

.daily-title {
    display: block;
    font-size: 1.1rem;
    font-weight: 600;
    color: var(--grey-8);
    line-height: 1.5;
    margin-bottom: 0.5rem;
    text-decoration: none;
    transition: color 0.2s;

    &:hover {
        color: var(--primary-color);
    }
}

.daily-date {
    display: flex;
    align-items: center;
    font-size: 0.8rem;
    color: var(--grey-5);
}

.daily-loading {
    padding: 2rem;
    text-align: center;
    color: var(--grey-5);
}

.daily-footer {
    padding: 0.75rem 1.25rem 1.25rem;
    display: flex;
    justify-content: center;
}

.daily-read-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    padding: 0.5rem 1.5rem;
    background: linear-gradient(135deg, #667eea, #764ba2);
    color: #fff;
    border-radius: 2rem;
    font-size: 0.9rem;
    text-decoration: none;
    transition: all 0.2s;

    &:hover {
        transform: translateY(-2px);
        box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
    }
}

@media (max-width: 767px) {
    .daily-card {
        width: 320px;
    }

    .daily-cover {
        height: 160px;
    }
}
</style>

3. 在首页引入

views/Home/index.vue 中:

vue 复制代码
<template>
    <!-- ... 其他内容 ... -->
    <!-- 每日推荐 -->
    <DailyRecommend></DailyRecommend>
</template>

<script setup lang="ts">
import DailyRecommend from "@/components/DailyRecommend/index.vue";
// ... 其他导入 ...
</script>

五、使用效果

  1. 用户首次进入网站首页
  2. 页面加载完成后,自动调用每日推荐接口
  3. 如果今天还没显示过,弹出每日推荐弹窗
  4. 弹窗展示随机文章的封面、标题、日期
  5. 用户可以点击「阅读文章」跳转,或点击关闭按钮
  6. 同一天内不会重复显示(localStorage 记录)

六、遇到的问题

1. Redis 过期时间计算

问题:需要确保缓存到当天结束,但计算到第二天 0 点的秒数可能不准确。

解决 :用 Duration.between() 计算当前时间到第二天 0 点的秒数,确保精确。

2. 随机选择文章

问题 :MySQL 的 ORDER BY RAND() 性能不好,文章多时会很慢。

解决 :先查总数,再用 OFFSET 随机偏移:LIMIT 1 OFFSET {randomOffset}。性能更好。

3. 前端重复显示

问题:用户刷新页面时,可能重复显示弹窗。

解决 :用 localStorage 记录今天是否已显示过,key 是 daily_recommend_date,value 是日期字符串(如 2026-02-08)。

4. 文章不存在

问题:缓存的文章 ID 对应的文章可能已被删除或改为私密。

解决:从缓存获取文章后,再查一次数据库,确保文章存在且是公开状态。如果不存在,重新随机选择。

七、总结

这个功能实现起来不算复杂:

  1. 后端:用 Redis 缓存当天随机选中的文章,确保同一天返回同一篇
  2. 前端:首页加载时调用接口,检查今天是否已显示过,未显示则弹出弹窗

关键是:

  • Redis key 用日期,确保同一天返回同一篇文章
  • 缓存过期时间精确到当天结束
  • 随机选择用 OFFSET 而不是 ORDER BY RAND(),性能更好
  • 前端用 localStorage 记录今天是否已显示过
  • 处理文章不存在的情况,重新随机选择

现在用户进入网站时,每天都能看到一篇推荐文章,提升用户体验。如果用户不想看,也可以关闭弹窗,不影响正常浏览。

八、完整代码文件清单

后端

  • RedisConstant.java - 添加 DAILY_ARTICLE 常量
  • ArticleController.java - 添加 /article/daily 接口
  • ArticleService.java - 添加 getDailyArticle 方法
  • ArticleRecommendResp.java - 响应实体类(如果还没有)

前端

  • api/article/index.ts - 添加 getDailyArticle 函数
  • components/DailyRecommend/index.vue - 新建每日推荐组件
  • views/Home/index.vue - 引入每日推荐组件

按照上面的步骤,就可以在自己的项目中实现这个功能了。

相关推荐
VALENIAN瓦伦尼安教学设备5 分钟前
设备对中不良的危害
数据库·嵌入式硬件·算法
wx_xkq12886 分钟前
营销智脑V3重磅迭代:从工具到平台,AI营销进入“全能时代“
人工智能
阿钱真强道7 分钟前
02 从 MLP 到 LeNet:数据、标签和任务:机器学习到底在解决什么问题?
人工智能·深度学习·机器学习·cnn·分类算法·lenet
天蓝色的鱼鱼9 分钟前
别慌!AI时代,记住这12个新名词,你就赢了一半的人
人工智能
秋916 分钟前
《世界的本质》的深度分析与解读,给出了如何“顺天应人”以实现个人价值最大化的行动指南
人工智能
小兔崽子去哪了17 分钟前
Docker 安装 PostgreSQL
数据库·后端·postgresql
Sweet锦18 分钟前
SpringBoot 3.5 集成 InfluxDB 1.8
spring boot·时序数据库
野犬寒鸦21 分钟前
Redis热点key问题解析与实战解决方案(附大厂实际方案讲解)
服务器·数据库·redis·后端·缓存·bootstrap
阿钱真强道24 分钟前
04 从 MLP 到 LeNet:sigmoid 和 softmax 到底在做什么?为什么输出层需要它们?
人工智能·机器学习·softmax·分类模型·sigmoid·深度学习入门
Forrit25 分钟前
Agent长期运行(Long-Running Tasks)实现方案与核心挑战
大数据·人工智能·深度学习