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

上一个模块是使用 redis 使用的消息队列,这实际不怎么使用所以选择直接跳过了然后之后选择用
rabbitMQ 来实现 JVM 带来的缺陷
一、这一模块要解决什么问题
前几模块的主题是登录态共享、缓存一致性、秒杀防超卖,围绕的是”读多写少”和”并发扣减”。达人探店换了个方向:内容型业务里的 UGC 和互动。
要做三件事。
- 发布探店笔记。笔记图文混排,图片要存下来并且前端能访问到,标题正文写进数据库。
- 查看探店笔记。首页有按点赞数排序的热门列表,点进去是详情页,要显示作者的昵称和头像。
- 点赞和点赞排行榜。同一个人对同一篇笔记只能点一次,再点取消;详情页按点赞时间展示最早点赞的 Top5,已点赞的按钮要亮。
第三件事是重点,这个需求服务的:既要”一个人只算一次”,又要”按点赞时间排序”,List 去不了重,Set 排不了序,最后落在 SortedSet 上,拿时间戳当 score。
整个模块的思路一句话:点赞总数放数据库,点赞名单放 Redis 的 ZSet。
五、功能一:发布探店笔记
5.1 业务流程
- 用户在首页点最下方的
+,进入发笔记页面,填标题、写正文、选照片。 - 选完照片,前端先调
POST /upload/blog把图片传上来,后端存盘并返回文件名。 - 前端把图片文件名、标题、正文、关联商户一起通过
POST /blog提交。 - 后端从
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,写读都频繁,丢了也能从数据库的计数大致恢复。
名单用什么结构?把需求翻译成数据结构的语言:
- 同一个人不能重复出现 → 需要去重,List 排除。
- 要按点赞时间排出先后 → 需要排序,Set 排除。
- 还要能按人查、按人删 → 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() | 点赞数 +1 | UPDATE 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() | 点赞数 -1 | UPDATE 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 页的对比表:
| List | Set | SortedSet | |
|---|---|---|---|
| 排序方式 | 按添加顺序排序 | 无法排序 | 根据 score 值排序 |
| 唯一性 | 不唯一 | 唯一 | 唯一 |
| 查找方式 | 按索引查找或首尾查找 | 根据元素查找 | 根据元素查找 |
对照本章需求逐条看:
- 需要一个用户只算一次 → List 允许重复,先排除。
- 需要判断”我点过赞没有” → List 得遍历,Set 和 SortedSet 都能按元素直接查。
- 需要按点赞时间给出最早点赞的 5 个人 → Set 无序,也不行;SortedSet 用时间戳做 score,天然有序。
ZSet 是三者里唯一同时满足去重和按时间排序的。System.currentTimeMillis() 做 score,userId 做 member,一个结构同时解决了去重、排序和取消点赞时的定位问题。这也是为什么 PPT 在第 153 页说”用 set 集合”,而实际代码写的是 ZSet。