尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

黑马点评——探店达人模块

发布时间:2026/9/30 1:54:45

资讯中心
01
ARTICLE

黑马点评——探店达人模块

黑马点评——探店达人模块
黑马点评 · 达人探店模块学习笔记上一个模块是使用 redis 使用的消息队列这实际不怎么使用所以选择直接跳过了然后之后选择用rabbitMQ 来实现 JVM 带来的缺陷一、这一模块要解决什么问题前几模块的主题是登录态共享、缓存一致性、秒杀防超卖围绕的是读多写少和并发扣减。达人探店换了个方向内容型业务里的 UGC 和互动。要做三件事。发布探店笔记。笔记图文混排图片要存下来并且前端能访问到标题正文写进数据库。查看探店笔记。首页有按点赞数排序的热门列表点进去是详情页要显示作者的昵称和头像。点赞和点赞排行榜。同一个人对同一篇笔记只能点一次再点取消详情页按点赞时间展示最早点赞的 Top5已点赞的按钮要亮。第三件事是重点这个需求服务的既要一个人只算一次又要按点赞时间排序List 去不了重Set 排不了序最后落在 SortedSet 上拿时间戳当 score。整个模块的思路一句话点赞总数放数据库点赞名单放 Redis 的 ZSet。五、功能一发布探店笔记5.1 业务流程用户在首页点最下方的进入发笔记页面填标题、写正文、选照片。选完照片前端先调POST /upload/blog把图片传上来后端存盘并返回文件名。前端把图片文件名、标题、正文、关联商户一起通过POST /blog提交。后端从UserHolder取出当前登录用户的 id 填进blog.userId落库返回笔记 id。这里分成两个接口是有原因的图片可能有好几张而且是二进制跟表单字段混在一个请求里不好处理。前端先把图传上去拿到文件名再用普通的 JSON 请求提交文字内容后端两次处理都很简单。5.2 图片上传接口怎么写需求前端需要一个接口把文件传上来返回一个后端能识别的文件名。Spring MVC 怎么接文件参数写MultipartFile用RequestParam(file)绑定表单里的字段名。PostMapping(blog)publicResultuploadImage(RequestParam(file)MultipartFileimage){try{// 获取原始文件名称StringoriginalFilenameimage.getOriginalFilename();// 生成新文件名StringfileNamecreateNewFileName(originalFilename);// 保存文件image.transferTo(newFile(SystemConstants.IMAGE_UPLOAD_DIR,fileName));// 返回结果returnResult.ok(fileName);}catch(IOExceptione){thrownewRuntimeException(文件上传失败,e);}}四步拿原始文件名只是为了取后缀→ 生成新文件名 → 转存到磁盘 → 返回文件名。文件名怎么生成privateStringcreateNewFileName(StringoriginalFilename){// 获取后缀StringsuffixStrUtil.subAfter(originalFilename,.,true);// 生成目录StringnameUUID.randomUUID().toString();inthashname.hashCode();intd1hash0xF;intd2(hash4)0xF;// 判断目录是否存在FiledirnewFile(SystemConstants.IMAGE_UPLOAD_DIR,StrUtil.format(/blogs/{}/{},d1,d2));if(!dir.exists()){dir.mkdirs();}// 生成文件名returnStrUtil.format(/blogs/{}/{}/{}.{},d1,d2,name,suffix);}两个考虑用 UUID 重命名。如果直接用用户上传的名字两个人都传1.jpg就会互相覆盖。UUID 不重复也顺手避免了用户拿文件名做文章。拆两级子目录。hash 0xF取低 4 位(hash 4) 0xF取接下来 4 位合起来是低 8 位落在 0–15 之间两级就是 256 个目录。这样文件被摊开存放不会因为单个目录文件太多而影响查找。mkdirs()带上s表示父目录不存在时一起创建。保存到哪SystemConstants.IMAGE_UPLOAD_DIR。publicclassSystemConstants{publicstaticfinalStringIMAGE_UPLOAD_DIRD:\\lesson\\nginx-1.18.0\\html\\hmdp\\imgs\\;publicstaticfinalStringUSER_NICK_NAME_PREFIXuser_;publicstaticfinalintDEFAULT_PAGE_SIZE5;publicstaticfinalintMAX_PAGE_SIZE10;}这个值必须改成自己机器上 nginx 的静态资源目录。图片既不进数据库也不放项目的resources而是直接落到 nginx 的静态目录接口返回的/blogs/...路径前端拼上 nginx 地址就能访问。后端只负责写文件读文件交给 nginx省掉后端的 IO 压力。代价是后端和 nginx 得在同一台机器上多机部署要考虑共享存储。另外MvcConfig里/upload/**被登录拦截器放行了上传接口本身不做登录校验。5.3 保存笔记接口怎么写需求把前端提交的Blog存进tb_blog返回 id。PostMappingpublicResultsaveBlog(RequestBodyBlogblog){// 获取登录用户UserDTOuserUserHolder.getUser();blog.setUserId(user.getId());// 保存探店博文blogService.save(blog);// 返回idreturnResult.ok(blog.getId());}三个点前端传的是 JSON所以用RequestBody绑定到Blog对象。用户 id 不从请求体里取而是从UserHolder拿。UserHolder里的用户是登录拦截时从 Redis 的 token 里解析出来放进 ThreadLocal 的前端伪造不了。如果直接用blog.getUserId()任何人都能冒充别人发笔记。blogService.save(blog)是 MyBatis-Plus 的方法会生成一条INSERT INTO tb_blog (...) VALUES (...)。执行完之后 MyBatis-Plus 会把自增主键回填到blog.id所以紧接着blog.getId()就能拿到新 id返回给前端用于跳转详情页。说明这段是本章提交时的写法。学到第 07 章关注推送后saveBlog被搬进了BlogServiceImpl并在落库后多了一步往粉丝收件箱feed:{userId}推消息。看当前工作区代码时 Controller 里只剩return blogService.saveBlog(blog);那是后续章节的改动。六、功能二查看探店笔记6.1 先把前端要什么想清楚PPT 第 150 页的需求是点首页的探店笔记进入详情页实现该页面的查询接口。说明内容请求方式GET请求路径/blog/{id}请求参数idblog 的 id返回值Blog笔记信息包含用户信息关键在于包含用户信息这几个字。tb_blog里只有user_id没有昵称和头像而详情页要显示作者列表页也一样。所以后端返回的Blog必须比表字段多东西这就是 3.3 节那三个非表字段的由来。再加上按钮高亮的要求列表页和详情页都需要isLike。两个查询接口的差别只在于列表查一页详情查一条。6.2 首页热门列表 queryHotBlog 怎么写需求按点赞数从高到低分页每页返回笔记列表每条带上作者信息和点赞状态。OverridepublicResultqueryHotBlog(Integercurrent){// 根据用户查询PageBlogpagequery().orderByDesc(liked).page(newPage(current,SystemConstants.MAX_PAGE_SIZE));// 获取当前页数据ListBlogrecordspage.getRecords();// 查询用户records.forEach(blog-{this.queryBlogUser(blog);this.isBlogLiked(blog);});returnResult.ok(records);}拆开看query()是 MyBatis-Plus 提供的链式查询入口orderByDesc(liked)拼上ORDER BY liked DESC。.page(new Page(current, MAX_PAGE_SIZE))交给分页插件处理它会自动加上LIMIT并统计总数。MAX_PAGE_SIZE是 10current由前端传默认 1。page.getRecords()拿到当前页的数据。forEach里对每条笔记补两个字段。这里没有用 for 循环而是 lambda写法上更短注意 lambda 里调用本类方法要用this.queryBlogUser(...)因为 lambda 的this指向外层。数据库压力上有个天然的好处一页只有 10 条所以后面补作者信息最多查 10 次和笔记总数无关。6.3 笔记详情 queryBlogById 怎么写OverridepublicResultqueryBlogById(Longid){// 1.查询blogBlogbloggetById(id);if(blognull){returnResult.fail(笔记不存在);}// 2.查询blog有关的用户queryBlogUser(blog);// 3.查询blog是否被点赞isBlogLiked(blog);returnResult.ok(blog);}三步查笔记、补作者、判断是否点过赞。和列表的差别在于单条查询要处理查不到的情况笔记不存在时返回Result.fail(笔记不存在)而不是返回空的Blog让前端能区分没这条笔记和有笔记但内容是空。6.4 两个工具方法补作者信息privatevoidqueryBlogUser(Blogblog){LonguserIdblog.getUserId();UseruseruserService.getById(userId);blog.setName(user.getNickName());blog.setIcon(user.getIcon());}拿blog.userId去tb_user查一条把nickName和icon塞回Blog。抽成方法是因为列表和详情都要用。判断是否点过赞privatevoidisBlogLiked(Blogblog){// 1.获取登录用户UserDTOuserUserHolder.getUser();if(usernull){// 用户未登录无需查询是否点赞return;}LonguserIduser.getId();// 2.判断当前登录用户是否已经点赞Stringkeyblog:liked:blog.getId();DoublescorestringRedisTemplate.opsForZSet().score(key,userId.toString());blog.setIsLike(score!null);}用ZSCORE key member判断 ZSet 里有没有这个用户。返回null表示没点过赞返回一个时间戳说明点过。这里只需要判断存在性用ZSCORE就够了不必用ZRANK。查出来的Double直接和null比较不用管 score 的具体值所以就赋成score ! null这个布尔结果。为什么必须先判空这个方法同时被queryHotBlog调用而/blog/hot是放行给游客的未登录访问时UserHolder里没有用户。少了这个判断游客刷首页就会 NPE。两个可以顺手改进的地方这里的 key 是手写拼接的blog:liked:没有用BLOG_LIKED_KEY常量属于一处不一致另外一条笔记一次ZSCORE一页 10 条就是 10 次往返量大的话可以用 pipeline 合并。6.5 想清楚点赞状态为什么不单独开接口前端已经写好了高亮逻辑它只是读Blog.isLike。既然后端在两个查询接口的返回值里都带上了这个字段按钮自然就亮了不需要再加一个查我点没点过赞的接口。这是把状态挂在查询结果上的常见做法少一次请求。七、功能三点赞 / 取消点赞7.1 需求PPT 第 153 页同一个用户只能点赞一次再次点击则取消点赞。如果当前用户已经点赞点赞按钮高亮显示前端已实现判断字段Blog的isLike属性。实现步骤给Blog加isLike字段点赞功能用 Redis 的 set 集合判断是否点过赞没点过就 1点过就 -1查询详情和分页查询时判断是否点过赞赋值给isLike。7.2 思路推导这条数据该放哪先想如果只改数据库会怎样。tb_blog.liked只是一个计数点一次 1再点 -1看起来够了。但它回答不了一个问题当前这个人点过没有。没有这个信息就没法实现再点一次取消也没法让按钮高亮取消点赞时甚至不知道该给谁减。所以要额外存一份谁点过赞的名单。名单放哪放数据库就得再建一张点赞关系表每次点赞多一次写库。放 Redis 更合适这是一份典型的 key-value 数据一篇笔记一个 key写读都频繁丢了也能从数据库的计数大致恢复。名单用什么结构把需求翻译成数据结构的语言同一个人不能重复出现 → 需要去重List 排除。要按点赞时间排出先后 → 需要排序Set 排除。还要能按人查、按人删 → Set 和 SortedSet 的函数都能做到。于是答案就是 ZSetmember 存userId去重score 存点赞时间戳排序。总数和名单各管一头数据库的liked负责显示数字、按热度排序列表Redis 的 ZSet 负责去重、判断我点没点过、给出点赞顺序。两边都要写所以点赞方法里每个分支都有两次写操作。7.3 likeBlog 怎么写按 7.2 的推导方法骨架自然是先判断状态再分两个分支每个分支里先改数据库再改 Redis。OverridepublicResultlikeBlog(Longid){// 1.获取登录用户LonguserIdUserHolder.getUser().getId();// 2.判断当前登录用户是否已经点赞StringkeyBLOG_LIKED_KEYid;DoublescorestringRedisTemplate.opsForZSet().score(key,userId.toString());if(scorenull){// 3.如果未点赞可以点赞// 3.1.数据库点赞数 1booleanisSuccessupdate().setSql(liked liked 1).eq(id,id).update();// 3.2.保存用户到Redis的set集合 zadd key value scoreif(isSuccess){stringRedisTemplate.opsForZSet().add(key,userId.toString(),System.currentTimeMillis());}}else{// 4.如果已点赞取消点赞// 4.1.数据库点赞数 -1booleanisSuccessupdate().setSql(liked liked - 1).eq(id,id).update();// 4.2.把用户从Redis的set集合移除if(isSuccess){stringRedisTemplate.opsForZSet().remove(key,userId.toString());}}returnResult.ok();}逐段对应到思路代码在做什么实际执行的命令 / SQLUserHolder.getUser().getId()取当前登录用户接口需要登录这里不会为空无opsForZSet().score(key, userId)判断这个人点过没有ZSCORE blog:liked:{id} {userId}setSql(liked liked 1).eq(id, id).update()点赞数 1UPDATE tb_blog SET liked liked 1 WHERE id ?opsForZSet().add(key, userId, now)把用户记进名单score 是当前时间ZADD blog:liked:{id} {时间戳} {userId}setSql(liked liked - 1).eq(id, id).update()点赞数 -1UPDATE tb_blog SET liked liked - 1 WHERE id ?opsForZSet().remove(key, userId)从名单里删掉ZREM blog:liked:{id} {userId}为什么点赞数要用setSql交给数据库自增。如果写成先getById查出liked加一之后update两个用户同时点赞时双方可能都读到 10各自写回 11结果少算一次。liked liked 1由数据库在行锁内完成不会互相覆盖。为什么用if (isSuccess)兜住 Redis 的写。数据库更新失败比如笔记被删了WHERE id ?没命中时不应该再往 ZSet 里加人否则名单和计数就对不上。这是保证两边尽量一致的最低成本做法以数据库为准数据库动不了就不动缓存。为什么取消点赞不用先查名单里有没有。走到else分支已经说明ZSCORE查到了用户确实在名单里直接ZREM就行ZREM删不存在的 member 也不会报错。7.4 前端怎么知道要显示高亮isLike不需要单独的接口。isBlogLiked在/blog/hot和/blog/{id}里都调用了一次前端刷新列表或进详情页时就能拿到最新的点赞状态按钮自动高亮或取消高亮。八、功能四点赞排行榜8.1 需求PPT 第 155 页在详情页把给该笔记点赞的人显示出来取最早点赞的 TOP5。说明内容请求方式GET请求路径/blog/likes/{id}请求参数idblog 的 id返回值ListUserDTO给这个笔记点赞的 TopN 用户集合8.2 思路7.2 用时间戳当 score 的时候其实已经为这个需求铺好路了ZSet 按 score 从小到大就是按点赞时间从早到晚。所以取 Top5 只要一句ZRANGE key 0 4。但排行榜要展示的是人不是 id。所以还得拿这 5 个 id 去tb_user查昵称和头像。这里有一步容易出错SQL 的IN查询返回顺序不由IN里的顺序决定通常按主键排而排行榜是有顺序的。解决办法是查的时候用ORDER BY FIELD把顺序摆回来。所以流程是ZSet 取有序 id → 用 id 查用户 → 查询时保持原顺序 → 返回用户信息。8.3 queryBlogLikes 怎么写OverridepublicResultqueryBlogLikes(Longid){StringkeyBLOG_LIKED_KEYid;// 1.查询top5的点赞用户 zrange key 0 4SetStringtop5stringRedisTemplate.opsForZSet().range(key,0,4);if(top5null||top5.isEmpty()){returnResult.ok(Collections.emptyList());}// 2.解析出其中的用户idListLongidstop5.stream().map(Long::valueOf).collect(Collectors.toList());StringidStrStrUtil.join(,,ids);// 3.根据用户id查询用户 WHERE id IN ( 5 , 1 ) ORDER BY FIELD(id, 5, 1)ListUserDTOuserDTOSuserService.query().in(id,ids).last(ORDER BY FIELD(id,idStr)).list().stream().map(user-BeanUtil.copyProperties(user,UserDTO.class)).collect(Collectors.toList());// 4.返回returnResult.ok(userDTOS);}分四步看第一步取 id 列表。range(key, 0, 4)对应ZRANGE blog:liked:{id} 0 4下标从 0 到 4 共 5 个按 score 升序也就是最早点赞的 5 个人。取 5 是写死的要做得灵活可以加个参数。第二步类型转换。ZSet 的 member 存的是字符串用stream().map(Long::valueOf)转成Long。Long::valueOf是方法引用写法等价于s - Long.valueOf(s)。第三步查用户并保序。这是全章代码含量最高的一行拆开念userService.query()开一个链式查询。.in(id, ids)生成WHERE id IN (5, 1)。MyBatis-Plus 会把集合展开成占位符不是字符串拼接所以这一步是安全的。.last(ORDER BY FIELD(id, 5, 1))把ORDER BY FIELD(id, 5, 1)直接追加到 SQL 末尾。FIELD(id, 5, 1)返回 id 在列表里的位置5 在第一位置返回 11 在第二位返回 2按这个值排序就恢复了 ZSet 给出的顺序。StrUtil.join(,, ids)就是把ListLong拼成5,1这样的串。.list()执行查询拿到ListUser。.stream().map(...)逐个转成UserDTO。BeanUtil.copyProperties是 Hutool 的工具按同名属性拷贝转成UserDTO是因为它没有密码字段直接把User返回给前端会把tb_user.password也带出去。最终执行的 SQL 大致是SELECTid,phone,password,nick_name,icon,create_time,update_timeFROMtb_userWHEREidIN(5,1)ORDERBYFIELD(id,5,1)第四步返回。列表为空时返回Result.ok(Collections.emptyList())而不是Result.ok()保证前端拿到的data永远是数组少写一个判空分支。关于ORDER BY FIELD的安全性这行是字符串拼接 SQL。这里的idStr来自 Redis 里存的用户 id已经过Long::valueOf转换只可能是数字所以可控。如果这个串来自前端参数就不能这么写了。不想拼字符串的话也可以在内存里按 ZSet 的顺序对结果重排。九、为什么选 SortedSetPPT 第 156 页的对比表ListSetSortedSet排序方式按添加顺序排序无法排序根据 score 值排序唯一性不唯一唯一唯一查找方式按索引查找或首尾查找根据元素查找根据元素查找对照本章需求逐条看需要一个用户只算一次 → List 允许重复先排除。需要判断我点过赞没有 → List 得遍历Set 和 SortedSet 都能按元素直接查。需要按点赞时间给出最早点赞的 5 个人 → Set 无序也不行SortedSet 用时间戳做 score天然有序。ZSet 是三者里唯一同时满足去重和按时间排序的。System.currentTimeMillis()做 scoreuserId做 member一个结构同时解决了去重、排序和取消点赞时的定位问题。这也是为什么 PPT 在第 153 页说用 set 集合而实际代码写的是 ZSet。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。