好友关注主要有三个功能:
1.关注和取关
2.共同关注
3.关注推送
关注和取关
涉及到的表,中间表:tb_follow,是博主 User 和粉丝 User 的中间表
请求一,查询是否关注了该用户:
请求路径:follow/or/not/{id}
请求方式:get
携带信息:登录 token
请求二,关注或者取关用户:
请求路径:follow/{id}/true (最后一个参数表示关注或取关)
请求方式:put
携带信息:登录 token
Pojo:
@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;}
Controller:
@RestController
@RequestMapping("/follow")
public class FollowController {@Resourceprivate FollowService followService;//查询是否关注@GetMapping("/or/not/{id}")public Result isFollow(@PathVariable("id") Long id){return followService.isFollow(id);}//关注或者取关@PutMapping("/{id}/{idFollow}")public Result follow(@PathVariable("id") Long id,@PathVariable("idFollow") Boolean isFollow){return followService.follow(id,isFollow);}}
Service:
public interface FollowService {Result isFollow(Long id);Result follow(Long id, Boolean isFollow);}
@Service
public class FollowServiceImpl implements FollowService {@Resourceprivate FollowMapper followMapper;@Overridepublic Result isFollow(Long id) {//判断当前登录用户是否关注该博主UserDTO user = UserHolder.getUser();if(user == null){return Result.fail("未登录,请先完成登录");}Integer count = followMapper.judgeFollow(id, user.getId());if(count != null && count > 0){//已关注return Result.ok(true);}else {//未关注return Result.ok(false);}}@Overridepublic Result follow(Long id, Boolean isFollow) {if(isFollow == null){return Result.fail("空指针异常");}UserDTO user = UserHolder.getUser();if(user == null){return Result.fail("未登录,请先完成登录");}if(isFollow){//关注用户Follow follow = new Follow();follow.setUserId(id);follow.setFollowUserId(user.getId());followMapper.insert(follow);return Result.ok(follow);}else {//取关用户followMapper.deleteFan(id,user.getId());return Result.ok("delete:"+user.getId()+" from "+id);}}
}
Mapper:
@Mapper
public interface FollowMapper extends BaseMapper<Follow> {//查询 a 是否 拥有粉丝 b@Select("select * from tb_follow where user_id = #{a} and follow_user_id = #{b}")Integer judgeFollow(@Param("a") Long a,@Param("b") Long b);//取关@Delete("delete from tb_follow where user_id = #{a} and follow_user_id = #{b}")void deleteFan(@Param("a") Long a,@Param("b") Long b);
}
Mapper 测试:
@Autowiredprivate FollowMapper followMapper;@Testvoid testIsFollow(){//查询用户1的粉丝有没有用户2Integer integer = followMapper.judgeFollow(1L, 2L);System.out.println(integer);}@Testvoid testRemove(){followMapper.deleteFan(1L,2L);}
测试:
共同关注
需求:点击博主头像后发送查询博主用户信息,以及查看博主发布的笔记的请求
查询博主用户信息:
请求路径:user/{id}
请求方式:get
携带信息:登录 token
查看博主发布的笔记:
请求路径:blog/of/user/{id}
请求方式:get
携带信息:登录 token,博主的用户 id
//查看博主的历史博文@GetMapping("/of/user/{id}")public Result getBlogs(@PathVariable("id") Long id){return blogService.getBlogs(id);}
@Overridepublic Result getBlogs(Long id) {List<Blog> allBlogs = blogMapper.getAllBlogs(id);return Result.ok(allBlogs);}
//查询 id 下的所有博文@Select("select * from tb_blog where user_id = #{id}")List<Blog> getAllBlogs(@Param("id") Long id);
测试:
查询共同关注请求:
请求路径:follow/common/{id}
请求方式:get
携带信息,登录 token
该功能的实现可以使用 redis 中的 set 集合中自带的 sinter set1 set2 方法来求两个集合的交集
具体实现,在关注用户的请求 service 中,将 "follow:"+userId 作为 key 将当前博主挂载到当前用户的关注用户的集合下,在取关时将其移除,再在共同关注请求中使用 opsForSet().intersect(key1,key2) 来得到两个 set 集合的交集,返回值时为 String 类型的集合
在关注和取关时,将博主加入用户的关注列表:
@Overridepublic Result follow(Long id, Boolean isFollow) {if(isFollow == null){return Result.fail("空指针异常");}UserDTO user = UserHolder.getUser();if(user == null){return Result.fail("未登录,请先完成登录");}String followKey = "user:follows:"+user.getId();if(isFollow){//关注用户Follow follow = new Follow();follow.setUserId(id);follow.setFollowUserId(user.getId());followMapper.insert(follow);//TODO 将当前博主挂在该用户的关注用户下stringRedisTemplate.opsForSet().add(followKey,Long.toString(id));return Result.ok(follow);}else {//取关用户followMapper.deleteFan(id,user.getId());//TODO 将当前博主从该用户的关注列表中移除stringRedisTemplate.opsForSet().remove(followKey,Long.toString(id));return Result.ok("delete:"+user.getId()+" from "+id);}}
我们通过不断让管理员用户(id=1)关注和取关 id=2 的用户,查看 redis 来测试功能是否实现
共同关注请求,Controller:
//查询共同关注的用户的集合@GetMapping("/common/{id}")public Result getSameFollow(@PathVariable("id") Long id){return followService.getSame(id);}
Service:
@Overridepublic Result getSame(Long id) {UserDTO user = UserHolder.getUser();if(user == null){return Result.fail("未登录,请先完成登录");}String followKey1 = "user:follows:"+user.getId();String followKey2 = "user:follows:"+id;Set<String> intersect = stringRedisTemplate.opsForSet().intersect(followKey1, followKey2);//将所有 userId 下的用户封装成 UserDTO 后以集合的形式返回List<UserDTO> users = new ArrayList<>();if(intersect == null){//没有共同关注用户,直接返回空集合return Result.ok(users);}for(String stringUserId : intersect){Long userId = Long.parseLong(stringUserId);User user1 = userMapper.selectById(userId);UserDTO userDTO = BeanUtil.copyProperties(user1,UserDTO.class);users.add(userDTO);}return Result.ok(users);}
为了方便测试,我们再添加一个管理员,设置其用户 id 为 2,来与 1 号管理员测试共同关注的功能
//缓存一个永久用的用户登录信息@Testvoid saveUserForever(){Map<String,String> userMap = new HashMap<>();userMap.put("id","2");userMap.put("nickName","管理员2");stringRedisTemplate.opsForHash().putAll("login:token:"+"textUser2",userMap);}
测试:
先让管理员 2 关注 3号用户
这下两个管理员都关注了 3 号用户
我们查询两个管理员的共同关注用户
在这里我一开始查询返回结果为 null ,通过 debug 找到是由于没有 3 这个 id 的用户导致的,在数据库添加 3号用户即可
关注推送
在用户发送笔记时通知所有关注该用户的人,即 feed 流
Feed 流常见模式:
1.TimeLine :类比微信朋友圈
2.智能排序 :类比短视频推送
本项目使用关注列表,以 TimeLine 的方式完成推送
推送方式主要有两种。推模式,在发送博文时,推送给每一个粉丝;拉模式,在查询关注时,拉取所有博主放在收件箱的博文
若是大型项目,可以采用推拉结合模式:
若粉丝量少的博主,我们采用推模式推送消息
若粉丝量多的博主,我们把分为活跃粉丝和非活跃粉丝。对于活跃粉丝我们采用推模式,对于非活跃粉丝我们采用拉模式推送
这里我们采用推模式来完成关注推送,使用 redis 的 SortedSet 集合,使用时间戳作为 score 排序,利用时间戳来实现滚动分页查询
每一个用户都有一个收件箱,是 redis 中的一个唯一的 key ,用于装推送给改用户的博文的 id,在每一次发布博文时,把该博文推送给所有粉丝
推送博文 id 给所有粉丝
followMapper 新写一个查询用户所有粉丝的 sql
@Mapper
public interface FollowMapper extends BaseMapper<Follow> {//查询 a 是否 拥有粉丝 b@Select("select * from tb_follow where user_id = #{a} and follow_user_id = #{b}")Integer judgeFollow(@Param("a") Long a,@Param("b") Long b);//取关@Delete("delete from tb_follow where user_id = #{a} and follow_user_id = #{b}")void deleteFan(@Param("a") Long a,@Param("b") Long b);//拿到对应用户的所有粉丝@Select("select follow_user_id from tb_follow where user_id = #{id}")List<Long> getFans(@Param("id") Long id);
}
@Overridepublic Result saveBlag(Blog blog) {if(blog == null){return Result.fail("博文状态异常");}int insert = blogMapper.insert(blog);if(insert == 0){return Result.fail("存入数据失败");}//TODO 将博文 id 推送给所有粉丝UserDTO user = UserHolder.getUser();if(user == null){return Result.fail("用户未登录");}//在tb_follow表中查询 user_id 为当前用户的所有粉丝List<Long> fans = followMapper.getFans(user.getId());//将博文 id 发送给所有粉丝的消息箱子(即redis的key)for (Long fan : fans) {String feedKey = "blog:feed:"+fan;stringRedisTemplate.opsForZSet().add(feedKey,Long.toString(user.getId()), System.currentTimeMillis());}return Result.ok(blog);}
测试是否会成功推送:
我们先登录 3号用户
然后拿着登录token去发送博文
再去 redis 中查看是否成功发送给所有粉丝
可以看到最后两条,已经成功给 3 号用户的粉丝,1、2 发送了消息推送
关注推送页面的分页查询请求
请求路径:/blog/of/follow
请求方式:get
请求参数:上一次查询的最晚时间戳,偏移量(最晚时间戳的博文数量),登录 token
返回值:返回当前查询的最小时间戳以及偏移量给前端
java 中 对应 redis 的 api:
查找在 min 和 max 之间的 count 个元素,偏移量为 offerset:
tuples = stringRedisTemplate.opsForZset().
reverseRangeByScoreWithScores(key,min,max,offerset,count);
tuple.getValue() 拿到博文的 String id
tuple.getScore().longValue() 拿到 Long 类型的 时间戳
完整代码:
controller:
//关注推送@GetMapping("/of/follow")public Result getFeed(@RequestParam("max") Long max,@RequestParam(value="offset",defaultValue="0") Integer offset ){return blogService.getFeed(max,offset);}
响应类:
@Data
public class BlogsFeedResult {private List<Blog> blogs;private Long max;private Integer offset;
}
seiviceImpl:
@Overridepublic Result getFeed(Long max,Integer offset) {UserDTO user = UserHolder.getUser();if(user == null){return Result.fail("用户登录信息失效,请登录后重试");}//拿到消息箱分页处理后的博文idString feedKey = "blog:feed:"+user.getId();Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet().reverseRangeByScoreWithScores(feedKey, 0, max, offset, 3);//每一个 tuples 都装了 一个 value 和一个 scoreif(typedTuples == null || typedTuples.size() == 0){return Result.fail("未关注任何用户");}long min = System.currentTimeMillis();int save = 1;List<Blog> blogs = new ArrayList<>();for (ZSetOperations.TypedTuple<String> tuple : typedTuples) {//将博文信息封装到集合中,并拿到当前查询的最小时间戳,计算最小时间戳的元素个数Blog blog = blogMapper.selectById(Long.parseLong(tuple.getValue()));blogs.add(blog);long time = tuple.getScore().longValue();if(time == min){save++;}else{min = time;save = 1;}}BlogsFeedResult blogsFeedResult = new BlogsFeedResult();blogsFeedResult.setBlogs(blogs);blogsFeedResult.setMax(min);blogsFeedResult.setOffset(save);return Result.ok(blogsFeedResult);}
测试:
首次
第一次测试时发现返回值中的当前查询时间戳的最小值与预期不符,通过 debug 得知是在封装答案时将 min 写成了 max
在发送 get 请求时要注意:@RequestParam 的注解接收的参数必须在 Param 中传,而不能在 Body 中传