实战篇 - 达人探店模块
📋 模块概览
本模块实现”黑马点评”项目的达人探店功能,分四个阶段递进:
| 阶段 | 内容 | 核心技术 |
|---|---|---|
| 1 | 发布探店笔记 | 图片上传 + 博客保存 |
| 2 | 查看探店笔记详情 | 根据 ID 查询 + 关联用户信息 |
| 3 | 点赞功能 | Redis Set 实现去重点赞 |
| 4 | 点赞排行榜 | Redis Sorted Set 实现 Top5 排行 |
一、发布探店笔记
1.1 业务场景
达人(网红)到店试吃,拍照配上文字描述,发布探店笔记供其他用户浏览。类似小红书的图文笔记功能。
1.2 核心数据表
| 表名 | 用途 | 关键字段 |
|---|---|---|
tb_blog |
探店笔记表 | id, shop_id, user_id, title, images, content, liked, comments |
tb_blog_comments |
笔记评价表 | 保存其他用户的评价(本模块暂不涉及) |
tb_blog 表关键字段说明:
images:图片地址,最多 9 张,以逗号分隔存储在一个字段中liked:点赞数量comments:评论数量(点赞不一定评论,两者独立计数)
1.3 业务流程
用户点击"+"按钮 → 弹出发布页面
↓
① 上传照片(独立接口) → 返回图片地址
② 填写标题 + 内容 + 关联商户
③ 点击发布 → 提交表单(含图片地址,非图片本身)
→ 保存到 tb_blog 表
→ 跳转到个人主页💡 关键设计:上传照片和发布笔记是两个独立接口,因为图片上传功能在多个业务中复用,不只是探店笔记。
1.4 核心接口
1.4.1 上传图片:POST /upload/blog
// UploadController
public Result uploadBlog(@RequestParam("file") MultipartFile image) {
// 1. 获取原始文件名
// 2. 生成唯一文件名(避免冲突)
// 3. 保存到 Nginx 前端目录(简化:本地保存)
// 4. 返回图片访问地址
}⚠️ 配置注意:需在配置文件中设置
image.upload.dir为 Nginx 前端服务的静态资源目录(如/usr/local/nginx/html/hmdp/),否则上传后无法访问图片。
1.4.2 发布笔记:POST /blog
// BlogController
@PostMapping
public Result saveBlog(@RequestBody Blog blog) {
// 获取登录用户信息
blog.setUserId(UserHolder.getUser().getId());
// 保存笔记
blogService.save(blog);
return Result.ok();
}- 前端提交时已包含 title、images、content、shopId
- 后端只需补充 userId(从登录用户获取)
- 业务逻辑简单,属于基本 CRUD,与 Redis 无关
二、查看探店笔记详情
2.1 业务需求
用户在首页点击某篇笔记,进入详情页展示:标题、图片、内容、发布者信息(头像 + 昵称)。
2.2 核心难点:返回结果包含两部分数据
笔记详情页除了展示 blog 信息外,还需展示发布者的头像和昵称,方便其他用户关注。这意味着返回结果需要包含 blog + user 两部分数据。
2.3 实现方案对比
| 方案 | 做法 | 优缺点 |
|---|---|---|
| 方案一 | Blog 类中添加 User 成员变量 |
需要额外查询并设置 |
| 方案二(推荐) | Blog 类中添加 icon 和 name 两个字段 |
简化操作,直接查询填充 |
方案二实现细节:
@Data
public class Blog {
// ... 数据库字段
private Long id;
private Long shopId;
private Long userId;
private String title;
private String images;
private String content;
private Integer liked;
private Integer comments;
private LocalDateTime createTime;
private LocalDateTime updateTime;
// 非数据库字段:用户信息(用于前端展示)
@TableField(exist = false) // 标记不属于数据库表
private String icon; // 用户头像
@TableField(exist = false)
private String name; // 用户昵称
// 点赞状态(后续添加)
@TableField(exist = false)
private Boolean isLike;
}💡
@TableField(exist = false)告诉 MyBatisPlus 这个字段不属于tb_blog表,不会参与 SQL 操作,仅在 Java 对象中使用。
2.4 接口实现:GET /blog/{id}
// BlogController
@GetMapping("/{id}")
public Result queryBlogById(@PathVariable("id") Long id) {
return blogService.queryBlogById(id);
}
// BlogServiceImpl
public Result queryBlogById(Long id) {
// 1. 查询 blog
Blog blog = getById(id);
if (blog == null) {
return Result.fail("笔记不存在");
}
// 2. 查询 blog 相关的用户,填充 icon 和 name
queryBlogUser(blog);
return Result.ok(blog);
}封装公共方法 queryBlogUser:
// BlogServiceImpl - 查热门博客和查详情都会用到
private void queryBlogUser(Blog blog) {
Long userId = blog.getUserId();
User user = userService.getById(userId);
blog.setIcon(user.getIcon());
blog.setName(user.getNickName());
}💡 重构建议:将查询用户逻辑从 Controller 移到 Service 层,避免 Controller 包含过多业务逻辑。多个查询接口共享
queryBlogUser方法。
三、点赞功能
3.1 原始实现的问题
最初的点赞实现直接操作数据库:
UPDATE tb_blog SET liked = liked + 1 WHERE id = ?严重问题:同一用户可以无限次点赞,数据失去意义。
3.2 业务需求
- 同一用户对同一篇笔记只能点赞一次(再次点击则取消点赞)
- 点赞后按钮高亮显示,取消点赞后变灰
- 前端通过 Blog 对象的
isLike属性判断是否已点赞(true→ 高亮,false→ 灰色)
3.3 数据结构选择:Redis Set
判断用户是否点过赞的核心需求:
- 存储多个元素(点赞过的所有用户)→ 集合
- 同一用户不重复 → 唯一性
- 需要快速判断是否存在 → 查找效率
| 数据结构 | 是否集合 | 是否唯一 | 是否有序 | 查找方式 |
|---|---|---|---|---|
| List | ✅ | ❌ | ✅ | 遍历 O(n) |
| Set | ✅ | ✅ | ❌ | 哈希 O(1) |
| Sorted Set | ✅ | ✅ | ✅ | 哈希 O(1) |
选择 Set 的原因:当前阶段只需判断”是否点过赞”,不需要排序,Set 最合适。
3.4 Redis Key 设计
Key: blog:liked:{blogId}
Type: Set
Value: 点赞过的用户 ID 集合3.5 相关 Redis 命令
| 命令 | 作用 | 示例 |
|---|---|---|
SADD key member |
添加元素 | SADD blog:liked:13 5 |
SISMEMBER key member |
判断元素是否存在 | SISMEMBER blog:liked:13 5 |
SREM key member |
移除元素 | SREM blog:liked:13 5 |
3.6 接口实现:PUT /blog/like/{id}
// BlogController
@PutMapping("/like/{id}")
public Result likeBlog(@PathVariable("id") Long id) {
return blogService.likeBlog(id);
}
// BlogServiceImpl
public Result likeBlog(Long id) {
// 1. 获取当前登录用户 ID
Long userId = UserHolder.getUser().getId();
// 2. 判断用户是否已经点赞(Redis Set)
String key = BLOG_LIKED_KEY + id;
Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
if (BooleanUtil.isFalse(isMember)) {
// 3. 未点赞 → 点赞
// 3.1 数据库点赞数 +1
boolean isSuccess = update()
.setSql("liked = liked + 1")
.eq("id", id)
.update();
// 3.2 保存用户到 Redis Set
if (isSuccess) {
stringRedisTemplate.opsForSet().add(key, userId.toString());
}
} else {
// 4. 已点赞 → 取消点赞
// 4.1 数据库点赞数 -1
boolean isSuccess = update()
.setSql("liked = liked - 1")
.eq("id", id)
.update();
// 4.2 从 Redis Set 中移除用户
if (isSuccess) {
stringRedisTemplate.opsForSet().remove(key, userId.toString());
}
}
return Result.ok();
}⚠️ BooleanUtil.isFalse:
Boolean包装类型不能直接== false(可能为null),使用 Hutool 的BooleanUtil.isFalse()安全判断。
3.7 查询时设置 isLike 属性
在查询笔记详情和分页查询时,都需要判断当前用户是否点过赞:
// BlogServiceImpl - 封装判断方法
private void isBlogLiked(Blog blog) {
// 1. 获取登录用户
UserDTO user = UserHolder.getUser();
if (user == null) {
// 用户未登录,无需查询点赞状态
return;
}
Long userId = user.getId();
// 2. 判断是否点赞
String key = BLOG_LIKED_KEY + blog.getId();
Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
// 3. 设置 isLike 属性
blog.setIsLike(BooleanUtil.isTrue(isMember));
}💡 踩坑点:首页不一定需要登录才能访问,所以
UserHolder.getUser()可能为null,需要做空值判断,否则会报NullPointerException。
3.8 修改后的查询方法
// queryBlogById - 根据 ID 查询详情
public Result queryBlogById(Long id) {
Blog blog = getById(id);
if (blog == null) return Result.fail("笔记不存在");
queryBlogUser(blog); // 填充用户信息
isBlogLiked(blog); // 设置点赞状态 ⭐
return Result.ok(blog);
}
// queryHotBlog - 分页查询热门博客
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 -> {
queryBlogUser(blog); // 填充用户信息
isBlogLiked(blog); // 设置点赞状态 ⭐
});
return Result.ok(records);
}四、点赞排行榜
4.1 业务需求
在笔记详情页底部展示点赞排行榜(Top5),按点赞时间排序(类似微信朋友圈),先点赞的排在前面。
4.2 为什么需要换数据结构?
排行榜的核心需求变为三个:
- 存储多个元素 → 集合
- 元素不重复 → 唯一性
- 能排序(按时间) → 有序
Set 不支持排序,需要升级为 Sorted Set。
4.3 Sorted Set 替代 Set
| 对比项 | Set | Sorted Set |
|---|---|---|
| 添加元素 | SADD |
ZADD |
| 判断存在 | SISMEMBER |
ZSCORE(查分数,存在则返回分数,不存在返回 nil) |
| 移除元素 | SREM |
ZREM |
| 范围查询 | 无 | ZRANGE(按分数排序) |
判断存在的方式变化:
# Set 方式
SISMEMBER blog:liked:13 5 # 返回 true/false
# Sorted Set 方式(用 ZSCORE 代替)
ZSCORE blog:liked:13 5 # 返回分数(存在)或 nil(不存在)4.4 Redis Key 设计(不变,改类型)
Key: blog:liked:{blogId}
Type: Sorted Set(之前是 Set)
Value: 用户 ID
Score: 点赞时间戳(System.currentTimeMillis())4.5 改造点赞业务
// BlogServiceImpl - likeBlog(改造后)
public Result likeBlog(Long id) {
Long userId = UserHolder.getUser().getId();
String key = BLOG_LIKED_KEY + id;
// 判断是否已点赞(用 ZSCORE 代替 SISMEMBER)
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
if (score == null) {
// 未点赞 → 点赞
boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
if (isSuccess) {
// ZADD(带时间戳分数)
stringRedisTemplate.opsForZSet().add(
key,
userId.toString(),
System.currentTimeMillis()
);
}
} else {
// 已点赞 → 取消点赞
boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
if (isSuccess) {
stringRedisTemplate.opsForZSet().remove(key, userId.toString());
}
}
return Result.ok();
}4.6 改造 isBlogLiked
// isBlogLiked(改造后)
private void isBlogLiked(Blog blog) {
UserDTO user = UserHolder.getUser();
if (user == null) return;
Long userId = user.getId();
String key = BLOG_LIKED_KEY + blog.getId();
// 用 ZSCORE 代替 SISMEMBER
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
blog.setIsLike(score != null); // 分数不为 null → 已点赞
}4.7 接口实现:GET /blog/likes/{id}
// BlogController
@GetMapping("/likes/{id}")
public Result queryBlogLikes(@PathVariable("id") Long id) {
return blogService.queryBlogLikes(id);
}
// BlogServiceImpl
public Result queryBlogLikes(Long id) {
String key = BLOG_LIKED_KEY + id;
// 1. 查询 Top5 用户 ID(按时间戳从小到大排序 = 先点赞的在前)
Set<String> top5 = stringRedisTemplate.opsForZSet()
.range(key, 0, 4);
if (top5 == null || top5.isEmpty()) {
return Result.ok(Collections.emptyList());
}
// 2. 解析用户 ID(String → Long)
List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());
// 3. 根据 ID 批量查询用户
String idStr = StrUtil.join(",", ids);
// ⚠️ 用 in 查询时,数据库返回结果不保证按传入 ID 顺序排列
List<User> users = userService.query()
.in("id", ids)
.last("ORDER BY FIELD(id," + idStr + ")")
.list();
// 4. 转为 UserDTO 返回
List<UserDTO> userDTOS = users.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
return Result.ok(userDTOS);
}4.8 踩坑:SQL IN 查询的排序问题
问题现象:点赞顺序正确(Redis 中先 5 后 1),但排行榜显示顺序反了(先 1 后 5)。
原因:使用 WHERE id IN (?, ?) 查询时,MySQL 不会按照传入 ID 的顺序返回结果,而是按 ID 大小默认升序排列。
解决方案:使用 ORDER BY FIELD(id, ...) 强制按指定顺序排序:
// MyBatisPlus 写法
List<User> userService.query()
.in("id", ids)
.last("ORDER BY FIELD(id," + StrUtil.join(",", ids) + ")")
.list();
// 等价 SQL
SELECT * FROM tb_user
WHERE id IN (5, 1)
ORDER BY FIELD(id, 5, 1);💡
ORDER BY FIELD(field, val1, val2, ...):按字段值在参数列表中的位置排序,val1 排第一,val2 排第二,以此类推。
五、核心知识点总结
5.1 数据结构选型思维
| 业务需求 | 推荐数据结构 | 关键命令 |
|---|---|---|
| 判断是否存在(无序) | Set | SISMEMBER / SADD / SREM |
| 判断是否存在 + 排序 | Sorted Set | ZSCORE / ZADD / ZREM |
| 排行榜 / TopN | Sorted Set | ZRANGE(范围查询) |
选型方法论:先分析业务需求的三个维度——集合性、唯一性、有序性,再对照数据结构特点做选择。
5.2 Set vs Sorted Set 用法对比
| 操作 | Set 命令 | Sorted Set 命令 |
|---|---|---|
| 添加 | SADD key member |
ZADD key score member |
| 判断存在 | SISMEMBER key member |
ZSCORE key member(返回 nil = 不存在) |
| 移除 | SREM key member |
ZREM key member |
| 前N个 | ❌ 不支持 | ZRANGE key 0 N-1 |
5.3 编码技巧
- @TableField(exist = false):标记非数据库字段,用于跨表数据聚合返回
- 封装公共查询方法:
queryBlogUser和isBlogLiked被多个查询接口复用 - Boolean 包装类判断:不能用
==比较,使用BooleanUtil.isTrue()/isFalse() - ORDER BY FIELD:解决
IN查询结果排序不一致的问题 - StrUtil.join:将集合拼接为逗号分隔的字符串
5.4 常见踩坑点
- 空指针异常:首页不强制登录,
UserHolder.getUser()可能为 null,查询点赞状态前需做空判断 - Boolean 判断:
Boolean包装类不可直接== true/false,需用BooleanUtil工具 - IN 查询排序:数据库
IN查询不保证按传入顺序返回,需用ORDER BY FIELD强制排序 - 数据结构升级:从 Set 升级到 Sorted Set 时,判断存在的命令从
SISMEMBER变为ZSCORE - 图片上传目录:必须配置为 Nginx 前端静态资源目录,否则上传后无法访问
六、完整业务流程
┌─────────────────────────────────────────────────┐
│ 发布探店笔记 │
│ 上传图片 → 获取图片地址 → 填写标题/内容 → 保存 │
└─────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────┐
│ 查看探店笔记详情 │
│ 查询 blog → 填充用户信息 → 判断点赞状态 → 返回 │
└─────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────┐
│ 点赞功能 │
│ 获取用户 → ZSCORE 判断 → 已赞则取消/未赞则添加 │
│ (Set → Sorted Set, SISMEMBER → ZSCORE) │
└─────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────┐
│ 点赞排行榜 │
│ ZRANGE Top5 → 解析ID → ORDER BY FIELD 排序查询 │
└─────────────────────────────────────────────────┘