黑马点评自学04

devtools/2025/2/22 5:29:01/

实战篇

针对黑马点评项目的一些功能编写。

探店功能

本功能主要实现用户对店铺的评价功能,也可以称为探店笔记

主要涉及到两个表:

  • tb_blo:探店笔记表,包含笔记中的标题、文字、图片等
  • tb_blog_comments:其他用户对探店笔记的评价(即其他用户可以评价该笔记)

项目原型:

点击+号进入编写笔记页面

主要涉及到两个接口:

  • 文件上传接口:主要用于上传图片,不仅可以在这里使用,本项目的所有图片上传都可以使用
  • 笔记发布接口:在编写完笔记后,将笔记写入到数据库中

文件上传接口

接口设计:

请求路径:http://localhost:8080/api/blog

携带参数:MultipartFile格式的图片信息

返回:文件名,即文件访问路径

代码开发:

UploadController 中的uploadImage方法实现上传文件

SystemConstants.IMAGE_UPLOAD_DIR为图片的本地存储路径,这里我们指定图片上传到前端服务器上,以便前端直接能够调用。路径改为:本地前端防止位置的:hm_dp\nginx-1.18.0\html\hmdp\imgs目录下

java">    public static final String IMAGE_UPLOAD_DIR = "D:\\Code\\Java\\Object\\hm_dp\\nginx-1.18.0\\html\\hmdp\\imgs\\";
java">@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);}}
}

图片文件名生成方法:用于图片上传

java">    private String createNewFileName(String originalFilename) {// 获取后缀String suffix = StrUtil.subAfter(originalFilename, ".", true);// 生成目录String name = UUID.randomUUID().toString();int hash = name.hashCode();int d1 = hash & 0xF;int d2 = (hash >> 4) & 0xF;// 判断目录是否存在File dir = new File(SystemConstants.IMAGE_UPLOAD_DIR, StrUtil.format("/blogs/{}/{}", d1, d2));if (!dir.exists()) {dir.mkdirs();}// 生成文件名return StrUtil.format("/blogs/{}/{}/{}.{}", d1, d2, name, suffix);}
功能测试:

成功上传图片到本地文件夹下

探店笔记上传接口

接口设计:

请求路径:api/blog

请求携带参数:店铺id,笔记名称,上传的图片路径,笔记内容

 无返回值 。

代码开发: 

直接在Controller层实现保存笔记功能,需要将当前用户信息存入Blog对象中

java">    @PostMappingpublic Result saveBlog(@RequestBody Blog blog) {// 获取登录用户UserDTO user = UserHolder.getUser();blog.setUserId(user.getId());// 保存探店博文blogService.save(blog);// 返回idreturn Result.ok(blog.getId());}
功能测试:

成功上传笔记。

 根据笔记Id查询笔记信息

接口设计:

代码开发:

Controller层:

接收前端的blogID,调用Service层的查询方法

java">    @GetMapping("/{id}")public Result QueryBlogBuId(@PathVariable("id") Long id) {return blogService.queryBlogById(id);}

Service层:

根据BlogID查询对应的Blog信息,并根据用户id查询必要的用户信息封装到Blog中返回

java">    @Overridepublic Result queryBlogById(Long id) {// 1. 根据id查询Blog信息Blog blog = getById(id);// 2. 获取Blog对应的用户信息queryBlogUser(blog);// 返回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());}
功能测试:

成功查看笔记详情

用户点赞Blog功能

接口设计

请求路径:api/blog/like/{id}

请求参数:blog的id

无返回值

在目前的代码中,简单编写了对blog进行点赞的功能,但有缺陷,未记录已点赞用户,导致用户能够重复进行点赞。

之前的代码:

java">    @PutMapping("/like/{id}")public Result likeBlog(@PathVariable("id") Long id) {// 修改点赞数量blogService.update().setSql("liked = liked + 1").eq("id", id).update();return Result.ok();}

 基于此,我们提出下面的需求:

  • 同一个用户只能点赞一次,当已点赞时再次点赞会取消点赞
  • 如果当前用户已经点赞,则点赞按钮会高亮以提示用户已点赞(前端已实现,只需要在Blog类中增加isLike属性)

修改步骤:

  1. 给Blog类添加isLike字段,用于标记blog是否被当前用户点赞
  2. 修改点赞功能,利用Redis的set集合来标记用户是否点赞,并实现点赞功能
  3. 修改根据id查询Blog信息的功能,需要给isLike字段赋值
  4. 修改分页查询Blog的业务,同样需要对isLike字段赋值

给Blog类添加isLike字段

在Blog类中添加下面的字段即可,@TableFiled表示是否为数据库字段

java">    /*** 是否点赞过了*/@TableField(exist = false)private Boolean isLike;

修改点赞功能

Controller层:不实现业务,只调用Service层即可

java">    @PutMapping("/like/{id}")public Result likeBlog(@PathVariable("id") Long id) {return blogService.likeBlogById(id);}

Service层:

根据在Redis中查询的结果来判断当前用户是否点赞,然后进行点赞和取消点赞操作。

java">    @Overridepublic Result likeBlogById(Long id) {// 1. 获取当前用户Long userId = UserHolder.getUser().getId();// 2. 判断当前用户是否已经点赞String key = RedisConstants.BLOG_LIKED_KEY + id;Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString());// 3. 如果未点赞,进行点赞操作if (BooleanUtil.isFalse(isMember)) {// 3.1 数据库点赞数+1boolean success = update().setSql("liked = liked + 1").eq("id", id).update();if (success) {// 点赞成功// 3.2 保存用户id到Redis中,表示用户已经点赞stringRedisTemplate.opsForSet().add(key, userId.toString());}else {log.error("点赞失败");}}else {// 4. 如果已点赞,取消点赞// 4.1 数据库点赞数-1boolean success = update().setSql("liked = liked - 1").eq("id", id).update();if (success) {// 4.2 从Redis中移除用户id,表示用户取消点赞stringRedisTemplate.opsForSet().remove(key, userId.toString());}else {log.error("取消点赞失败");}}return Result.ok();}

增加isLike字段赋值操作,根据笔记id为isLike字段赋值

java">    private void isBlogLiked(Blog blog) {// 1. 获取当前用户Long userId = UserHolder.getUser().getId();// 2. 判断当前用户是否已经点赞String key = RedisConstants.BLOG_LIKED_KEY + blog.getId();Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString());blog.setIsLike(BooleanUtil.isTrue(isMember));}

 queryBlogById和queryHotBlog方法调用该方法即可。

注意:这里的代码有Bug,当用户未登录时,获取不到用户,或造成查询用户是否点赞有空指针异常,因此这里需要进行修改。

我们获取用户id前先查询用户是否登录,即UserHolder中是否存在用户,不存在则直接结束(未登录则未点赞),存在才进行查询。

java">    private void isBlogLiked(Blog blog) {// 判断当前用户是否登录,未登录不需要查询是否点赞UserDTO user = UserHolder.getUser();if (user == null) {return;}// 1. 获取当前用户Long userId = UserHolder.getUser().getId();// 2. 判断当前用户是否已经点赞String key = RedisConstants.BLOG_LIKED_KEY + blog.getId();Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());blog.setIsLike(BooleanUtil.isTrue(score !=null));}
功能测试:

成功实现用户只能点赞一次和点赞提示功能

该功能有一个缺陷:如果Redis中的数据被删除,但数据库中的点赞数不变,还是能够同一个用户多次点赞。

点赞排行榜功能

对于用户发表的笔记,我们实现一个点赞用户排行榜功能,展示前几个先点赞的用户基本信息

接口设计:

修改之前的点赞功能

在之前的点赞功能中,我们使用set数据结构来存储点赞用户,但由于set不支持 排序,因此查找前几名点赞用户较麻烦,而Redis中的zset数据结构,能够实现排序,我们可以通过将时间戳作为分数对用户id进行排序,从而实现前几个用户信息的查询。下面对之前的点赞代码进行改造。

选择Zset结果进行数据存储,使用score方法,通过查询对应value值的分数来判断数据是否存在,点赞时将时间戳作为分数进行存储。

java">    @Override@Transactionalpublic Result likeBlogById(Long id) {// 1. 获取当前用户Long userId = UserHolder.getUser().getId();// 2. 判断当前用户是否已经点赞String key = RedisConstants.BLOG_LIKED_KEY + id;Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());// 3. 如果未点赞,进行点赞操作if (score == null) {// 3.1 数据库点赞数+1boolean success = update().setSql("liked = liked + 1").eq("id", id).update();if (success) {// 点赞成功// 3.2 保存用户id到Redis中,表示用户已经点赞,保存时间戳作为分数stringRedisTemplate.opsForZSet().add(key, userId.toString(), System.currentTimeMillis());}else {log.error("点赞失败");}}else {// 4. 如果已点赞,取消点赞// 4.1 数据库点赞数-1boolean success = update().setSql("liked = liked - 1").eq("id", id).update();if (success) {// 4.2 从Redis中移除用户id,表示用户取消点赞stringRedisTemplate.opsForZSet().remove(key, userId.toString());}else {log.error("取消点赞失败");}}return Result.ok();}

isBlogLiked方法做相应的修改。

java">    private void isBlogLiked(Blog blog) {// 1. 获取当前用户Long userId = UserHolder.getUser().getId();// 2. 判断当前用户是否已经点赞String key = RedisConstants.BLOG_LIKED_KEY + blog.getId();Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());blog.setIsLike(BooleanUtil.isTrue(score ==null));}

测试:同样能够实现点赞功能

点赞排行榜代码:

Controller层:

获取前端传入的笔记id,然后执行对应的方法

java">    @GetMapping("/likes/{id}")public Result queryBlogLikesById(@PathVariable("id") Long id) {return blogService.queryBlogLikesById(id);}

Service层:

根据key查询前5个点赞用户id,查询不到则直接返回,查询到了需要将id解析为Long型的列表

通过将id组合成字符串用于SQl查询

注意,这里的SQL语句要自己拼接一部分,直接使用listByIds方法,查询的结果会按照数据库自动排序,而不是我们希望的顺序,因此通过last拼接最后一条SQL语句,来保证结果与isStr的顺序相同。

java">    @Overridepublic Result queryBlogLikesById(Long id) {String key = RedisConstants.BLOG_LIKED_KEY + id;//1. 查询top5的点赞用户信息Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);if (top5 == null || top5.isEmpty()) {// 没人点赞return Result.ok();}// 2. 解析出其中的用户idList<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());// 3. 将用户ids列表连接成字符串,用于后面SQL语句String idStr = StrUtil.join(",", ids);// 4. 根据id列表查询用户信息,并将User封装为UserDTO返回List<UserDTO> userDTOS = userService.query().in("id", ids)// 根据id查询用户信息.last("ORDER BY FIELD (id," + idStr + ")")// 根据id列表进行排序,保证得到结果跟传入的id顺序一致.list().stream().map(user -> BeanUtil.copyProperties(user, UserDTO.class))// 转换User为UserDTO.collect(Collectors.toList());return Result.ok(userDTOS);}

测试:

成功实现前5个点赞用户信息展示,这里显示7人点赞是因为之前数据库中存在点赞数据,但在Redis中未建立。

 好友关注功能

关注和取关

基于该产品原型,我们需要实现两个功能:

  • 关注和取关功能
  • 判断是否关注功能

关注和取关功能

接口设计:

请求路径:api/follow/{id}/{true|false}

携带参数:用户id和关注还是取关操作

返回值:无返回值

代码开发:

Controller层:

接收前端传入的用户id和关注或取关操作,调用Service层的方法进行相应的操作

java">    /*** 根据用户id关注或取关用户* @param followUserId* @param isFollow* @return*/@PutMapping("/{id}/{isFollow}")public Result followById(@PathVariable("id") Long followUserId, @PathVariable("isFollow") Boolean isFollow) {return followService.followById(followUserId, isFollow);}

Service层:

这里提前判断一下用户未登录的情况,防止出现空指针异常,然后根据isFollow的值进行相应的关注和取关操作。

java">    /*** 根据用户id关注或取关用户* @param followUserId* @param isFollow* @return*/@Overridepublic Result followById(Long followUserId, Boolean isFollow) {// 防止未登录,出现空指针异常UserDTO user = UserHolder.getUser();if (user == null) {return Result.ok();}// 1. 查询当前登录用户Long userId = UserHolder.getUser().getId();// 2. 根据操作为取关还是关注进行相应的操作if (isFollow) {// 2.1 关注操作,往follow表中新增数据Follow follow = new Follow();follow.setUserId(userId);follow.setFollowUserId(followUserId);save(follow);}else {// 2.2 取关操作,删除follow表中的数据remove(new QueryWrapper<Follow>().eq("user_id", userId).eq("follow_user_id", followUserId));}return Result.ok();}

查询关注状态功能

接口设计:

请求路径:api/follow/or/not/{id}

携带参数:路径传入id

返回参数:返回true或false

代码开发:

Controller层:

java">    /*** 根据id判断是否关注* @param followUserId* @return*/@GetMapping("/or/not/{id}")public Result isFollow(@PathVariable("id") Long followUserId) {return followService.isFollow(followUserId);}

Service层:

同样进行未登录判定,然后查询当前对应的信息

java">    /*** 根据id查询关注状态* @param followUserId* @return*/@Overridepublic Result isFollow(Long followUserId) {// 防止未登录,出现空指针异常UserDTO user = UserHolder.getUser();if (user == null) {return Result.ok();}// 1. 查询当前登录用户Long userId = UserHolder.getUser().getId();// 2. 查询是否关注Integer count = query().eq("user_id", userId).eq("follow_user_id", followUserId).count();// 3. 返回结果return Result.ok(count > 0);}
功能测试

成功实现关注和取关功能,也能查询关注状态。

点击查询用户信息 

需求分析:

点击对应的用户,需要返回用户基本信息和笔记信息,因此需要两个接口:

查询用户信息和查询用户笔记两个

查询用户信息功能

接口设计:

请求路径:api/user/{id}

请求携带参数:路径传参用户id

返回数据:将用户信息封装为UserDTO返回

代码开发:

Controller层:

java">    /*** 根据id查询用户信息* @param userId* @return*/@GetMapping("/{id}")public Result queryUserById(@PathVariable("id") Long userId) {return userService.queryUserById(userId);}

Service层:

java">    /*** 根据id查询用户信息* @param userId* @return*/@Overridepublic Result queryUserById(Long userId) {// 1. 查询用户信息User user = getById(userId);if (user == null) {// 没有用户,直接返回return Result.ok();}UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);return Result.ok(userDTO);}

查询用户笔记功能

接口设计:

请求路径:api/blog/of/user?&id=2&current=1

请求参数:URL传参,传入用户id和分页数(第几页)

返回数据:返回笔记信息的List列表

代码开发

Controller层:

@RequestParam接收URL传参,调用Service层方法

java">    /*** 分页查询用户的博客* @param current* @param id* @return*/@GetMapping("of/user")public Result queryBlogByUserId(@RequestParam(value = "current", defaultValue = "1") Integer current,@RequestParam("id") Long id) {return blogService.queryBlogByUserId(current, id);}

Service层:

分页查询用户笔记信息并封装返回

java">    /*** 查询用户的博客信息* @param current* @param id* @return*/@Overridepublic Result queryBlogByUserId(Integer current, Long id) {// 根据用户查询博客信息Page<Blog> page = query().eq("user_id", id).page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));// 获取当前页数据List<Blog> records = page.getRecords();return Result.ok(records);}
功能测试

成功通过用户头像进入用户信息详情。

共同关注功能

对于共同关注功能,我们需要得到两个用户的关注列表,因次我们用Redis的set结构来存储用户的关注列表,它具有求交集的功能。首先对之前的关注功能进行修改,在关注成功和失败时对Redis进行相应的操作。

修改之前的关注功能

根据关注和取关操作的结果,进行相应的Redis操作。

java">    /*** 根据用户id关注或取关用户* @param followUserId* @param isFollow* @return*/@Overridepublic Result followById(Long followUserId, Boolean isFollow) {// 防止未登录,出现空指针异常UserDTO user = UserHolder.getUser();if (user == null) {return Result.ok();}// 1. 查询当前登录用户Long userId = UserHolder.getUser().getId();// 2. 根据操作为取关还是关注进行相应的操作String key = RedisConstants.USER_FOLLOWS_KEY + userId; // 用户关注列表keyif (isFollow) {// 2.1 关注操作,往follow表中新增数据Follow follow = new Follow();follow.setUserId(userId);follow.setFollowUserId(followUserId);boolean success = save(follow);if (success) {// 关注成功,将用户id保存到redis中,表示用户已经关注stringRedisTemplate.opsForSet().add(key, followUserId.toString());}}else {// 2.2 取关操作,删除follow表中的数据boolean success = remove(new QueryWrapper<Follow>().eq("user_id", userId).eq("follow_user_id", followUserId));if (success) {// 取关成功,从redis中移除用户id,表示用户已经取关stringRedisTemplate.opsForSet().remove(key, followUserId.toString());}}return Result.ok();}
共同关注功能实现接口设计

请求路径:api/follow/common/{id}

携带参数:路径传参传入用户id

返回数据:返回共同关注的用户列表

代码开发:

Controller层:

接受前端传入的用户id,调用Service方法

java">    /*** 根据id查询共同关注用户* @param id* @return*/@GetMapping("/common/{id}")public Result followCommons(@PathVariable("id") Long id) {return followService.followCommons(id);}

Service层:

使用Redis的set查询共同关注好友id,然后查询对应的信息封装返回

java">    /*** 根据id查询共同关注用户* @param id* @return*/@Overridepublic Result followCommons(Long id) {if (UserHolder.getUser() == null){// 防止未登录空指针异常return Result.ok(Collections.emptyList());}// 1. 查询当前登录用户Long userId = UserHolder.getUser().getId();// 2. 查询共同关注String key1 = RedisConstants.USER_FOLLOWS_KEY + userId;String key2 = RedisConstants.USER_FOLLOWS_KEY + id;// 2.1 获取两个集合的交集Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key1, key2);if (intersect == null || intersect.isEmpty()) {// 没有共同关注return Result.ok(Collections.emptyList());}// 2.2 将Set集合转为List集合,即用户id集合List<Long> ids = intersect.stream().map(Long::valueOf).collect(Collectors.toList());// 3. 查询用户信息List<UserDTO> userDTOS = userService.listByIds(ids).stream().map(user -> BeanUtil.copyProperties(user, UserDTO.class)).collect(Collectors.toList());return Result.ok(userDTOS);}
功能测试:

在Redis中成功保存关注用户id

 成功查询得到共同关注用户信息

关注推送功能

我们需要对用户关注的用户笔记进行推送

 关注推送也叫做Feed流,直译为投喂,它为用户持续提供沉浸式的体验。

主要有两种模式:

传统模式是用户去寻找内容,通常用于对关注的用户的内容进行推送,如微信朋友圈11

另一种模式是Feed模式,它是内容去匹配可能需要的用户,也就是现在的大数据推送。它不需要用户关注当前内容就可推送看见。

Feed流的实现方案

方案1 :拉模式(读扩散)

即发送方把发送的内容放到一个发件箱中,接收方在需要的时候去发件箱中获取信息放入收件箱中展示,展示完以后删除收件箱。

好处是:占用内存较少

缺陷是:接收方每次都要去发件箱获取信息,耗时较长

方案2:推模式(写扩散)

即发送方直接将消息发送到接受方的收件箱中,接收方可随时查看

优点:接收方不需要额外查询,速度较快

缺点:内存浪费,当接收方过多时,发送占用空间较大,且许多接收方不会查看消息,因此造成资源的浪费。

 方案3:推拉结合模式(读写混合)

通过将推拉结合,对于接收方较多的发送发,采用拉模式,发送到收件箱中,用户使用时去发件箱中获取。对于活跃的接受方或者接收方较少的发送方,在发送时采用推模式,将信息发送到收件箱中。

 

Feed流产品常见模式

  • Timeline:不做内容筛选,简单的按照内容的发布顺序进行排序,常用于好友或关注,如微信朋友圈,QQ空间
    • 优点:信息全面,不会有缺失,实现相对简单
    • 缺点:信息噪音较多,用户不一定感兴趣,有用内容的获取效率较低
  • 智能排序:利用智能算法屏蔽掉违规的、用户不感兴趣的内容,推送用户感兴趣的内容以吸引用户。
    • 优点:投喂用户感兴趣的信息,用户粘度较高,收益较高
    • 缺点:如果算法不精准可能带来反效果,即对算法精准度要求较高

推送消息到粉丝收件箱

对于关注推动功能,我们需要完成下面的需求:

  • 修改新增探店笔记额业务,在保存blog到数据库的同时,推送到粉丝的收件箱中
  • 使用Redis实现收件箱功能,要求满足按照时间戳排序
  • 查询收件箱数据时,可以实现分页查询

对Redis中的数据结构分析,发现Lis和Zset可以实现分页查询功能,但由于Feed流中的数据会不断地更新,如果使用List结构,由于数据的角标在不断的更新,可能出现重读的情况,即

因此我们采用Zset数据结构作为收件箱,然后在分页的时候,记录上一次读取数据的位置,保证下一次读取从上一次读取数据之后开始读取,从而防止重复读取的情况。

修改新增探店笔记业务

修改之前的新增笔记业务

Controller层:

java">    /*** 新建Blog笔记* @param blog* @return*/@PostMappingpublic Result saveBlog(@RequestBody Blog blog) {// 返回idreturn blogService.saveBlog(blog);}

 Service层:

在新增笔记完成后,将笔记ID发送给所有笔记发布用户的粉丝

这里的时间戳,教程是在发送每个粉丝收件箱是给定,我觉得应该保证每个笔记在粉丝那发送时间一直,因此使用的同一个时间戳。

java">    /*** 新增探店笔记* @param blog* @return*/@Overridepublic Result saveBlog(Blog blog) {// 1.获取登录用户UserDTO user = UserHolder.getUser();blog.setUserId(user.getId());// 2.保存探店博文boolean isSuccess = save(blog);// 根据新增成功与否进行下一步if (!isSuccess) {// 3. 失败,返回错误信息return Result.fail("新增笔记失败");}// 记录当前时间戳long currentTimeMillis = System.currentTimeMillis();// 新增成功,推送笔记id给所有粉丝// 4.查询当前用户粉丝List<Follow> follows = followService.query().eq("follow_user_id", user.getId()).list();if (follows == null || follows.isEmpty()) {// 没有粉丝,直接返回return Result.ok(blog.getId());}// 5. 推送笔记id给所有的粉丝for (Follow follow : follows) {// 5.1 获取粉丝idLong userId = follow.getUserId();// 5.2 推送String key = RedisConstants.FEED_KEY + userId;// 这里我认为同一个blog在不同收件箱中的时间戳是否应该为同一个,表示发布时间一致stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(), currentTimeMillis);}// 返回idreturn Result.ok(blog.getId());}

测试:成功在新增笔记时发送笔记id到粉丝收件箱中

分页查询收件箱数据

滚动分页查询

由Feed流会不断的更新收件箱中的消息,因此,我们不能使用传统的分页查询模式,需要使用滚动分页查询的方式,即Zset结构的ZREVRANGEBYSCORE方法来实现score从大到小排序

ZREVRANGEBYSCORE参数介绍:

  • max:查询开始位置,通常为当前时间戳(无上一次时间戳输入)|上一次查询的最小时间戳
  • min:0,最小时间戳,不需要管,靠max和count来管理记录个数
  • offset:偏移量,0表示从max开始|从上一次的结果中,与最小值一样的元素的个数,保证不会漏查和重复查
  • count:需要查询的数据量
接口设计

注意返回值:需要本次的返回值用于下一次的数据查询

代码开发:

Controller层:

获取前端传入的开始时间戳和偏移量,偏移量不传时默认为0

java">    /*** 分页查询关注用户笔记* @param max* @param offset* @return*/@GetMapping("/of/follow")public Result queryBlogOfFollow(@RequestParam("lastId") Long max, @RequestParam(value = "offset", defaultValue = "0") Integer offset){return blogService.queryBlogOfFollow(max,offset);}

Service层:

使用Zset的reverseRangeByScoreWithScores查询收件箱中的信息

通过解析查询的数据,得到blogId,minTine(时间戳),offset,然后查询对应的Blog信息,最后进行数据封装并返回。

java">    /*** 分页查询用户的关注者的博客信息* @param max* @param offset* @return*/@Overridepublic Result queryBlogOfFollow(Long max, Integer offset) {if (UserHolder.getUser() == null) return Result.fail("请先登录");// 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, 2);// 3. 非空判断if (typedTuples == null || typedTuples.isEmpty()) {return Result.ok();}// 4. 解析查询的数据,得到blogId,minTine(时间戳),offsetList<Long> ids = new ArrayList<>(typedTuples.size());// 设置大小,避免扩容影响系统性能long minTine  = 0; // 记录本次最小时间戳int os = 1; // 记录最小时间戳数据个数,即下一次的偏移量for (ZSetOperations.TypedTuple<String> typedTuple : typedTuples) {// 4.1 获取blog的idids.add(Long.valueOf(typedTuple.getValue()));// 4.2 获取分数long time = typedTuple.getScore().longValue();// 记录最小分数和个数if (minTine == time) {os++;}else {// 更新最小分数minTine = time;os = 1;}}// 5. 根据blog的id查询详细的信息String idStr = StrUtil.join(",", ids);List<Blog> blogs = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();for (Blog blog : blogs) {// 5.1 查询blog的用户心胸queryBlogUser(blog);// 5.2 查询blog的是否被点赞isBlogLiked(blog);}// 6. 封装数据并返回ScrollResult r = new ScrollResult();r.setList(blogs);r.setOffset(os);r.setMinTime(minTine);return Result.ok(r);}
功能测试:

成功实现分页查询收件箱信息。

附近店铺功能

GEO介绍

 导入店铺经纬度信息到Redis中

因为我们使用Redis的数据结构;来实现地理位置的查询,因此我们需要先将店铺的经纬度信息保存在Redis中,以便后续使用。

这里我们将每一个Shop写入一次Redis改成每一个店铺类型写入一次Redis,减少了对Redis的访问次数,提高整体效率。

java">    /*** 将店铺经纬度信息上传到Redis中*/@Testvoid loadShopData(){// 1. 查询店铺信息List<Shop> list = shopService.list();// 2. 对店铺信息按照typeId分组,将同一个类型的店铺放在同一个集合中Map<Long, List<Shop>> map = list.stream().collect(Collectors.groupingBy(Shop::getTypeId));// 3. 分组写入到Redis中for (Map.Entry<Long, List<Shop>> entry : map.entrySet()) {// 3.1 获取类型idLong typeId = entry.getKey();//3.2 获取同类型店铺集合List<Shop> value = entry.getValue();String key = RedisConstants.SHOP_GEO_KEY + typeId;List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(value.size());// 3.3 把店铺信息转换为RedisGeoCommands.GeoLocation对象集合for (Shop shop : value) {locations.add(new RedisGeoCommands.GeoLocation<>(shop.getId().toString(),new Point(shop.getX(), shop.getY())));}// 3.4 将当前类型shop集合写入Redis中,每种类型只写入一次stringRedisTemplate.opsForGeo().add(key, locations);
//            // 3.3 写入Redis
//            for (Shop shop : value) {
//                // 传统的每次写入一个shop信息,对系统负担大
//                stringRedisTemplate.opsForGeo()
//                        .add(key, new Point(shop.getX(), shop.getY()), shop.getId().toString());
//            }}}

实现附近商铺功

接口设计

请求路径:api/shop/type

携带参数:typeId、current,经纬度想x,y(经纬度可能不传)

代码实现:

Controller层:

添加接收经纬度的参数,但不是必须的

java">    /*** 根据商铺类型分页查询商铺信息* @param typeId 商铺类型* @param current 页码* @param x 经度 不可不要* @param y 纬度 不可不要* @return 商铺列表*/@GetMapping("/of/type")public Result queryShopByType(@RequestParam("typeId") Integer typeId,@RequestParam(value = "current", defaultValue = "1") Integer current,@RequestParam(value = "x", required = false) Double x,@RequestParam(value = "y", required = false) Double y) {return shopService.queryShopByType(typeId, current, x, y);}

Service层:

根据经纬度是否传入,来判断是否需要根据位置查询,未传入则直接在数据库中分页查询即可

这里注意,由于geo的search功能是在Redis6.x之后才有的,所以本功能实现需要对Redis进行升级操作才能完成。

java">    /*** 根据店铺类型和经纬度分页查询店铺信息* @param typeId* @param current* @param x* @param y* @return*/@Overridepublic Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {// 1. 判断需不需要经纬度查询if (x == null || y == null) {// 不传入经纬度时直接根据店铺类型查询Page<Shop> page = query().eq("type_id", typeId).page(new Page<>(current, SystemConstants.DEFAULT_PAGE_SIZE));// 返回数据return Result.ok(page.getRecords());}// 2. 计算分页的参数int from = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE;int end = current * SystemConstants.DEFAULT_PAGE_SIZE;// 3. 查询redis、按照距离排序、分页。结果:shopId、distanceString key = RedisConstants.SHOP_GEO_KEY + typeId;GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo().search(key, GeoReference.fromCoordinate(x, y),// 根据经纬度查询new Distance(5000),// 半径范围,以米为单位,可以传入单位RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance().limit(end));// 这里limit是分页查询从0到end,后续需要自己调整// 判空if (results == null) {return Result.ok(Collections.emptyList());}// 4. 解析店铺idList<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();if(list.size() <= from) {// 没有下一页了,结束return Result.ok(Collections.emptyList());}// 4.1 截取form到end的数据List<Long> ids = new ArrayList<>(list.size());Map<String, Distance> distanceMap = new HashMap<>(list.size());list.stream().skip(from).forEach(result -> {// skip会跳过form之前的数据// 4.2 获取店铺idString shopIdStr = result.getContent().getName();ids.add(Long.valueOf(shopIdStr));// 4.3 获取距离Distance distance = result.getDistance();distanceMap.put(shopIdStr, distance);});// 5. 根据id查询shop信息String idStr = StrUtil.join(",", ids);List<Shop> shops = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();for (Shop shop : shops) {// 设置每个店铺的距离信息shop.setDistance(distanceMap.get(shop.getId().toString()).getValue());}return Result.ok(shops);}

功能测试:

成功根据位置查询得到附近商铺。

用户签到功能

BitMap(位图)用法

通过位图的方式,能够在记录用户签到情况的同时,大大降低内存消耗。

Redis中利用String类型数据结构实现BitMap,但它的命令是用BitMap的新命令

一些常见的命令如下:

实现用户签到

接口设计:

这里无请求参数是因为可以直接在后台获取当前登录用户

代码开发:

Controller层:

java">    /*** 用户当日签到功能* @return*/@PostMapping("/sign")public Result sign(){return userService.sign();}

 Service层:

这里增加对用户未登录的判断和重复签到的判定。

java">    /*** 用户当日签到功能* @return*/@Overridepublic Result sign() {// 1. 查询当前登录用户if (UserHolder.getUser() == null){// 防止未登录return Result.fail("请先登录");}Long userId = UserHolder.getUser().getId();// 2. 获取日期LocalDateTime now = LocalDateTime.now();// 3. 拼接keyString keySuffix = now.format(DateTimeFormatter.ofPattern("yyyyMM"));// 某年某月String key = RedisConstants.USER_SIGN_KEY + userId + keySuffix;// 4. 获取今天是本月第几天int day = now.getDayOfMonth();// 5. 判断今天是否已经签到Boolean isSign = stringRedisTemplate.opsForValue().getBit(key, day - 1);if (isSign) {// 已签到return Result.fail("请勿重复签到");}// 6. 未签到,写入RedisstringRedisTemplate.opsForValue().setBit(key, day-1, true);return Result.ok();}
功能测试

成功实现签到功能

 连续签到功能

接口设计

代码开发

Controller层:

java">    /*** 连续签到次数统计* @return*/@GetMapping("/sign/count")public Result signCount(){return userService.signCount();}

Service层:

按照正常的逻辑来说,当天如果签到了,我们需要算到连续签到,如果当天还没有签到,但前一天签到了,应该从前一天开始统计连续签到次数,而不是当前没有签到就断签。

java">    /*** 连续签到次数统计* @return*/@Overridepublic Result signCount() {// 1. 查询当前登录用户if (UserHolder.getUser() == null){// 防止未登录return Result.fail("请先登录");}Long userId = UserHolder.getUser().getId();// 2. 获取日期LocalDateTime now = LocalDateTime.now();// 3. 拼接keyString keySuffix = now.format(DateTimeFormatter.ofPattern("yyyyMM"));// 某年某月String key = RedisConstants.USER_SIGN_KEY + userId + keySuffix;// 4. 获取今天是本月第几天int day = now.getDayOfMonth();// 5. 判断今天是否已经签到Boolean isSign = stringRedisTemplate.opsForValue().getBit(key, day - 1);// 6.获取本月截止到今天为止的所有签到记录,返回的是一个十进制得到数字List<Long> results = stringRedisTemplate.opsForValue().bitField(key, BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType.unsigned(day)).valueAt(0));if (results == null || results.isEmpty()) {return Result.ok(0);}// 获取签到的次数Long num = results.get(0);if (num == null || num == 0) {return Result.ok(0);}// 7. 循环遍历,获取连续签到次数int count = 0;if (!isSign) {// 如果今天未签到,不应该考虑今天,右移一位从前一天开始统计num >>>= 1;}while (true) {// 7.1 让num与1做与运算,得到数字的最后一个bit位if ((num & 1) == 0) {// 不是1,说明未签到,结束break;}else {// 不为0,说明签到,计数器加一count++;}// 7.2 将数字右移一位,相当于num/2,抛弃最后一个bit位,继续下一个bit位num >>>= 1;}return Result.ok(count);}
功能测试

测试了当天签到,前一天也签到的情况,当前未签到但前一天签到了的情况,当前未签到且前一天也未签到的情况。都成功拿到了理想的结果。

 UV统计

UV:全称为Unique Visitor,也叫独立访问量,是指互联网访问。浏览这个网页的自然人,一天内同一个用户多次访问该网站,只记录一次。

PV:全称为Page View,也叫页面访问量或点击量,用户每访网站的一个页面,则记录1次PV,用户多次打开页面,则记录多次PV。往往被用来衡量网站的流量。

UV统计在服务端做会比较麻烦,因为需要判断用户是否已经通过了,需将统计过的用户信息保存,但如果每访问的用户都信息都保存在Redis中,造成的数据集量会非常恐怖。因此HLL算法出现了。

HyperLogLog

HyperLogLog(HLL)是从LogLog算法派生出的概率算法,用户确定非常大的集合的基数,而不需要存储其所有的值。

Redis中的HLL是基于String结构实现的,他能保证单个HLL存储的内存永远小于16KB,当然,作为代价,其测量的结果时概率性的,有哦小于0.81%的误差,但=对于大部分UV统计来说,这完成可以忽略。

测试百万数据的UV

通过向Redis中插入HLL的百万的数据,测试一下HLL算法的存储代价和精确度。

记录目前的Redis内存消耗:575724

测试代码:

java">    @Testvoid testHyperLogLog(){// 准备数组,装测试数据String[] values = new String[1000];// 数组下标int index = 0;for (int i = 0; i < 1000000; i++) {// 赋值values[index++] = "user_" + i;if (i % 1000 == 0) {// 每一千条发送一次index = 0;stringRedisTemplate.opsForHyperLogLog().add("hl1", values);}}// 统计数据Long size = stringRedisTemplate.opsForHyperLogLog().size("hl1");System.out.println("size=" + size);}

 测试结果:

只增加了十几k的数据

 十万数据得到九万九千多。


http://www.ppmy.cn/devtools/160870.html

相关文章

《鸿蒙开发-答案之书》获取视频第一帧和视频时间

《鸿蒙开发-答案之书》获取视频第一帧和视频时间 /*** 获取视频信息**let result await MySightUtil.getSightInfo(this.sightUri);*let base64 : string result[0];*let duration : number result[1]** param uri 视频地址* returns 第一个数据是缩略图 base64 字符串&…

python电影数据分析及可视化系统建设

博主介绍&#xff1a;✌程序猿徐师兄、8年大厂程序员经历。全网粉丝15w、csdn博客专家、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于Java技术领域和毕业项目实战✌ &#x1f345;文末获取源码联系&#x1f345; &#x1f447;&#x1f3fb; 精彩专栏推荐订阅&#x1f447;…

详细介绍Tess4J的使用:从PDF到图像的OCR技术实现

在当今的数字化时代&#xff0c;OCR&#xff08;光学字符识别&#xff09;技术被广泛应用于文档扫描、图片文字识别以及其他自动化数据提取任务。Tesseract作为一款强大的开源OCR引擎&#xff0c;在处理图像和PDF中的文本提取方面具有非常高的准确度和效率。本文将详细介绍如何…

JVM基础---java类加载机制(类的生命周期,类加载器,双亲委派模型)

文章目录 类的生命周期类的加载&#xff1a;查找并加载类的二进制数据验证准备解析初始化 类加载器启动类加载器&#xff08;Bootstrap ClassLoader&#xff09;扩展类加载器&#xff08;Extension ClassLoader&#xff09;应用程序类加载器&#xff08;Application ClassLoade…

DeepSeek助力:打造属于你的GPTs智能AI助手

文章目录 一、环境准备1.安装必要的工具和库2. 选择合适的开发语言 二、核心技术选型1. 选择适合的AI框架 三、功能实现1. 文本生成与对话交互2. 代码生成与自动补全3. 数据分析与报告生成 四、案例实战1. 搭建一个简单的聊天机器人2. 创建一个代码生成器 五、总结与展望1. 当前…

七星棋牌源码高阶技术指南:6端互通、200+子游戏玩法深度剖析与企业级搭建实战(完全开源)

在棋牌游戏行业高速发展的今天&#xff0c;如何构建一个具备高并发、强稳定性与多功能支持的棋牌游戏系统成为众多开发者和运营团队关注的焦点。七星棋牌全开源修复版源码 凭借其 六端互通、200子游戏玩法、多省区本地化支持&#xff0c;以及 乐豆系统、防沉迷、比赛场、AI智能…

C++笔记之标准库中的std::copy 和 std::assign 作用于 std::vector

C++笔记之标准库中的std::copy 和 std::assign 作用于 std::vector code review! 文章目录 C++笔记之标准库中的std::copy 和 std::assign 作用于 std::vector1. `std::copy`1.1.用法1.2.示例2.`std::vector::assign`2.1.用法2.2.示例3.区别总结4.支持assign的容器和不支持ass…

蓝桥杯 Java B 组 之堆的基础(优先队列实现 Top K 问题)

Day 6&#xff1a;堆的基础&#xff08;优先队列实现 Top K 问题&#xff09; &#x1f4d6; 一、什么是堆&#xff08;Heap&#xff09;&#xff1f; 堆&#xff08;Heap&#xff09; 是一种特殊的二叉树结构&#xff0c;满足&#xff1a; 最大堆&#xff08;Max Heap&#…