黑马点评 · 达人探店模块学习笔记

上一个模块是使用 redis 使用的消息队列,这实际不怎么使用所以选择直接跳过了然后之后选择用
rabbitMQ 来实现 JVM 带来的缺陷

一、这一模块要解决什么问题

前几模块的主题是登录态共享、缓存一致性、秒杀防超卖,围绕的是”读多写少”和”并发扣减”。达人探店换了个方向:内容型业务里的 UGC 和互动。

要做三件事。

  1. 发布探店笔记。笔记图文混排,图片要存下来并且前端能访问到,标题正文写进数据库。
  2. 查看探店笔记。首页有按点赞数排序的热门列表,点进去是详情页,要显示作者的昵称和头像。
  3. 点赞和点赞排行榜。同一个人对同一篇笔记只能点一次,再点取消;详情页按点赞时间展示最早点赞的 Top5,已点赞的按钮要亮。

第三件事是重点,这个需求服务的:既要”一个人只算一次”,又要”按点赞时间排序”,List 去不了重,Set 排不了序,最后落在 SortedSet 上,拿时间戳当 score。

整个模块的思路一句话:点赞总数放数据库,点赞名单放 Redis 的 ZSet。


五、功能一:发布探店笔记

5.1 业务流程

  1. 用户在首页点最下方的 +,进入发笔记页面,填标题、写正文、选照片。
  2. 选完照片,前端先调 POST /upload/blog 把图片传上来,后端存盘并返回文件名。
  3. 前端把图片文件名、标题、正文、关联商户一起通过 POST /blog 提交。
  4. 后端从 UserHolder 取出当前登录用户的 id 填进 blog.userId,落库,返回笔记 id。

这里分成两个接口是有原因的:图片可能有好几张,而且是二进制,跟表单字段混在一个请求里不好处理。前端先把图传上去拿到文件名,再用普通的 JSON 请求提交文字内容,后端两次处理都很简单。

5.2 图片上传接口怎么写

需求:前端需要一个接口,把文件传上来,返回一个后端能识别的文件名。

Spring MVC 怎么接文件:参数写 MultipartFile,用 @RequestParam("file") 绑定表单里的字段名。

@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));
        // 返回结果
        return Result.ok(fileName);
    } catch (IOException e) {
        throw new RuntimeException("文件上传失败", e);
    }
}

四步:拿原始文件名(只是为了取后缀)→ 生成新文件名 → 转存到磁盘 → 返回文件名。

文件名怎么生成:

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 重命名。如果直接用用户上传的名字,两个人都传 1.jpg 就会互相覆盖。UUID 不重复,也顺手避免了用户拿文件名做文章。
  • 拆两级子目录。hash & 0xF 取低 4 位,(hash >> 4) & 0xF 取接下来 4 位,合起来是低 8 位,落在 0–15 之间,两级就是 256 个目录。这样文件被摊开存放,不会因为单个目录文件太多而影响查找。mkdirs() 带上 s 表示父目录不存在时一起创建。

保存到哪:SystemConstants.IMAGE_UPLOAD_DIR。

public class SystemConstants {
    public static final String IMAGE_UPLOAD_DIR = "D:\\lesson\\nginx-1.18.0\\html\\hmdp\\imgs\\";
    public static final String USER_NICK_NAME_PREFIX = "user_";
    public static final int DEFAULT_PAGE_SIZE = 5;
    public static final int MAX_PAGE_SIZE = 10;
}

这个值必须改成自己机器上 nginx 的静态资源目录。图片既不进数据库,也不放项目的 resources,而是直接落到 nginx 的静态目录;接口返回的 /blogs/... 路径,前端拼上 nginx 地址就能访问。后端只负责写文件,读文件交给 nginx,省掉后端的 IO 压力。代价是后端和 nginx 得在同一台机器上,多机部署要考虑共享存储。

另外 MvcConfig 里 /upload/** 被登录拦截器放行了,上传接口本身不做登录校验。

5.3 保存笔记接口怎么写

需求:把前端提交的 Blog 存进 tb_blog,返回 id。

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

三个点:

  • 前端传的是 JSON,所以用 @RequestBody 绑定到 Blog 对象。
  • 用户 id 不从请求体里取,而是从 UserHolder 拿。UserHolder 里的用户是登录拦截时从 Redis 的 token 里解析出来放进 ThreadLocal 的,前端伪造不了。如果直接用 blog.getUserId(),任何人都能冒充别人发笔记。
  • blogService.save(blog) 是 MyBatis-Plus 的方法,会生成一条 INSERT INTO tb_blog (...) VALUES (...)。执行完之后 MyBatis-Plus 会把自增主键回填到 blog.id,所以紧接着 blog.getId() 就能拿到新 id,返回给前端用于跳转详情页。

说明:这段是本章提交时的写法。学到第 07 章”关注推送”后,saveBlog 被搬进了 BlogServiceImpl,并在落库后多了一步往粉丝收件箱 feed:{userId} 推消息。看当前工作区代码时 Controller 里只剩 return blogService.saveBlog(blog);,那是后续章节的改动。


六、功能二:查看探店笔记

6.1 先把前端要什么想清楚

PPT 第 150 页的需求是:点首页的探店笔记进入详情页,实现该页面的查询接口。

说明内容
请求方式GET
请求路径/blog/{id}
请求参数id:blog 的 id
返回值Blog,笔记信息,包含用户信息

关键在于”包含用户信息”这几个字。tb_blog 里只有 user_id,没有昵称和头像,而详情页要显示作者,列表页也一样。所以后端返回的 Blog 必须比表字段多东西,这就是 3.3 节那三个非表字段的由来。

再加上”按钮高亮”的要求,列表页和详情页都需要 isLike。两个查询接口的差别只在于:列表查一页,详情查一条。

6.2 首页热门列表 queryHotBlog 怎么写

需求:按点赞数从高到低分页,每页返回笔记列表,每条带上作者信息和点赞状态。

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

拆开看:

  • query() 是 MyBatis-Plus 提供的链式查询入口,orderByDesc("liked") 拼上 ORDER BY liked DESC。
  • .page(new Page<>(current, MAX_PAGE_SIZE)) 交给分页插件处理,它会自动加上 LIMIT 并统计总数。MAX_PAGE_SIZE 是 10,current 由前端传,默认 1。
  • page.getRecords() 拿到当前页的数据。
  • forEach 里对每条笔记补两个字段。这里没有用 for 循环而是 lambda,写法上更短,注意 lambda 里调用本类方法要用 this.queryBlogUser(...),因为 lambda 的 this 指向外层。

数据库压力上有个天然的好处:一页只有 10 条,所以后面补作者信息最多查 10 次,和笔记总数无关。

6.3 笔记详情 queryBlogById 怎么写

@Override
public Result queryBlogById(Long id) {
    // 1.查询blog
    Blog blog = getById(id);
    if (blog == null) {
        return Result.fail("笔记不存在!");
    }
    // 2.查询blog有关的用户
    queryBlogUser(blog);
    // 3.查询blog是否被点赞
    isBlogLiked(blog);
    return Result.ok(blog);
}

三步:查笔记、补作者、判断是否点过赞。和列表的差别在于单条查询要处理”查不到”的情况,笔记不存在时返回 Result.fail("笔记不存在!"),而不是返回空的 Blog,让前端能区分”没这条笔记”和”有笔记但内容是空”。

6.4 两个工具方法

补作者信息:

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

拿 blog.userId 去 tb_user 查一条,把 nickName 和 icon 塞回 Blog。抽成方法是因为列表和详情都要用。

判断是否点过赞:

private void isBlogLiked(Blog blog) {
    // 1.获取登录用户
    UserDTO user = UserHolder.getUser();
    if (user == null) {
        // 用户未登录,无需查询是否点赞
        return;
    }
    Long userId = user.getId();
    // 2.判断当前登录用户是否已经点赞
    String key = "blog:liked:" + blog.getId();
    Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
    blog.setIsLike(score != null);
}

用 ZSCORE key member 判断 ZSet 里有没有这个用户。返回 null 表示没点过赞,返回一个时间戳说明点过。这里只需要判断存在性,用 ZSCORE 就够了,不必用 ZRANK。查出来的 Double 直接和 null 比较,不用管 score 的具体值,所以就赋成 score != null 这个布尔结果。

为什么必须先判空:这个方法同时被 queryHotBlog 调用,而 /blog/hot 是放行给游客的,未登录访问时 UserHolder 里没有用户。少了这个判断,游客刷首页就会 NPE。

两个可以顺手改进的地方:这里的 key 是手写拼接的 "blog:liked:",没有用 BLOG_LIKED_KEY 常量,属于一处不一致;另外一条笔记一次 ZSCORE,一页 10 条就是 10 次往返,量大的话可以用 pipeline 合并。

6.5 想清楚:点赞状态为什么不单独开接口

前端已经写好了高亮逻辑,它只是读 Blog.isLike。既然后端在两个查询接口的返回值里都带上了这个字段,按钮自然就亮了,不需要再加一个”查我点没点过赞”的接口。这是”把状态挂在查询结果上”的常见做法,少一次请求。


七、功能三:点赞 / 取消点赞

7.1 需求

PPT 第 153 页:

  • 同一个用户只能点赞一次,再次点击则取消点赞。
  • 如果当前用户已经点赞,点赞按钮高亮显示(前端已实现,判断字段 Blog 的 isLike 属性)。
  • 实现步骤:给 Blog 加 isLike 字段;点赞功能用 Redis 的 set 集合判断是否点过赞,没点过就 +1,点过就 -1;查询详情和分页查询时判断是否点过赞,赋值给 isLike。

7.2 思路推导:这条数据该放哪

先想”如果只改数据库”会怎样。tb_blog.liked 只是一个计数,点一次 +1,再点 -1,看起来够了。但它回答不了一个问题:当前这个人点过没有。没有这个信息,就没法实现”再点一次取消”,也没法让按钮高亮,取消点赞时甚至不知道该给谁减。

所以要额外存一份”谁点过赞”的名单。名单放哪?

  • 放数据库就得再建一张点赞关系表,每次点赞多一次写库。
  • 放 Redis 更合适:这是一份典型的 key-value 数据,一篇笔记一个 key,写读都频繁,丢了也能从数据库的计数大致恢复。

名单用什么结构?把需求翻译成数据结构的语言:

  1. 同一个人不能重复出现 → 需要去重,List 排除。
  2. 要按点赞时间排出先后 → 需要排序,Set 排除。
  3. 还要能按人查、按人删 → Set 和 SortedSet 的函数都能做到。

于是答案就是 ZSet:member 存 userId 去重,score 存点赞时间戳排序。

总数和名单各管一头:数据库的 liked 负责”显示数字、按热度排序列表”,Redis 的 ZSet 负责”去重、判断我点没点过、给出点赞顺序”。两边都要写,所以点赞方法里每个分支都有两次写操作。

7.3 likeBlog 怎么写

按 7.2 的推导,方法骨架自然是先判断状态,再分两个分支,每个分支里先改数据库再改 Redis。

@Override
public Result likeBlog(Long id) {
    // 1.获取登录用户
    Long userId = UserHolder.getUser().getId();
    // 2.判断当前登录用户是否已经点赞
    String key = BLOG_LIKED_KEY + id;
    Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
    if (score == null) {
        // 3.如果未点赞,可以点赞
        // 3.1.数据库点赞数 + 1
        boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
        // 3.2.保存用户到Redis的set集合  zadd key value score
        if (isSuccess) {
            stringRedisTemplate.opsForZSet().add(key, userId.toString(), System.currentTimeMillis());
        }
    } else {
        // 4.如果已点赞,取消点赞
        // 4.1.数据库点赞数 -1
        boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
        // 4.2.把用户从Redis的set集合移除
        if (isSuccess) {
            stringRedisTemplate.opsForZSet().remove(key, userId.toString());
        }
    }
    return Result.ok();
}

逐段对应到思路:

代码在做什么实际执行的命令 / SQL
UserHolder.getUser().getId()取当前登录用户,接口需要登录,这里不会为空无
opsForZSet().score(key, userId)判断这个人点过没有ZSCORE blog:liked:{id} {userId}
setSql("liked = liked + 1").eq("id", id).update()点赞数 +1UPDATE tb_blog SET liked = liked + 1 WHERE id = ?
opsForZSet().add(key, userId, now)把用户记进名单,score 是当前时间ZADD blog:liked:{id} {时间戳} {userId}
setSql("liked = liked - 1").eq("id", id).update()点赞数 -1UPDATE tb_blog SET liked = liked - 1 WHERE id = ?
opsForZSet().remove(key, userId)从名单里删掉ZREM blog:liked:{id} {userId}

为什么点赞数要用 setSql 交给数据库自增。如果写成”先 getById 查出 liked,加一之后 update”,两个用户同时点赞时,双方可能都读到 10,各自写回 11,结果少算一次。liked = liked + 1 由数据库在行锁内完成,不会互相覆盖。

为什么用 if (isSuccess) 兜住 Redis 的写。数据库更新失败(比如笔记被删了,WHERE id = ? 没命中)时,不应该再往 ZSet 里加人,否则名单和计数就对不上。这是保证两边尽量一致的最低成本做法:以数据库为准,数据库动不了就不动缓存。

为什么取消点赞不用先查名单里有没有。走到 else 分支已经说明 ZSCORE 查到了,用户确实在名单里,直接 ZREM 就行,ZREM 删不存在的 member 也不会报错。

7.4 前端怎么知道要显示高亮

isLike 不需要单独的接口。isBlogLiked 在 /blog/hot 和 /blog/{id} 里都调用了一次,前端刷新列表或进详情页时就能拿到最新的点赞状态,按钮自动高亮或取消高亮。


八、功能四:点赞排行榜

8.1 需求

PPT 第 155 页:在详情页把给该笔记点赞的人显示出来,取最早点赞的 TOP5。

说明内容
请求方式GET
请求路径/blog/likes/{id}
请求参数id:blog 的 id
返回值List<UserDTO>,给这个笔记点赞的 TopN 用户集合

8.2 思路

7.2 用时间戳当 score 的时候,其实已经为这个需求铺好路了:ZSet 按 score 从小到大就是按点赞时间从早到晚。所以取 Top5 只要一句 ZRANGE key 0 4。

但排行榜要展示的是”人”,不是 id。所以还得拿这 5 个 id 去 tb_user 查昵称和头像。这里有一步容易出错:SQL 的 IN 查询返回顺序不由 IN 里的顺序决定(通常按主键排),而排行榜是有顺序的。解决办法是查的时候用 ORDER BY FIELD 把顺序摆回来。

所以流程是:ZSet 取有序 id → 用 id 查用户 → 查询时保持原顺序 → 返回用户信息。

8.3 queryBlogLikes 怎么写

@Override
public Result queryBlogLikes(Long id) {
    String key = BLOG_LIKED_KEY + id;
    // 1.查询top5的点赞用户 zrange key 0 4
    Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);
    if (top5 == null || top5.isEmpty()) {
        return Result.ok(Collections.emptyList());
    }
    // 2.解析出其中的用户id
    List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());
    String idStr = StrUtil.join(",", ids);
    // 3.根据用户id查询用户 WHERE id IN ( 5 , 1 ) ORDER BY FIELD(id, 5, 1)
    List<UserDTO> userDTOS = userService.query()
            .in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list()
            .stream()
            .map(user -> BeanUtil.copyProperties(user, UserDTO.class))
            .collect(Collectors.toList());
    // 4.返回
    return Result.ok(userDTOS);
}

分四步看:

第一步,取 id 列表。range(key, 0, 4) 对应 ZRANGE blog:liked:{id} 0 4,下标从 0 到 4 共 5 个,按 score 升序,也就是最早点赞的 5 个人。取 5 是写死的,要做得灵活可以加个参数。

第二步,类型转换。ZSet 的 member 存的是字符串,用 stream().map(Long::valueOf) 转成 Long。Long::valueOf 是方法引用写法,等价于 s -> Long.valueOf(s)。

第三步,查用户并保序。这是全章代码含量最高的一行,拆开念:

  • userService.query() 开一个链式查询。
  • .in("id", ids) 生成 WHERE id IN (5, 1)。MyBatis-Plus 会把集合展开成占位符,不是字符串拼接,所以这一步是安全的。
  • .last("ORDER BY FIELD(id, 5, 1)") 把 ORDER BY FIELD(id, 5, 1) 直接追加到 SQL 末尾。FIELD(id, 5, 1) 返回 id 在列表里的位置,5 在第一位置返回 1,1 在第二位返回 2,按这个值排序就恢复了 ZSet 给出的顺序。StrUtil.join(",", ids) 就是把 List<Long> 拼成 5,1 这样的串。
  • .list() 执行查询拿到 List<User>。
  • .stream().map(...) 逐个转成 UserDTO。BeanUtil.copyProperties 是 Hutool 的工具,按同名属性拷贝;转成 UserDTO 是因为它没有密码字段,直接把 User 返回给前端会把 tb_user.password 也带出去。

最终执行的 SQL 大致是:

SELECT id, phone, password, nick_name, icon, create_time, update_time
FROM tb_user
WHERE id IN (5, 1)
ORDER BY FIELD(id, 5, 1)

第四步,返回。列表为空时返回 Result.ok(Collections.emptyList()) 而不是 Result.ok(),保证前端拿到的 data 永远是数组,少写一个判空分支。

关于 ORDER BY FIELD 的安全性:这行是字符串拼接 SQL。这里的 idStr 来自 Redis 里存的用户 id,已经过 Long::valueOf 转换,只可能是数字,所以可控。如果这个串来自前端参数就不能这么写了。不想拼字符串的话,也可以在内存里按 ZSet 的顺序对结果重排。


九、为什么选 SortedSet

PPT 第 156 页的对比表:

ListSetSortedSet
排序方式按添加顺序排序无法排序根据 score 值排序
唯一性不唯一唯一唯一
查找方式按索引查找或首尾查找根据元素查找根据元素查找

对照本章需求逐条看:

  • 需要一个用户只算一次 → List 允许重复,先排除。
  • 需要判断”我点过赞没有” → List 得遍历,Set 和 SortedSet 都能按元素直接查。
  • 需要按点赞时间给出最早点赞的 5 个人 → Set 无序,也不行;SortedSet 用时间戳做 score,天然有序。

ZSet 是三者里唯一同时满足去重和按时间排序的。System.currentTimeMillis() 做 score,userId 做 member,一个结构同时解决了去重、排序和取消点赞时的定位问题。这也是为什么 PPT 在第 153 页说”用 set 集合”,而实际代码写的是 ZSet。