博客新增每日推荐功能

每日推荐弹窗功能实现

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

一、功能需求

  • 用户进入网站首页时,弹出每日推荐弹窗
  • 每天随机推荐一篇文章(同一天返回同一篇)
  • 展示文章封面、标题、日期和阅读按钮
  • 同一天内只显示一次(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 - 引入每日推荐组件

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

相关推荐
九.九7 小时前
ops-transformer:AI 处理器上的高性能 Transformer 算子库
人工智能·深度学习·transformer
春日见7 小时前
拉取与合并:如何让个人分支既包含你昨天的修改,也包含 develop 最新更新
大数据·人工智能·深度学习·elasticsearch·搜索引擎
小高不会迪斯科7 小时前
CMU 15445学习心得(二) 内存管理及数据移动--数据库系统如何玩转内存
数据库·oracle
恋猫de小郭7 小时前
AI 在提高你工作效率的同时,也一直在增加你的疲惫和焦虑
前端·人工智能·ai编程
deephub7 小时前
Agent Lightning:微软开源的框架无关 Agent 训练方案,LangChain/AutoGen 都能用
人工智能·microsoft·langchain·大语言模型·agent·强化学习
e***8907 小时前
MySQL 8.0版本JDBC驱动Jar包
数据库·mysql·jar
l1t7 小时前
在wsl的python 3.14.3容器中使用databend包
开发语言·数据库·python·databend
大模型RAG和Agent技术实践7 小时前
从零构建本地AI合同审查系统:架构设计与流式交互实战(完整源代码)
人工智能·交互·智能合同审核
老邋遢7 小时前
第三章-AI知识扫盲看这一篇就够了
人工智能
互联网江湖7 小时前
Seedance2.0炸场:长短视频们“修坝”十年,不如AI放水一天?
人工智能