【Redis | 黑马点评】达人探店功能源码解读

目录

  • [0. 业务背景:达人探店是什么](#0. 业务背景:达人探店是什么)
  • [1. 发布探店笔记](#1. 发布探店笔记)
  • [2. 查看探店笔记](#2. 查看探店笔记)
  • [3. 点赞功能](#3. 点赞功能)
  • [4. 点赞排行榜](#4. 点赞排行榜)
  • [5. 关键技术原理总结](#5. 关键技术原理总结)
  • [6. 练习题](#6. 练习题)
  • [7. 练习题参考答案与逐题反馈](#7. 练习题参考答案与逐题反馈)
  • 附录:源码索引

0. 业务背景:达人探店是什么

0.1 业务场景

"达人探店"是平台上的用户内容模块:

  • 用户到店消费后,可以发布一篇探店笔记;
  • 笔记包含标题、文字描述、最多 9 张图片、关联的商户;
  • 其他用户可以在首页看到热门笔记,进入详情页阅读;
  • 用户可以对笔记点赞/取消点赞;
  • 详情页展示最早点赞的 5 个用户头像,形成"点赞排行榜"。

0.2 数据模型

核心表是 tb_blog(resources/db/hmdp.sql:24-36):

字段 类型 含义
id bigint unsigned auto_increment 主键
shop_id bigint 关联商户 id
user_id bigint unsigned 发布者 id
title varchar(255) 标题
images varchar(2048) 图片路径,最多 9 张,逗号分隔
content varchar(2048) 文字描述
liked int unsigned default 0 点赞数
comments int unsigned 评论数
create_time / update_time timestamp 创建/更新时间

对应实体 Blog.java,其中三个字段不是数据库列:

java 复制代码
@TableField(exist = false)
private String icon;      // 发布者头像,查询时填充

@TableField(exist = false)
private String name;      // 发布者昵称,查询时填充

@TableField(exist = false)
private Boolean isLike;   // 当前登录用户是否点过赞,查询时填充

这三个字段是"展示态字段":数据库里没有,返回给前端时才组装进去。

0.3 接口总览

模块 方法 路径 登录要求
发布 POST /blog 需要
图片上传 POST /upload/blog 不需要(/upload/** 被排除)
图片删除 GET /upload/blog/delete 不需要
热门列表 GET /blog/hot?current= 不需要
我的笔记 GET /blog/of/me 需要
笔记详情 GET /blog/{id} 需要
点赞/取消 PUT /blog/like/{id} 需要
点赞排行榜 GET /blog/likes/{id} 需要

登录要求来自 MvcConfig.java:22-31 的 excludePathPatterns。/blog/hot、/upload/** 被排除,其余 /blog/** 会走 LoginInterceptor,没有登录用户就返回 401。


1. 发布探店笔记

1.1 业务流程

MySQL BlogController UploadController blog-edit.html 用户 MySQL BlogController UploadController blog-edit.html 用户 #mermaid-svg-jckFnkI1CmWvG86D{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-jckFnkI1CmWvG86D .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-jckFnkI1CmWvG86D .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-jckFnkI1CmWvG86D .error-icon{fill:#552222;}#mermaid-svg-jckFnkI1CmWvG86D .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-jckFnkI1CmWvG86D .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-jckFnkI1CmWvG86D .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-jckFnkI1CmWvG86D .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-jckFnkI1CmWvG86D .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-jckFnkI1CmWvG86D .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-jckFnkI1CmWvG86D .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-jckFnkI1CmWvG86D .marker{fill:#333333;stroke:#333333;}#mermaid-svg-jckFnkI1CmWvG86D .marker.cross{stroke:#333333;}#mermaid-svg-jckFnkI1CmWvG86D svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-jckFnkI1CmWvG86D p{margin:0;}#mermaid-svg-jckFnkI1CmWvG86D .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-jckFnkI1CmWvG86D text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-jckFnkI1CmWvG86D .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-jckFnkI1CmWvG86D .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-jckFnkI1CmWvG86D .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-jckFnkI1CmWvG86D .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-jckFnkI1CmWvG86D #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-jckFnkI1CmWvG86D .sequenceNumber{fill:white;}#mermaid-svg-jckFnkI1CmWvG86D #sequencenumber{fill:#333;}#mermaid-svg-jckFnkI1CmWvG86D #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-jckFnkI1CmWvG86D .messageText{fill:#333;stroke:none;}#mermaid-svg-jckFnkI1CmWvG86D .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-jckFnkI1CmWvG86D .labelText,#mermaid-svg-jckFnkI1CmWvG86D .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-jckFnkI1CmWvG86D .loopText,#mermaid-svg-jckFnkI1CmWvG86D .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-jckFnkI1CmWvG86D .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-jckFnkI1CmWvG86D .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-jckFnkI1CmWvG86D .noteText,#mermaid-svg-jckFnkI1CmWvG86D .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-jckFnkI1CmWvG86D .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-jckFnkI1CmWvG86D .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-jckFnkI1CmWvG86D .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-jckFnkI1CmWvG86D .actorPopupMenu{position:absolute;}#mermaid-svg-jckFnkI1CmWvG86D .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-jckFnkI1CmWvG86D .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-jckFnkI1CmWvG86D .actor-man circle,#mermaid-svg-jckFnkI1CmWvG86D line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-jckFnkI1CmWvG86D :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 选择图片 POST /upload/blog (multipart file) Result.ok("/blogs/7/14/uuid.jpg") fileList.push("/imgs" + data) 填写标题/内容,选择商户,点击发布 POST /blog {title, content, images, shopId} 从 UserHolder 取 userId INSERT tb_blog Result.ok(blogId) 跳转 info.html GET /blog/of/me 我的笔记列表

发布分为两段:

  1. 图片先上传,得到服务器上的文件路径;
  2. 提交笔记,把标题、内容、图片路径、商户 id 一起发给后端。

1.2 图片上传接口

UploadController.java:20-36:

java 复制代码
@PostMapping("blog")
public Result uploadImage(@RequestParam("file") MultipartFile image) {
    try {
        // 获取原始文件名称
        String originalFilename = image.getOriginalFilename();
        // 生成新文件名
        String fileName = createNewFileName(originalFilename);
        // 保存文件
        image.transferTo(new File(SystemConstants.IMAGE_UPLOAD_DIR, fileName));
        // 返回结果
        log.debug("文件上传成功,{}", fileName);
        return Result.ok(fileName);
    } catch (IOException e) {
        throw new RuntimeException("文件上传失败", e);
    }
}

createNewFileName(UploadController.java:47-63):

java 复制代码
private String createNewFileName(String originalFilename) {
    // 获取后缀
    String suffix = StrUtil.subAfter(originalFilename, ".", true);
    // 生成目录
    String name = UUID.randomUUID().toString();
    int hash = name.hashCode();
    int d1 = hash & 0xF;
    int d2 = (hash >> 4) & 0xF;
    // 判断目录是否存在
    File dir = new File(SystemConstants.IMAGE_UPLOAD_DIR,
            StrUtil.format("/blogs/{}/{}", d1, d2));
    if (!dir.exists()) {
        dir.mkdirs();
    }
    // 生成文件名
    return StrUtil.format("/blogs/{}/{}/{}.{}", d1, d2, name, suffix);
}

设计要点:

  • 文件名用 UUID,避免用户上传同名文件互相覆盖,也避免原始文件名里的特殊字符;
  • 目录按 hash 分成 16×16 个桶,避免单个目录下文件过多;
  • 返回的是相对路径 /blogs/7/14/uuid.jpg,不含 /imgs 前缀;
  • 前端拿到后拼成 /imgs/blogs/7/14/uuid.jpg(blog-edit.html:127-129):
js 复制代码
axios.post("/upload/blog", formData, config)
  .then(({data}) => this.fileList.push('/imgs' + data))

1.3 图片存放位置与 nginx

SystemConstants.java:4:

java 复制代码
public static final String IMAGE_UPLOAD_DIR =
        "D:\\BaiduNetdiskDownload\\heimadianping\\nginx-1.18.0\\html\\hmdp\\imgs";

这个目录就是 nginx 静态资源的 imgs 目录。前端用 /imgs/... 访问时,nginx 直接返回该目录下的文件。

因此上传接口和 nginx 必须指向同一个目录,否则会出现:

  • 数据库里有路径;
  • 浏览器访问 404。

这个路径是硬编码的 Windows 绝对路径,换机器/部署到 Linux 时需要修改,属于当前项目的可移植性问题。

1.4 发布接口

BlogController.java:35-44:

java 复制代码
@PostMapping
public Result saveBlog(@RequestBody Blog blog) {
    // 获取登录用户
    UserDTO user = UserHolder.getUser();
    blog.setUserId(user.getId());
    // 保存探店博文
    blogService.save(blog);
    // 返回id
    return Result.ok(blog.getId());
}

前端提交(blog-edit.html:108-113):

js 复制代码
submitBlog() {
  let {...data} = this.params;
  data.images = this.fileList.join(",");
  data.shopId = this.selectedShop.id;
  axios.post("/blog", data)
    .then(resp => location.href = "/info.html")
    .catch(this.$message.error)
}

几个关键点:

  1. userId 由后端从 UserHolder 取,不信任前端传值;
  2. images 是前端把多个图片路径用逗号拼起来的字符串;
  3. shopId 来自页面选中的商户;
  4. blogService.save(blog) 是 MyBatis-Plus 的插入方法,id 由数据库自增(@TableId(type = IdType.AUTO));
  5. 返回 blog.getId(),前端不使用返回值,直接跳转到 info.html。

1.5 发布模块的边界与风险

问题 位置 触发条件 影响 当前是否处理
客户端可以伪造 liked、comments、createTime 等非身份字段 BlogController.saveBlog 的 @RequestBody Blog 直接构造请求体额外字段 点赞数/评论数/创建时间可被伪造(id 由数据库自增,通常不受客户端控制) 未处理
没有服务端字段校验 saveBlog 标题为空、内容超长、图片超过 9 张 数据库字段 NOT NULL/长度限制可能导致 500;业务规则只靠前端 未处理
shopId 未校验是否存在 saveBlog 传入不存在的商户 id 产生关联不到商户的笔记 未处理
上传接口不需要登录 MvcConfig 排除 /upload/** 匿名调用 可被匿名往服务器写文件 未处理
删除接口路径穿越 UploadController.java:38-45 name=../../xxx 可删除上传目录之外的文件 未处理
删除接口无登录校验 同上 匿名调用 任何人可删除图片 未处理
文件类型/大小未校验 uploadImage 上传非图片、超大文件 存储滥用;Spring 默认单文件 1MB,超限会抛异常 未处理
上传目录硬编码 SystemConstants.java:4 换环境 上传成功但访问 404 未处理
内容 XSS Blog.content + 前端 v-html 用户提交 <script> 等 详情页执行恶意脚本 后端未过滤,前端直接渲染

这些不是本次功能的核心逻辑,但在真实项目中需要处理。


2. 查看探店笔记

查看分为三个入口:

  1. 首页热门列表:GET /blog/hot?current=
  2. 详情页:GET /blog/{id}
  3. 我的笔记:GET /blog/of/me

2.1 热门列表

BlogServiceImpl.java:42-56:

java 复制代码
@Override
public Result queryHotBlog(Integer current){
    //根据用户查询
    Page<Blog> page = query()
            .orderByDesc("liked")
            .page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
    //获取当页数据
    List<Blog> records = page.getRecords();
    //查询用户
    records.forEach(blog->{
        this.queryBlogUser(blog);
        this.isBlogLiked(blog);
    });
    return Result.ok(records);
}

流程:

  1. 按 liked 倒序分页,每页 MAX_PAGE_SIZE = 10 条;
  2. 对每条笔记调用 queryBlogUser 填充昵称和头像;
  3. 调用 isBlogLiked 填充当前用户的点赞状态;
  4. 返回 Result.ok(records)。

前端(index.html:86-92):

js 复制代码
axios.get("/blog/hot?current=" + this.current)
  .then(({data}) => {
    data.forEach(b => b.img = b.images.split(",")[0]);
    this.blogs = this.blogs.concat(data);
  })
  • 前端把 images 按逗号拆分,取第一张作为封面;
  • current 递增实现滚动分页;
  • /blog/hot 是公开接口,未登录也能访问。

2.2 用户信息填充

BlogServiceImpl.java:146-151:

java 复制代码
private void queryBlogUser(Blog blog) {
    Long userId = blog.getUserId();
    User user = userService.getById(userId);
    blog.setName(user.getNickName());
    blog.setIcon(user.getIcon());
}

icon、name 在数据库里不存在(@TableField(exist = false)),所以每次查询笔记后要单独查一次用户表填充。

2.3 详情页

BlogServiceImpl.java:60-72:

java 复制代码
@Override
public Result queryBlogById(Long id){
    //查询blog
    Blog blog = getById(id);
    if(blog==null){
        return Result.fail("笔记不存在!");
    }
    //查询blog相关用户
    queryBlogUser(blog);
    //查询blog是否被点赞
    isBlogLiked(blog);
    return Result.ok(blog);
}

前端 blog-detail.html:211-221:

js 复制代码
axios.get("/blog/" + id)
  .then(({data}) => {
    data.images = data.images.split(",")
    this.blog = data;
    this.$nextTick(this.init);
    this.queryShopById(data.shopId)
    this.queryLikeList(id);
    this.queryLoginUser();
  })

详情页一次性拿到的数据:

  • 笔记本身(标题、内容、图片、点赞数、评论数);
  • 发布者的 name/icon;
  • 当前用户是否点过赞(isLike)。

页面随后再调用:

  • /shop/{shopId} 查询关联商户;
  • /blog/likes/{id} 查询点赞排行榜;
  • /user/me 查询当前登录用户(用于判断是否关注)。

2.4 我的笔记

BlogController.java:52-62:

java 复制代码
@GetMapping("/of/me")
public Result queryMyBlog(@RequestParam(value = "current", defaultValue = "1") Integer current) {
    // 获取登录用户
    UserDTO user = UserHolder.getUser();
    // 根据用户查询
    Page<Blog> page = blogService.query()
            .eq("user_id", user.getId())
            .page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
    // 获取当前页数据
    List<Blog> records = page.getRecords();
    return Result.ok(records);
}

和热门列表的区别:

  • 按 user_id = 当前用户 过滤;
  • 不填充 name/icon/isLike(自己的笔记页面不需要);
  • 没有显式排序,通常按主键/存储顺序返回;如果业务需要"最新发布在前",应补 orderByDesc("create_time")。

前端 info.html:126-130 在"我的笔记"和"关注"两个 tab 中调用;/blog/of/follow 目前还没有实现,属于后续关注功能。

2.5 查看模块的边界与风险

问题 位置 说明
N+1 查询 queryHotBlog + queryBlogUser 每页 10 条笔记会额外查 10 次用户表;可以用 listByIds 批量查一次再做映射
用户被删除导致 NPE queryBlogUser:149 userService.getById 返回 null 时直接 getNickName() 会 NPE
详情内容 XSS 前端 v-html="blog.content" 用户提交的 HTML 会被直接渲染
images 用逗号分隔 Blog.images 文件名含逗号会解析错;当前 UUID 文件名不会含逗号
热门列表没有缓存 queryHotBlog 每次请求都查数据库;高并发下可加缓存
分页参数未校验 queryHotBlog / queryMyBlog 传入 0 或负数时 MyBatis-Plus 的行为需额外确认

其中 N+1 是最值得优化的点:queryBlogLikes 已经用了 listByIds 的思路,热门列表也可以借鉴。


3. 点赞功能

3.1 业务规则

  • 一个用户对同一篇笔记只能点一次赞;
  • 再次点击表示取消点赞;
  • 点赞数展示在笔记上(blog.liked);
  • 点赞状态展示在详情页/首页(blog.isLike);
  • 点赞记录要能按时间排序,供排行榜使用。

3.2 数据结构:为什么用 ZSet

Redis key:

java 复制代码
// RedisConstants.java:21
public static final String BLOG_LIKED_KEY = "blog:liked:";

实际 key 为 blog:liked:{blogId},例如 blog:liked:4。

BlogServiceImpl.java:74-76:

java 复制代码
private String likedKey(Long blogId) {
    return RedisConstants.BLOG_LIKED_KEY + blogId;
}

ZSet 的 member 是用户 id,score 是点赞时间戳:

复制代码
ZADD blog:liked:4 1792724437761 1011
ZADD blog:liked:4 1792724445317 1010

为什么不用 Set:

  • Set 只能判断"有没有点过赞",不能排序;
  • 排行榜需要按点赞时间取前 5 人,Set 没有 score,做不到;
  • ZSet 同时具备"去重(member 唯一)"和"排序(score)"两个能力。

为什么不用数据库:

  • 点赞/取消是高频操作,每次都写数据库压力大;
  • Redis ZSet 的 ZADD/ZREM/ZSCORE 都是 O(log N),而且天然支持范围查询;
  • 数据库只保留聚合后的 liked 数,作为展示和兜底。

3.3 判断是否点过赞

BlogServiceImpl.java:78-89:

java 复制代码
private void isBlogLiked(Blog blog) {
    //未登录用户直接视为未点赞(/blog/hot 是公开接口)
    UserDTO user = UserHolder.getUser();
    if (user == null) {
        blog.setIsLike(false);
        return;
    }
    //判断是否已经点过赞
    Double score = stringRedisTemplate.opsForZSet()
            .score(likedKey(blog.getId()), user.getId().toString());
    blog.setIsLike(score != null);
}

要点:

  • ZSCORE 存在说明点过赞,结果为 null 说明没点过;
  • /blog/hot 是公开接口,UserHolder.getUser() 可能为 null,必须先判断,否则 NPE;
  • 这里不再使用 opsForSet().isMember,因为同一个 key 是 ZSet,用 Set 命令会报 WRONGTYPE。

3.4 点赞/取消点赞

BlogServiceImpl.java:91-116:

java 复制代码
@Override
public Result likeBlog(Long id) {
    //获取登录用户
    Long userId = UserHolder.getUser().getId();
    //判断是否已经点过赞
    String key = likedKey(id);
    Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
    if(score==null){
        //如果未点赞,可以点赞
        //数据库点赞数加一
        boolean isSuccess = update().setSql("liked=liked+1").eq("id", id).update();
        //保存用户到redis的set集合
        if(isSuccess){
            stringRedisTemplate.opsForZSet().add(key,userId.toString(),System.currentTimeMillis());
        }
    }else{
        //如果已经点赞,取消点赞
        //数据库点赞数-1
        boolean isSuccess = update().setSql("liked=liked-1").eq("id", id).update();
        //把用户从redis的set集合移除
        if(isSuccess){
            stringRedisTemplate.opsForZSet().remove(key,userId.toString());
        }
    }
    return Result.ok();
}

流程可以用一张图表示:
渲染错误: Mermaid 渲染失败: Parse error on line 2: ... APUT /blog/like/{id} --> B{ZSCORE b -----------------------^ Expecting 'SQE', 'DOUBLECIRCLEEND', 'PE', '-)', 'STADIUMEND', 'SUBROUTINEEND', 'PIPE', 'CYLINDEREND', 'DIAMOND_STOP', 'TAGEND', 'TRAPEND', 'INVTRAPEND', 'UNICODE_TEXT', 'TEXT', 'TAGSTART', got 'DIAMOND_START'

关键设计:

  • 以 Redis 的 ZSCORE 作为"是否点赞"的判据,数据库只维护计数;
  • DB 更新成功后才更新 Redis,避免数据库失败但 Redis 已经变化;
  • ZADD 的 score 用当前毫秒时间戳,为排行榜提供排序依据。

3.5 点赞模块的边界与风险

问题 位置 触发条件 影响 当前是否处理
DB 与 Redis 非原子 likeBlog DB 更新成功、Redis 操作失败 DB 计数与 ZSet 不一致,isLike/排行榜错误 未处理
并发重复点赞 likeBlog 同一用户并发两次请求 两个请求都读到 score == null,DB 可能 +2,但 ZSet 只有一个 member 未处理
并发重复取消 likeBlog 同一用户并发取消两次 DB 可能 -2,ZSet 只删除一次 未处理
不校验笔记是否存在 likeBlog id 不存在 update 影响 0 行,仍然返回成功 未处理
liked 为无符号 tb_blog.liked 计数已为 0 仍执行 -1 严格模式可能报错,非严格模式可能被截断 未处理
ZSet 没有 TTL blog:liked:{id} 长期运行 每个笔记一个 key,内存随点赞关系增长 未处理
ZSet member 必须是数字 queryBlogLikes 数据被手动写坏 Long.valueOf 抛 NumberFormatException 未处理

其中"并发重复点赞"是比较典型的 check-then-act 竞态:

复制代码
请求A: ZSCORE → null
请求B: ZSCORE → null
请求A: UPDATE liked = liked + 1
请求B: UPDATE liked = liked + 1   // 计数 +2
请求A: ZADD userId                // 集合里只有一个 member
请求B: ZADD userId                // 幂等,仍然是同一个 member

最终 liked 比实际点赞人数多 1。要彻底解决,可以让 Redis 的 ZADD 返回值(0 表示已存在、1 表示新增)决定是否更新数据库,或者对 user+blog 加分布式锁。


4. 点赞排行榜

4.1 业务需求

详情页展示"最早点赞的 5 个用户"的头像:

  • 排行榜按点赞时间排序;
  • 只展示前 5 个;
  • 每个元素需要 id 和 icon,前端用 icon 渲染头像;
  • 返回结构是 Result.ok(List<UserDTO>)。

前端调用(blog-detail.html:230-235):

js 复制代码
queryLikeList(id){
  axios.get("/blog/likes/" + id)
    .then(({data}) => this.likes = data)
    .catch(this.$message.error)
}

模板(blog-detail.html:78-82 附近):

html 复制代码
<div class="zan-list">
  <div class="user-icon-mini" v-for="u in likes" :key="u.id">
    <img :src="u.icon || '/imgs/icons/default-icon.png'" alt="">
  </div>
  <div>{{blog.liked}}人点赞</div>
</div>

4.2 接口实现

BlogController.java:69-72:

java 复制代码
@GetMapping("/likes/{id}")
public Result queryBlogLikes(@PathVariable("id") Long id) {
    return blogService.queryBlogLikes(id);
}

BlogServiceImpl.java:118-144:

java 复制代码
@Override
public Result queryBlogLikes(Long id) {
    //ZRANGE key 0 4:按点赞时间升序取前5个(最早点赞的5人)
    Set<String> idStrings = stringRedisTemplate.opsForZSet()
            .range(likedKey(id), 0, 4);
    if (idStrings == null || idStrings.isEmpty()) {
        //必须返回空列表,不能是 Result.ok()(data 会是 null)
        return Result.ok(Collections.emptyList());
    }
    List<Long> userIds = idStrings.stream()
            .map(Long::valueOf)
            .collect(Collectors.toList());

    //listByIds 不保证顺序,按 ZSet 顺序重建
    Map<Long, User> userMap = userService.listByIds(userIds).stream()
            .collect(Collectors.toMap(User::getId, Function.identity()));

    List<UserDTO> users = new ArrayList<>(userIds.size());
    for (Long userId : userIds) {
        User user = userMap.get(userId);
        //用户已被删除则跳过
        if (user != null) {
            users.add(BeanUtil.copyProperties(user, UserDTO.class));
        }
    }
    return Result.ok(users);
}

4.3 逐段解释

第一步:取前 5 个用户 id

ZRANGE key 0 4:

  • ZSet 按 score 升序排列;
  • score 是点赞时间戳,所以升序就是"最早点赞";
  • 0 到 4 是闭区间,正好 5 个。

如果要"最近点赞的 5 人",改成:

java 复制代码
stringRedisTemplate.opsForZSet().reverseRange(likedKey(id), 0, 4);

第二步:批量查用户

userService.listByIds(userIds):

  • 一条 SELECT ... WHERE id IN (...),避免 N+1;
  • 但 SQL 的 IN 不保证返回顺序,可能和 ZSet 顺序不同。

第三步:按 ZSet 顺序重排

先把用户按 id 放进 Map,再按 userIds 的顺序遍历取出:

java 复制代码
Map<Long, User> userMap = ...;
for (Long userId : userIds) {
    User user = userMap.get(userId);
    ...
}

这样最终列表顺序严格等于 ZSet 的 score 顺序。

第四步:转成 UserDTO

用 BeanUtil.copyProperties(user, UserDTO.class):

  • UserDTO 只有 id、nickName、icon;
  • 避免把 phone、password 等敏感字段返回给前端。

第五步:处理空数据和缺失用户

  • ZSet 为空:返回 Result.ok(Collections.emptyList()),前端得到 [];
  • 如果写成 Result.ok(),data 是 null,前端 likes 会变成 null,模板 v-for 可能报错;
  • 用户被删除:listByIds 查不到,Map 里没有,遍历时跳过,列表可能不足 5 个。

4.4 排行榜模块的边界与风险

问题 说明
"排行榜"是按时间,不是按点赞数 每个用户只能赞一次,所以没有"点赞数排名";这里展示的是最早点赞的用户
score 相同 同一毫秒内点赞的用户,ZSet 会按 member 字典序排列,不是严格的时间先后
ZSet member 必须是数字 正常由 likeBlog 写入 userId;手工写入非数字会抛 NumberFormatException
没有 TTL 排行榜 key 和点赞数据长期存在,内存随点赞关系增长
返回用户信息有限 只返回 id、nickName、icon;如果前端需要更多字段要扩展 UserDTO
未登录不能看 /blog/likes/** 没有加入排除列表,匿名访问返回 401;详情页本身也需要登录,所以与现有前端流程一致

5. 关键技术原理总结

5.1 登录态与 ThreadLocal

  • RefreshTokenInterceptor 拦截所有请求,从 authorization 请求头取 token,查 Redis 的 login:token:{token},把 UserDTO 放进 UserHolder;
  • LoginInterceptor 只判断 UserHolder.getUser() 是否为 null,为 null 返回 401;
  • afterCompletion 中 UserHolder.removeUser(),避免线程池复用导致用户信息串号。

这解释了为什么 saveBlog、likeBlog、queryMyBlog 可以直接从 UserHolder 取当前用户,而不需要手动传 userId。

5.2 MyBatis-Plus

  • ServiceImpl<BlogMapper, Blog> 提供 save、getById、query()、update();
  • query().orderByDesc("liked").page(...) 构造分页查询;
  • update().setSql("liked=liked+1").eq("id", id).update() 生成原子 SQL 自增/自减;
  • @TableId(type = IdType.AUTO) 让插入后回填自增 id;
  • @TableField(exist = false) 标记非数据库字段,避免 MyBatis-Plus 拼进 SQL。

5.3 Redis ZSet

命令 Spring API 用途
ZADD opsForZSet().add 点赞,score 为时间戳
ZREM opsForZSet().remove 取消点赞
ZSCORE opsForZSet().score 判断是否点过赞
ZRANGE opsForZSet().range 按 score 升序取前 5
ZREVRANGE opsForZSet().reverseRange 按 score 降序(如需最近点赞)

ZSet 同时解决了"去重"和"排序"两个需求,这是它比 Set 更适合点赞场景的原因。

5.4 静态资源与文件上传

  • MultipartFile.transferTo(File) 把上传文件写到磁盘;
  • 上传目录必须是 nginx 的静态资源目录,前端才能通过 /imgs/... 访问;
  • UUID 文件名 + 二级 hash 目录是常见的文件存储组织方式;
  • 真实项目通常会把文件放到对象存储(OSS/MinIO),而不是本地磁盘。

6. 练习题

  1. 发布链路 :从用户在 blog-edit.html 点击"发布"开始,到笔记出现在"我的笔记"页面,请完整描述经过的接口、参数和表字段。userId 为什么由后端从 UserHolder 取,而不是前端传?

  2. 图片上传 :上传接口返回的字符串是什么格式?前端为什么要拼 /imgs?SystemConstants.IMAGE_UPLOAD_DIR 和 nginx 是什么关系?删除图片接口存在哪些安全问题?

  3. 查看笔记 :queryHotBlog 为什么要对每条笔记额外调用 queryBlogUser 和 isBlogLiked?icon、name、isLike 为什么标 @TableField(exist = false)?这样做带来了什么性能问题?

  4. 点赞功能:为什么点赞关系用 Redis ZSet 而不是 Set 或数据库表?请描述点赞和取消点赞的完整流程。数据库更新和 Redis 更新是两步操作,在并发或故障时可能出现什么问题?

  5. 点赞排行榜 :ZRANGE key 0 4 的含义是什么?为什么 listByIds 之后还要按 ZSet 顺序重排?如果把需求改成"最近点赞的 5 人",代码应该怎么改?


7. 练习题参考答案与逐题反馈

第 1 题:发布链路

标准答案

完整路径:

复制代码
1. 用户选择图片
   blog-edit.html: fileSelected()
   → POST /upload/blog,multipart 字段名 file

2. UploadController.uploadImage
   → 生成 UUID 文件名,保存到 IMAGE_UPLOAD_DIR/blogs/{d1}/{d2}/uuid.ext
   → 返回 Result.ok("/blogs/{d1}/{d2}/uuid.ext")

3. 前端把返回值拼成 "/imgs" + 路径,加入 fileList

4. 用户填写标题、内容、选择商户,点击发布
   → data.images = fileList.join(",")
   → data.shopId = selectedShop.id
   → POST /blog,JSON 请求体

5. BlogController.saveBlog
   → UserHolder.getUser() 获取当前用户
   → blog.setUserId(user.getId())
   → blogService.save(blog)

6. MySQL INSERT INTO tb_blog
   → id 自增回填
   → liked 使用默认值 0,comments 默认为 0/NULL
   → create_time/update_time 使用数据库默认值

7. 返回 Result.ok(blog.getId())
   前端跳转 info.html

8. info.html 调用 GET /blog/of/me
   → 按 user_id = 当前用户分页查询 tb_blog
   → 返回该用户的笔记列表

userId 由后端从 UserHolder 取的原因:

  • UserHolder 里的用户来自 token 对应的 Redis 登录信息,是服务端可信的;
  • 如果前端传 userId,恶意用户可以伪造别人的 id 发布笔记,冒充他人;
  • 这是"永远不要信任客户端提交的身份字段"的典型例子。
常见误区
  • ❌ "前端传了 userId,后端直接用就行。"------身份必须来自服务端会话/token。
  • ❌ "图片和笔记是一次请求提交的。"------当前实现是图片先上传、笔记后提交两次请求。
  • ❌ "images 是 JSON 数组。"------数据库存的是逗号分隔字符串,前端负责拆分。
  • ❌ "liked 是前端传的初始值。"------数据库默认 0,客户端不应该控制这个字段。

第 2 题:图片上传

标准答案

返回格式:

json 复制代码
{"success": true, "data": "/blogs/7/14/uuid.jpg"}

返回的是相对路径 ,以 /blogs/ 开头,不含 /imgs。

为什么要拼 /imgs:

  • 后端保存目录是 IMAGE_UPLOAD_DIR,对应 nginx 站点下的 imgs 目录;
  • 浏览器访问静态资源时,URL 必须以 /imgs/... 开头;
  • 前端把 /imgs 和返回的相对路径拼起来,得到完整可访问地址。

IMAGE_UPLOAD_DIR 与 nginx 的关系:

SystemConstants.IMAGE_UPLOAD_DIR 指向 nginx 的静态资源根目录下的 imgs 文件夹。上传写文件到这个目录,nginx 就能通过 /imgs/... 直接把文件返回给浏览器。两者必须指向同一个物理目录,否则上传成功但访问 404。

删除接口的安全问题:

GET /upload/blog/delete?name=...:

  1. /upload/** 被排除在登录拦截之外,匿名用户也能调用;
  2. name 没有做路径校验,传入 ../ 可以跳出上传目录,删除其他文件(路径穿越);
  3. file.isDirectory() 只判断目录,没有判断文件是否存在、是否是图片;
  4. 没有校验文件归属,任何人可以删除别人的图片。

改进方向:要求登录、只允许删除当前用户上传的文件、用文件名白名单/规范化路径校验、限制扩展名和大小。

常见误区
  • ❌ "返回的是完整 URL。"------返回的是 /blogs/...,/imgs 是前端拼的。
  • ❌ "上传目录可以随便放。"------必须和 nginx 静态目录一致。
  • ❌ "删除接口有登录拦截。"------/upload/** 被排除了。
  • ❌ "UUID 文件名就绝对安全。"------UUID 只解决重名,不解决鉴权和路径穿越。

第 3 题:查看笔记

标准答案

为什么要额外调用两个方法:

  • tb_blog 表里只有 user_id,没有用户昵称和头像;
  • icon、name 需要根据 user_id 查 tb_user 才能得到;
  • isLike 是"当前登录用户是否点过赞",数据库里没有这个字段,需要查 Redis ZSet 的 ZSCORE。

为什么标 @TableField(exist = false):

  • 这三个字段不是 tb_blog 的列;
  • 标记后 MyBatis-Plus 在生成 INSERT/SELECT 时不会把它们当成表字段;
  • 它们只是查询后临时填充、随 JSON 返回给前端的展示字段。

性能问题:N+1 查询

queryHotBlog 每页 10 条笔记:

复制代码
1 次分页查询 tb_blog
10 次 userService.getById(userId)   // 每条笔记一次
10 次 Redis ZSCORE                   // 每条笔记一次

用户表的 10 次查询可以优化成一次 listByIds:

java 复制代码
Set<Long> userIds = records.stream().map(Blog::getUserId).collect(Collectors.toSet());
Map<Long, User> userMap = userService.listByIds(userIds).stream()
        .collect(Collectors.toMap(User::getId, Function.identity()));
records.forEach(blog -> {
    User user = userMap.get(blog.getUserId());
    ...
});
常见误区
  • ❌ "icon 是 tb_blog 的字段。"------不是,标记了 exist=false。
  • ❌ "isLike 从数据库查。"------来自 Redis ZSet。
  • ❌ "getById 查 10 次没关系。"------这是典型的 N+1,数据量大时明显拖慢接口。
  • ❌ "未登录也能查 isLike。"------未登录时 isBlogLiked 直接设为 false。

第 4 题:点赞功能

标准答案

为什么用 ZSet:

  • ZSet 的 member 唯一,天然保证"一个用户只能一条点赞记录";
  • score 可以存点赞时间戳,支持按时间排序,排行榜需要;
  • Set 只能判断存在性,不能按时间排序;
  • 数据库表存点赞关系会带来高频写压力,当前方案用 Redis 承担高频读写,数据库只维护计数。

点赞流程:

复制代码
1. 从 UserHolder 取 userId
2. ZSCORE blog:liked:{id} userId
3. 如果为 null(未点赞):
   3.1 UPDATE tb_blog SET liked = liked + 1 WHERE id = ?
   3.2 更新成功 → ZADD blog:liked:{id} userId 当前时间戳
4. 如果不为 null(已点赞):
   4.1 UPDATE tb_blog SET liked = liked - 1 WHERE id = ?
   4.2 更新成功 → ZREM blog:liked:{id} userId
5. 返回 Result.ok()

两步操作的问题:

  • 不是原子操作:DB 更新成功后 Redis 操作失败(Redis 宕机、网络异常),会出现 DB 计数 +1 但 ZSet 没有记录,导致 isLike=false、排行榜少人;
  • 并发重复点赞:两个请求同时读到 score == null,都执行 DB +1,ZSet 因 member 唯一只保留一个,最终计数比实际多;
  • 并发取消同理,可能 -2;
  • likeBlog 没有校验笔记是否存在,id 不存在时 update 影响 0 行,但接口仍返回成功。
常见误区
  • ❌ "Redis 和数据库是原子的。"------两次独立操作,中间可能失败。
  • ❌ "ZSet 和 Set 差不多。"------ZSet 有 score,能排序;Set 不能。
  • ❌ "点赞数只在 Redis 里。"------DB 的 liked 才是展示计数,Redis 存的是用户关系。
  • ❌ "并发重复点赞不会发生。"------同一用户快速双击或多个请求重试都可能触发。

第 5 题:点赞排行榜

标准答案

ZRANGE key 0 4 的含义:

  • 从 ZSet 中按 score 从小到大取下标 0 到 4 的元素,共 5 个;
  • score 是点赞时间戳,所以取到的是最早点赞的 5 个用户;
  • 返回值是 member(用户 id 字符串)的有序集合。

为什么 listByIds 后还要重排:

  • SELECT ... WHERE id IN (...) 的返回顺序不保证与传入 id 顺序一致;
  • 如果不重排,前端拿到的头像顺序可能和点赞时间不一致;
  • 用 Map 按 id 映射,再按 ZSet 的 userIds 顺序遍历,就能保持正确顺序。

改成"最近点赞的 5 人":

java 复制代码
Set<String> idStrings = stringRedisTemplate.opsForZSet()
        .reverseRange(likedKey(id), 0, 4);

reverseRange 按 score 从大到小取,最近点赞的排在前面。

常见误区
  • ❌ "ZRANGE 0 4 是最新 5 个。"------是 score 最小的 5 个,即最早点赞的。
  • ❌ "listByIds 会按传入顺序返回。"------SQL IN 不保证顺序。
  • ❌ "排行榜是按点赞数排名。"------每个用户只能赞一次,没有点赞数差异;这里按时间排。
  • ❌ "返回 Result.ok() 也可以。"------data 为 null,前端 likes 期望数组。

附录:源码索引

内容 位置
发布接口 controller/BlogController.java:35-44
点赞接口 controller/BlogController.java:46-50
我的笔记 controller/BlogController.java:52-62
热门列表接口 controller/BlogController.java:64-67
排行榜接口 controller/BlogController.java:69-72
详情接口 controller/BlogController.java:74-77
热门列表实现 service/impl/BlogServiceImpl.java:42-56
详情实现 service/impl/BlogServiceImpl.java:60-72
likedKey service/impl/BlogServiceImpl.java:74-76
isBlogLiked service/impl/BlogServiceImpl.java:78-89
likeBlog service/impl/BlogServiceImpl.java:91-116
queryBlogLikes service/impl/BlogServiceImpl.java:118-144
queryBlogUser service/impl/BlogServiceImpl.java:146-151
图片上传 controller/UploadController.java:20-36
图片删除 controller/UploadController.java:38-45
文件名生成 controller/UploadController.java:47-63
上传目录 utils/SystemConstants.java:4
点赞 key 常量 utils/RedisConstants.java:21
登录拦截配置 config/MvcConfig.java:22-31
Blog 实体 entity/Blog.java
tb_blog 建表 resources/db/hmdp.sql:24-36

前端接口对照

页面 调用
blog-edit.html POST /upload/blog、GET /upload/blog/delete、POST /blog、GET /shop/of/name
index.html GET /blog/hot、PUT /blog/like/{id}、GET /blog/{id}
blog-detail.html GET /blog/{id}、GET /blog/likes/{id}、PUT /blog/like/{id}、GET /shop/{id}
info.html GET /blog/of/me、PUT /blog/like/{id}、GET /blog/{id}
相关推荐
赵渝强老师1 小时前
【赵渝强老师】崖山数据库的命令行客户端工具
数据库·国产数据库·yashandb·崖山数据库
忡黑梨1 小时前
PostgreSQL 小版本升级实战:14.23 升级到 14.24(源码编译方式)
数据库·postgresql
LMT流氓兔2 小时前
第五单元——第11课:自动测试“拒绝后绝不能写入”
数据库·人工智能·python·langchain
tianbin9112 小时前
批处理与微调辅助场景:Claude 中转并发、链路缓存与压测对比
java·开发语言·缓存
小朱爱编程1232 小时前
AI点了两次发布,我多了两篇事故
数据库·人工智能·大模型·软件工程
时间售卖铺2 小时前
MySQL DML 与 DQL 语句详解
运维·数据库·mysql·云原生
codeejun2 小时前
每日一Go·MySQL-9、索引存储结构|页分裂、页合并、索引高度与磁盘
数据库·mysql
Pniubi2 小时前
事务(Spring&MySQL)
数据库·sql·spring
DBA_G3 小时前
GBase 8a MPP集群个人电脑安装环境浅析
数据库