|

实战篇 - 达人探店模块

📋 模块概览

本模块实现”黑马点评”项目的达人探店功能,分四个阶段递进:

阶段 内容 核心技术
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 类中添加 iconname 两个字段 简化操作,直接查询填充

方案二实现细节

@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 业务需求

  1. 同一用户对同一篇笔记只能点赞一次(再次点击则取消点赞)
  2. 点赞后按钮高亮显示,取消点赞后变灰
  3. 前端通过 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.isFalseBoolean 包装类型不能直接 == 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 为什么需要换数据结构?

排行榜的核心需求变为三个

  1. 存储多个元素 → 集合
  2. 元素不重复 → 唯一性
  3. 能排序(按时间) → 有序

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):标记非数据库字段,用于跨表数据聚合返回
  • 封装公共查询方法queryBlogUserisBlogLiked 被多个查询接口复用
  • Boolean 包装类判断:不能用 == 比较,使用 BooleanUtil.isTrue() / isFalse()
  • ORDER BY FIELD:解决 IN 查询结果排序不一致的问题
  • StrUtil.join:将集合拼接为逗号分隔的字符串

5.4 常见踩坑点

  1. 空指针异常:首页不强制登录,UserHolder.getUser() 可能为 null,查询点赞状态前需做空判断
  2. Boolean 判断Boolean 包装类不可直接 == true/false,需用 BooleanUtil 工具
  3. IN 查询排序:数据库 IN 查询不保证按传入顺序返回,需用 ORDER BY FIELD 强制排序
  4. 数据结构升级:从 Set 升级到 Sorted Set 时,判断存在的命令从 SISMEMBER 变为 ZSCORE
  5. 图片上传目录:必须配置为 Nginx 前端静态资源目录,否则上传后无法访问

六、完整业务流程

┌─────────────────────────────────────────────────┐
│                发布探店笔记                       │
│  上传图片 → 获取图片地址 → 填写标题/内容 → 保存   │
└─────────────────────────────────────────────────┘
                        ↓
┌─────────────────────────────────────────────────┐
│              查看探店笔记详情                     │
│  查询 blog → 填充用户信息 → 判断点赞状态 → 返回   │
└─────────────────────────────────────────────────┘
                        ↓
┌─────────────────────────────────────────────────┐
│                  点赞功能                        │
│  获取用户 → ZSCORE 判断 → 已赞则取消/未赞则添加  │
│  (Set → Sorted Set, SISMEMBER → ZSCORE)         │
└─────────────────────────────────────────────────┘
                        ↓
┌─────────────────────────────────────────────────┐
│                点赞排行榜                        │
│  ZRANGE Top5 → 解析ID → ORDER BY FIELD 排序查询  │
└─────────────────────────────────────────────────┘
评论交流

文章目录