好友关注
01.关注和取关
需求
在探店图文的详情页面中,可以关注发布笔记的作者:

需求:基于该表数据结构,实现两个接口:
- 关注和取关接口。
- 判断是否关注的接口。
关注是User之间的关系,是博主与粉丝的关系,数据库中有一张tb_follow表来标示:
CREATE TABLE `tb_follow` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` bigint unsigned NOT NULL COMMENT '用户id',
`follow_user_id` bigint unsigned NOT NULL COMMENT '关联的用户id',
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=COMPACT;
实现
Follow
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("tb_follow")
public class Follow implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 主键
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 用户id
*/
private Long userId;
/**
* 关联的用户id
*/
private Long followUserId;
/**
* 创建时间
*/
private LocalDateTime createTime;
}
FollowController
/**
* 关注与取关用户
* @param followUserId 关注的用户id
* @param isFollow 是否关注
* @return R
*/
@PutMapping("/{id}/{isFollow}")
public Result follow(@PathVariable("id") Long followUserId, @PathVariable("isFollow") Boolean isFollow){
return followService.follow(followUserId, isFollow);
}
/**
* 是否已关注用户
* @param followUserId 关注用户id
* @return R
*/
@GetMapping("/or/not/{id}")
public Result isFollow(@PathVariable("id") Long followUserId){
return followService.isFollow(followUserId);
}
@Override
public Result follow(Long followUserId, Boolean isFollow) {
// 1.获取用户id
Long userId = UserHolder.getUser().getId();
// 判断是关注还是取关
if (isFollow){
// 2.关注,新增数据
Follow follow = new Follow();
follow.setUserId(userId);
follow.setFollowUserId(followUserId);
save(follow);
} else {
// 3.取关,删除数据 delete from tb_follow where user_id = ? and follow_user_id = ?
LambdaQueryWrapper<Follow> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Follow::getUserId, userId);
queryWrapper.eq(Follow::getFollowUserId, followUserId);
remove(queryWrapper);
}
return Result.ok();
}
@Override
public Result isFollow(Long followUserId) {
// 查询是否关注 select * from tb_follow where user_id = ? and follow_user_id = ?
Long userId = UserHolder.getUser().getId();
Integer count = query().eq("user_id", userId).eq("follow_user_id", followUserId).count();
return Result.ok(count > 0);
}
02.共同关注
需求
点击博主头像,可以进入博主首页:

博主个人首页依赖两个接口:
根据id查询user信息:
UserController
@GetMapping("/{id}") public Result queryUserById(@PathVariable("id") Long userId){ // 查询详情 User user = userService.getById(userId); if (user == null) { return Result.ok(); } UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class); // 返回 return Result.ok(userDTO); }
根据id查询博主的探店笔记:
BlogController
@GetMapping("/of/user") public Result queryBlogByUserId( @RequestParam(value = "current", defaultValue = "1") Integer current, @RequestParam("id") Long id) { // 根据用户查询 Page<Blog> page = blogService.query() .eq("user_id", id).page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE)); // 获取当前页数据 List<Blog> records = page.getRecords(); return Result.ok(records); }
需求:利用Redis中恰当的数据结构,实现共同关注功能。在博主个人页面展示出当前用户与博主的共同好友。

实现
FollowController
/**
* 共同关注
* @param id 用户id
* @return R
*/
@GetMapping("/common/{id}")
public Result followCommons(@PathVariable("id") Long id){
return followService.followCommons(id);
}
@Override
public Result followCommons(Long id) {
// 1.获取当前登录用户
Long userId = UserHolder.getUser().getId();
String key = RedisConstants.FOLLOWS_KEY + userId;
// 2.求交集
String key2 = RedisConstants.FOLLOWS_KEY + id;
Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key, key2);
if (intersect == null || intersect.isEmpty()){
return Result.ok(Collections.emptyList());
}
// 3.解析id集合
List<Long> ids = intersect.stream().map(Long::valueOf).collect(Collectors.toList());
// 4.查询用户
List<UserDTO> users = userService.listByIds(ids)
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
return Result.ok(users);
}
在Redis中使用set集合存储关注用户id,使用set的交集获取共同关注。

因为用到了Redis中的set数据结构,所以需要再用户关注的时候把关注信息存到set,用户取关的时候从Redis中移除关注信息。key是用户id,value存储关注id。

修改关注实现:
@Override
public Result follow(Long followUserId, Boolean isFollow) {
// 1.获取用户id
Long userId = UserHolder.getUser().getId();
String key = RedisConstants.FOLLOWS_KEY + userId;
// 判断是关注还是取关
if (isFollow){
// 2.关注,新增数据
Follow follow = new Follow();
follow.setUserId(userId);
follow.setFollowUserId(followUserId);
boolean isSuccess = save(follow);
if (isSuccess){
// 把关注用户的id,存入Redis中的set
stringRedisTemplate.opsForSet().add(key, followUserId.toString());
}
} else {
// 3.取关,删除数据 delete from tb_follow where user_id = ? and follow_user_id = ?
LambdaQueryWrapper<Follow> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Follow::getUserId, userId);
queryWrapper.eq(Follow::getFollowUserId, followUserId);
boolean isSuccess = remove(queryWrapper);
// 把关注用户的id从redis中的set移除
if (isSuccess){
stringRedisTemplate.opsForSet().remove(key, followUserId.toString());
}
}
return Result.ok();
}
03.关注推送
Feed流实现方案分析
关注推送也叫做Feed流,直译为投喂。为用户持续的提供“沉浸式”的体验,通过无限下拉刷新获取新的信息。

Feed流产品有两种常见模式:
- Timeline:不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注。例如朋友圈。
- 优点:信息全面,不会有缺失。并且实现也相对简单。
- 缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低。
- 智能排序:利用智能算法屏蔽掉违规的、用户不感兴趣的内容。推送用户感兴趣信息来吸引用户。
- 优点:投喂用户感兴趣信息,用户粘度很高,容易沉迷。
- 缺点:如果算法不精准,可能起到反作用。
本例中的个人页面,是基于关注的好友来做Feed流,因此采用Timeline的模式。该模式的实现方案有三种:
拉模式:也叫读扩散。
image-20230205104019207 推模式:也叫做写扩散。
image-20230205104050924 推拉结合:也叫做读写混合,兼具推和拉两种模式的优点。
image-20230205104138932
三种方案对比:
拉模式 | 推模式 | 推拉结合 | |
---|---|---|---|
写比例 | 低 | 高 | 中 |
读比例 | 高 | 低 | 中 |
用户读取延迟 | 高 | 低 | 低 |
实现难度 | 复杂 | 简单 | 很复杂 |
使用场景 | 很少使用 | 用户量少、没有大V | 过千万的用户量,有大V |
基于推模式实现关注推送功能
需求:
- 修改新增探店笔记的业务,在保存blog到数据库的同时,推送到粉丝的收件箱。
- 收件箱满足可以根据时间戳排序,必须用Redis的数据结构实现。
- 查询收件箱数据时,可以实现分页查询。
推送到粉丝收件箱
BlogController
/**
* 保存探店笔记
* @param blog 探店笔记实体
* @return R
*/
@PostMapping
public Result saveBlog(@RequestBody Blog blog) {
return blogService.saveBlog(blog);
}
/**
* 保存探店笔记,并推送笔记id到Redis
* @param blog 探店笔记实体
* @return R
*/
@Override
public Result saveBlog(Blog blog) {
// 1.获取登录用户
UserDTO user = UserHolder.getUser();
blog.setUserId(user.getId());
// 2.保存探店博文
boolean isSuccess = save(blog);
if (!isSuccess){
return Result.fail("新增笔记失败!");
}
// 3.查询笔记作者的所有粉丝
List<Follow> follows = followService.query().eq("follow_user_id", user.getId()).list();
// 4.推送笔记id给所有粉丝
double score = System.currentTimeMillis();
for (Follow follow : follows) {
// 4.1获取粉丝id
Long userId = follow.getUserId();
// 4.2推送
String key = RedisConstants.FEED_KEY + userId;
stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(), score);
}
// 5.返回id
return Result.ok(blog.getId());
}
查看Redis:

1号用户关注了博主,当博主发布新笔记的时候,就会把笔记id推送到Redis中。
滚动分页查询收件箱的思路
Feed流中的数据会不断更新,所以数据的角标也在变化,因此不能采用传统的分页模式。
实现关注推送页面的分页查询
需求:在个人主页的“关注”卡片中,查询并展示推送的Blog信息:

接口设计:
请求地址:
/blog/of/follow
请求方式:
GET
请求参数:
参数 说明 lastId 上一次查询的最小时间戳 offset 偏移量 响应格式:
参数 说明 List<Blog> 小于指定时间戳的笔记集合 minTime 本次查询的推送的最小时间戳 offset 偏移量 { "success": true, "data": { "list": [ { "id": 29, "shopId": 5, "userId": 1010, "icon": "", "name": "user_aahh9auvpj", "isLike": false, "title": "可可不吃肉第100次发", "images": "/imgs/blogs/7/12/f9d5f2cd-8ca7-43ae-b2b6-d7cdcd077a1c.jpg", "content": "环境:\n环境挺优雅的,然后自己在这边逛街,然后累了有点饿,就随便进了一家餐厅外观看着挺好看的适合下午茶呀,风景都很漂亮,地方也很好找,就是在电梯旁边。\n服务:\n服务员是一个叔叔,挺热情的,他说我一个人点了太多菜吃不完很浪费,让后让我去掉几个菜,他说我不应该点七八个菜。蛮好的,吃了这么多页才四百多。", "liked": 0, "createTime": "2023-02-04T19:29:31", "updateTime": "2023-02-04T19:29:31" }, { "id": 28, "shopId": 4, "userId": 1010, "icon": "", "name": "user_aahh9auvpj", "isLike": false, "title": "可爱多第5次发", "images": "/imgs/blogs/3/6/1d5fdf4f-c589-466f-a7d8-d8d6a34114fd.jpg", "content": "5555555555555555", "liked": 0, "createTime": "2023-02-03T17:42:22", "updateTime": "2023-02-03T17:42:22" }, { "id": 27, "shopId": 8, "userId": 1010, "icon": "", "name": "user_aahh9auvpj", "isLike": false, "title": "可爱多第4次发", "images": "/imgs/blogs/7/8/e42d4ccf-1a80-4de1-83bc-38dff19b8306.jpg", "content": "44444444444444", "liked": 0, "createTime": "2023-02-03T17:37:17", "updateTime": "2023-02-03T17:37:17" } ], "minTime": 1675417037362, "offset": 1 } }
BlogController
/**
* 滚动分页查询关注收件箱信息
* @param max 当前时间戳
* @param offset 偏移量
* @return R
*/
@GetMapping("/of/follow")
public Result queryBlogOfFollow(
@RequestParam("lastId") Long max,
@RequestParam(value = "offset", defaultValue = "0") Integer offset) {
return blogService.queryBlogOfFollow(max, offset);
}
/**
* 滚动分页查询关注收件箱信息
*
* @param max 当前时间戳
* @param offset 偏移量
* @return R
*/
@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
// 1.获取当前用户
Long userId = UserHolder.getUser().getId();
// 2.查询收件箱
String key = RedisConstants.FEED_KEY + userId;
Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
.reverseRangeByScoreWithScores(key, 0, max, offset, 3);
if (typedTuples == null || typedTuples.isEmpty()){
return Result.ok();
}
// 3.解析数据: blogId、minTime(时间戳)、offset
// 探店笔记id集合
List<Long> ids = new ArrayList<>(typedTuples.size());
// 上次查询的最小时间戳
long minTime = 0;
// 滚动分页查询偏移量
int os = 1;
for (ZSetOperations.TypedTuple<String> tuple : typedTuples) {
ids.add(Long.valueOf(tuple.getValue()));
long time = tuple.getScore().longValue();
if (time == minTime){
os++;
} else {
minTime = time;
os = 1;
}
}
// 4.根据id查询blog
String idStr = StrUtil.join(",", ids);
List<Blog> blogs = query()
.in("id", ids).last("ORDER BY FIELD(id," + idStr + ")")
.list();
for (Blog blog : blogs) {
// 4.1查询blog有关的用户
queryBlogUser(blog);
// 4.2.查询blog是否被点赞过
isBlogLiked(blog);
}
// 5.封装并返回
ScrollResult r = new ScrollResult();
r.setList(blogs);
r.setMinTime(minTime);
r.setOffset(os);
return Result.ok(r);
}
测试:
第一次查询:http://localhost:8080/api/blog/of/follow?&lastId=1675565744328
image-20230205105949019 滚动翻页:http://localhost:8080/api/blog/of/follow?&offset=1&lastId=1675417037362
image-20230205110054549