1 达人探店 1.1 分享探店图文 1.1.1 发布探店笔记 涉及两个数据库表:
tb_blog:探店笔记表,包含笔记中的标题、文字、图片等
tb_blog_comments:其他用户对探店笔记的评价
发布流程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 @Slf4j @RestController @RequestMapping("upload") public class UploadController { @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)); log.debug("文件上传成功,{}" , fileName); return Result.ok(fileName); } catch (IOException e) { throw new RuntimeException ("文件上传失败" , e); } } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 public static final String IMAGE_UPLOAD_DIR = "xxxx" ;@RestController @RequestMapping("/blog") public class BlogController { @Resource private IBlogService blogService; @PostMapping public Result saveBlog (@RequestBody Blog blog) { UserDTO user = UserHolder.getUser(); blog.setUpdateTime(user.getId()); blogService.saveBlog(blog); return Result.ok(blog.getId()); } }
1.1.2 查看探店笔记
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @GetMapping("/hot") public Result queryHotBlog (@RequestParam(value = "current", defaultValue = "1") Integer current) { return blogService.queryHotBlog(current); }@GetMapping("/{id}") public Result queryBlogById (@PathVariable("id") Long id) { return blogService.queryBlogById(id); }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 @Service public class BlogServiceImpl extends ServiceImpl <BlogMapper, Blog> implements IBlogService { @Resource private IUserService userService; @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(this ::queryBlogUser); return Result.ok(records); } @Override public Result queryBlogById (Long id) { Blog blog = getById(id); if (null == blog) { return Result.fail("笔记不存在!" ); } queryBlogUser(blog); return Result.ok(blog); } private void queryBlogUser (Blog blog) { Long userId = blog.getUserId(); User user = userService.getById(userId); blog.setName(user.getNickName()); blog.setIcon(user.getIcon()); } }
1.2 点赞功能 1 2 3 4 5 6 @GetMapping("/likes/{id}") public Result queryBlogLikes (@PathVariable("id") Long id) { blogService.update().setSql("liked = liked +1 " ).eq("id" ,id).update(); return Result.ok(); }
这样会有一个问题,一个用户可以多次的点赞
1.2.1 修改
给Blog类中添加一个isLike字段,标示是否被当前用户点赞
1 2 @TableField(exist = false) private Boolean isLike;
修改点赞功能,利用Redis的set集合判断是否点赞过,未点赞过则点赞数+1,已点赞过则点赞数-1
修改根据id查询Blog的业务,判断当前登录用户是否点赞过,赋值给isLike字段
修改分页查询Blog业务,判断当前登录用户是否点赞过,赋值给isLike字段
1 2 3 4 5 6 @PutMapping("/like/{id}") public Result likeBlog (@PathVariable("id") Long id) { return blogService.likeBlog(id); }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 @Service public class BlogServiceImpl extends ServiceImpl <BlogMapper, Blog> implements IBlogService { @Resource private IUserService userService; @Resource StringRedisTemplate stringRedisTemplate; @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(new Consumer <Blog>() { @Override public void accept (Blog blog) { queryBlogUser(blog); isBlogLiked(blog); } }); return Result.ok(records); } @Override public Result queryBlogById (Long id) { Blog blog = getById(id); if (null == blog) { return Result.fail("笔记不存在!" ); } queryBlogUser(blog); isBlogLiked(blog); return Result.ok(blog); } private void isBlogLiked (Blog blog) { Long id = blog.getId(); Long userId = UserHolder.getUser().getId(); String key = RedisConstants.BLOG_LIKED_KEY + id; Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString()); boolean flag = BooleanUtil.isTrue(isMember); blog.setIsLike(flag); } @Override public Result likeBlog (Long id) { Long userId = UserHolder.getUser().getId(); String key = RedisConstants.BLOG_LIKED_KEY + id; Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString()); boolean flag = BooleanUtil.isTrue(isMember); if (!flag) { boolean isSuccess = update().setSql("liked = liked + 1" ).eq("id" , id).update(); if (isSuccess) { stringRedisTemplate.opsForSet().add(key, userId.toString()); } } else { boolean isSuccess = update().setSql("liked = liked - 1" ).eq("id" , id).update(); if (isSuccess) { stringRedisTemplate.opsForSet().remove(key, userId.toString()); } } return null ; } private void queryBlogUser (Blog blog) { Long userId = blog.getUserId(); User user = userService.getById(userId); blog.setName(user.getNickName()); blog.setIcon(user.getIcon()); } }
1.3 基于List实现点赞用户列表 在探店笔记的详情页面,应该把给该笔记点赞的人显示出来,比如最早点赞的TOP5,形成点赞排行榜:
之前的点赞是放到set集合,但是set集合是不能排序的,所以这个时候,咱们可以采用一个可以排序的set集合,就是咱们的sortedSet
1.3.1 List/Set/SortedSet对比
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 @Service public class BlogServiceImpl extends ServiceImpl <BlogMapper, Blog> implements IBlogService { @Resource private IUserService userService; @Resource StringRedisTemplate stringRedisTemplate; @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(new Consumer <Blog>() { @Override public void accept (Blog blog) { queryBlogUser(blog); isBlogLiked(blog); } }); return Result.ok(records); } @Override public Result queryBlogById (Long id) { Blog blog = getById(id); if (null == blog) { return Result.fail("笔记不存在!" ); } queryBlogUser(blog); isBlogLiked(blog); return Result.ok(blog); } private void isBlogLikedSet (Blog blog) { Long id = blog.getId(); Long userId = UserHolder.getUser().getId(); if (null == userId) { return ; } String key = RedisConstants.BLOG_LIKED_KEY + id; Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString()); boolean flag = BooleanUtil.isTrue(isMember); blog.setIsLike(flag); } private void isBlogLiked (Blog blog) { Long id = blog.getId(); Long userId = UserHolder.getUser().getId(); if (null == userId) { return ; } String key = RedisConstants.BLOG_LIKED_KEY + id; Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString()); blog.setIsLike(null != score); } public Result likeBlogSet (Long id) { Long userId = UserHolder.getUser().getId(); String key = RedisConstants.BLOG_LIKED_KEY + id; Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString()); boolean flag = BooleanUtil.isTrue(isMember); if (!flag) { boolean isSuccess = update().setSql("liked = liked + 1" ).eq("id" , id).update(); if (isSuccess) { stringRedisTemplate.opsForSet().add(key, userId.toString()); } } else { boolean isSuccess = update().setSql("liked = liked - 1" ).eq("id" , id).update(); if (isSuccess) { stringRedisTemplate.opsForSet().remove(key, userId.toString()); } } return null ; } @Override public Result likeBlog (Long id) { Long userId = UserHolder.getUser().getId(); String key = RedisConstants.BLOG_LIKED_KEY + id; Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString()); if (null == score) { boolean isSuccess = update().setSql("liked = liked + 1" ).eq("id" , id).update(); if (isSuccess) { 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 null ; } private void queryBlogUser (Blog blog) { Long userId = blog.getUserId(); User user = userService.getById(userId); blog.setName(user.getNickName()); blog.setIcon(user.getIcon()); } }
BlogController.java
1 2 3 4 5 @GetMapping ("/likes/{id}" ) public Result queryBlogLikes (@PathVariable ("id" ) Long id) { return blogService .queryBlogLikes (id); }
BlogServiceImpl.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 @Override public Result queryBlogLikes (Long id) { String key = RedisConstants.BLOG_LIKED_KEY + id; Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0 , 4 ); if (null == top5 || top5.isEmpty()) { return Result.ok(Collections.emptyList()); } List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList()); List<UserDTO> userDTOS = userService.listByIds(ids).stream().map(user -> BeanUtil.copyProperties(user, UserDTO.class)).collect(Collectors.toList()); return Result.ok(userDTOS); }
此时会发现点赞的顺序是反的,问题就出现在了SQL上
那么此时修改SQL即可解决
1 SELECT * FROM tb_user WHERE id IN ('5' ,'1' ) ORDER BY FIELD(id,5 ,1 );
1 2 3 4 5 6 7 8 9 List<UserDTO> userDTOS = userService.query() .in("id" , ids) .last("order by field (id," + idsStr + ") " ).list() .stream().map(user -> BeanUtil.copyProperties(user, UserDTO.class)) .collect(Collectors.toList());
2 关注列表 2.1 添加关注 针对用户的操作:可以对用户进行关注和取消关注功能。
涉及到数据库表tb_follow:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 @RestController @RequestMapping("/follow") public class FollowController { @Autowired private IFollowService iFollowService; @PutMapping("/{id}/{isFollow}") public Result follow (@PathVariable("id") Long followUserId, @PathVariable("isFollow") boolean isFollow) { return iFollowService.follow(followUserId, isFollow); } @GetMapping("/or/not/{id}") public Result isFollow (@PathVariable("id") Long followUserId) { return iFollowService.isFollow(followUserId); } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 @Service public class FollowServiceImpl extends ServiceImpl <FollowMapper, Follow> implements IFollowService { @Override public Result follow (Long followUserId, boolean isFollow) { Long userId = UserHolder.getUser().getId(); if (isFollow) { Follow follow = new Follow (); follow.setFollowUserId(followUserId); follow.setUserId(userId); save(follow); } else { LambdaQueryWrapper<Follow> wrapper = new LambdaQueryWrapper <>(); remove(wrapper.eq(Follow::getUserId, userId).eq(Follow::getFollowUserId, followUserId)); } return Result.ok(); } @Override public Result isFollow (Long followUserId) { Long userId = UserHolder.getUser().getId(); Integer count = query().eq("user_id" , userId).eq("follow_user_id" , followUserId).count(); return Result.ok(count > 0 ); } }
2.2 共同关注列表 想要去看共同关注的好友,需要首先进入到这个页面,这个页面会发起两个请求
1、去查询用户的详情
2、去查询用户的笔记
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 @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); }@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); }
但是此时点击了之后发现共同关注报错,如何去解决共同关注的问题。模拟场景:A关注了B、C,而D关注了C、E,那么对于A和D来说他们的共同关注就是C,在Redis中可以使用set求交集
2.2.1 修改 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 @Override public Result follow (Long followUserId, boolean isFollow) { Long userId = UserHolder.getUser().getId(); String key = RedisConstants.FOLLOW_USER_LIST + userId; if (isFollow) { Follow follow = new Follow (); follow.setFollowUserId(followUserId); follow.setUserId(userId); boolean isSuccess = save(follow); if (isSuccess) { stringRedisTemplate.opsForSet().add(key, followUserId.toString()); } } else { LambdaQueryWrapper<Follow> wrapper = new LambdaQueryWrapper <>(); boolean isSuccess = remove(wrapper.eq(Follow::getUserId, userId).eq(Follow::getFollowUserId, followUserId)); if (isSuccess) { stringRedisTemplate.opsForSet().remove(key, followUserId.toString()); } } return Result.ok(); }
2.2.2 共同关注 1 2 3 4 5 6 7 8 9 @GetMapping("/common/{id}") public Result followCommons (@PathVariable("id") Long id) { return iFollowService.followCommons(id); }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 @Override public Result followCommons (Long id) { Long userId = UserHolder.getUser().getId(); String key = RedisConstants.FOLLOW_USER_LIST + userId; String keyFollow = RedisConstants.FOLLOW_USER_LIST + id; Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key, keyFollow); if (null == intersect || intersect.isEmpty()) { return Result.ok(Collections.emptyList()); } List<Long> ids = intersect.stream().map(Long::valueOf).collect(Collectors.toList()); List<UserDTO> userDTOS = userService.listByIds(ids) .stream().map(user -> BeanUtil.copyProperties(user, UserDTO.class)).collect(Collectors.toList()); return Result.ok(userDTOS); }
2.3 关注用户发布消息-Feed流 当我们关注了用户后,这个用户发了动态,那么我们应该把这些数据推送给用户,这个需求,其实我们又把他叫做Feed流,关注推送也叫做Feed流,直译为投喂。为用户持续的提供“沉浸式”的体验,通过无限下拉刷新获取新的信息。
对于传统的模式的内容解锁:我们是需要用户去通过搜索引擎或者是其他的方式去解锁想要看的内容
对于新型的Feed流的的效果:不需要我们用户再去推送信息,而是系统分析用户到底想要什么,然后直接把内容推送给用户,从而使用户能够更加的节约时间,不用主动去寻找。
Feed流产品有两种常见模式: Timeline:不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注。例如朋友圈
优点:信息全面,不会有缺失。并且实现也相对简单 缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低 智能排序:利用智能算法屏蔽掉违规的、用户不感兴趣的内容。推送用户感兴趣信息来吸引用户
优点:投喂用户感兴趣信息,用户粘度很高,容易沉迷 缺点:如果算法不精准,可能起到反作用 本例中的个人页面,是基于关注的好友来做Feed流,因此采用Timeline的模式。该模式的实现方案有三种:
2.3.1 拉模式-读扩散 该模式的核心含义就是:当张三和李四和王五发了消息后,都会保存在自己的邮箱中,假设赵六要读取信息,那么他会从读取他自己的收件箱,此时系统会从他关注的人群中,把他关注人的信息全部都进行拉取,然后在进行排序
优点:比较节约空间,因为赵六在读信息时,并没有重复读取,而且读取完之后可以把他的收件箱进行清楚。
缺点:比较延迟,当用户读取数据时才去关注的人里边去读取数据,假设用户关注了大量的用户,那么此时就会拉取海量的内容,对服务器压力巨大。
2.3.2 推模式-写扩散 推模式是没有写邮箱的,当张三写了一个内容,此时会主动的把张三写的内容发送到他的粉丝收件箱中去,假设此时李四再来读取,就不用再去临时拉取了
优点:时效快,不用临时拉取
缺点:内存压力大,假设一个大V写信息,很多人关注他, 就会写很多分数据到粉丝那边去
2.3.3 推拉结合-读写混合 推拉模式是一个折中的方案,站在发件人这一段,如果是个普通的人,那么我们采用写扩散的方式,直接把数据写入到他的粉丝中去,因为普通的人他的粉丝关注量比较小,所以这样做没有压力,如果是大V,那么他是直接将数据先写入到一份到发件箱里边去,然后再直接写一份到活跃粉丝收件箱里边去,现在站在收件人这端来看,如果是活跃粉丝,那么大V和普通的人发的都会直接写入到自己收件箱里边来,而如果是普通的粉丝,由于他们上线不是很频繁,所以等他们上线时,再从发件箱里边去拉信息。
2.4 探店推送 需要实现当关注用户增加一个新的笔记后,能推送到分析收件箱
2.4.1 分析 Feed流中的数据会不断更新,所以数据的角标也在变化,因此不能采用传统的分页模式。
传统的分页在feed流是不适用的,因为我们的数据会随时发生变化。
假设在t1 时刻,我们去读取第一页,此时page = 1 ,size = 5 ,那么我们拿到的就是10 ~ 6 这几条记录,假设现在t2时候又发布了一条记录,此时t3 时刻,我们来读取第二页,读取第二页传入的参数是page=2 ,size=5 ,那么此时读取到的第二页实际上是从6 开始,然后是6~2 ,那么我们就读取到了重复的数据,所以feed流的分页,不能采用原始方案来做。
2.4.2 Feed流滚动分页 我们需要记录每次操作的最后一条,然后从这个位置开始去读取数据
举个例子:我们从t1时刻开始,拿第一页数据,拿到了10~6,然后记录下当前最后一次拿取的记录,就是6,t2时刻发布了新的记录,此时这个11放到最顶上,但是不会影响我们之前记录的6,此时t3时刻来拿第二页,第二页这个时候拿数据,还是从6后一点的5去拿,就拿到了5-1的记录。我们这个地方可以采用sortedSet来做,可以进行范围查询,并且还可以记录当前获取数据时间戳最小值,就可以实现滚动分页了
即:在保存完探店笔记后,获得到当前笔记的粉丝,然后把数据推送到粉丝的redis中去。
1 2 3 4 @PostMapping public Result saveBlog (@RequestBody Blog blog) { return blogService.saveBlog(blog); }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 @Override public Result saveBlog (Blog blog) { UserDTO user = UserHolder.getUser(); blog.setUserId(user.getId()); boolean isSuccess = save(blog); if (!isSuccess) { return Result.fail("新增笔记失败,请重新发布!" ); } LambdaQueryWrapper<Follow> wrapper = new LambdaQueryWrapper <>(); wrapper.eq(Follow::getFollowUserId, user.getId()); List<Follow> follows = followService.list(wrapper); for (Follow follow : follows) { Long fansId = follow.getUserId(); String key = RedisConstants.FEED_KEY + fansId; stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(), System.currentTimeMillis()); } return Result.ok(blog.getId()); }
2.5 好有关注页分页查询 在个人主页的“关注”卡片中,查询并展示推送的Blog信息:
正序:
倒序:
带分数:
滚动分页规律:
1 2 3 4 ZREVRANGEBYSCORE key 设定一个最大值 0 WITHSCORES LIMIT 0 每页展示几条 ZREVRANGEBYSCORE key 第一条最小的角标 0 WITHSCORES LIMIT 第一页中与最小值相等的元素的个数 每页展示几条
2.5.1 新增实体类 1 2 3 4 5 6 @Data public class ScrollResult { private List<?> list; private Long minTime; private Integer offset; }
1 2 3 4 5 @GetMapping("/of/follow") public Result queryBlogOfFollow ( @RequestParam("lastId") Long max, @RequestParam(value = "offset", defaultValue = "0") Integer offset) { return blogService.queryBlogOfFollow(max, offset); }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 @Override public Result queryBlogOfFollow (Long max, Integer offset) { Long userId = UserHolder.getUser().getId(); String key = RedisConstants.FEED_KEY + userId; Set<ZSetOperations.TypedTuple<String>> set = stringRedisTemplate .opsForZSet() .reverseRangeByScoreWithScores(key, 0 , max, offset, 2L ); if (null == set || set.isEmpty()) { return Result.ok(); } ArrayList<Long> ids = new ArrayList <>(set.size()); long minTime = Long.MAX_VALUE; int off = 1 ; for (ZSetOperations.TypedTuple<String> tuple : set) { ids.add(Long.valueOf(tuple.getValue())); long time = tuple.getScore().longValue(); if (time == minTime) { off++; } else { minTime = minTime < tuple.getScore().longValue() ? minTime : time; off = 1 ; } } String joinStr = StrUtil.join("," , ids); List<Blog> blogs = query().in("id" , ids).last("order by field(id," + joinStr + ")" ).list(); for (Blog blog : blogs) { queryBlogUser(blog); isBlogLiked(blog); } ScrollResult result = new ScrollResult (); result.setList(blogs); result.setOffset(off); result.setMinTime(minTime); return Result.ok(result); }