**谷粒随享** ## 第5章 专辑/声音详情 **学习目标:** - 专辑详情业务需求 - 专辑服务 1.专辑信息 2.分类信息 3.统计信息 4.主播信息 - 搜索/**详情服务**:汇总专辑详情数据 - 专辑包含**声音列表(付费标识动态展示)** - MongoDB文档型数据库应用 - 基于**MongoDB**存储用户对于声音**播放进度** - 基于Redis实现排行榜(将不同不同分类下包含各个维度热门专辑排行) # 1、专辑详情 ![详情-专辑详情和声音列表](assets/详情-专辑详情和声音列表.gif) 专辑详情页面渲染需要以下四项数据: - **albumInfo**:当前专辑信息 - **albumStatVo**:专辑统计信息 - **baseCategoryView**:专辑分类信息 - **announcer**:专辑主播信息 因此接下来,我们需要在**专辑微服务**、**用户微服务**中补充RestFul接口实现 并且 提供远程调用Feign API接口给**搜索微服务**来调用获取。 在专辑**搜索微服务**中编写控制器**汇总专辑详情**所需**数据**: 以下是详情需要获取到的数据集 1. 通过专辑Id 获取专辑数据{已存在} 2. 通过专辑Id 获取专辑统计信息**{不存在}** 3. 通过三级分类Id 获取到分类数据{已存在} 4. 通过用户Id 获取到主播信息{存在} ## 1.1 服务提供方提供接口 ### 1.1.1 根据专辑Id 获取专辑数据(已完成) ### 1.1.2 根据三级分类Id获取到分类信息(已完成) ### 1.1.3 根据用户Id 获取主播信息(已完成) ### 1.1.4 根据专辑Id 获取统计信息 > YAPI接口地址:http://192.168.200.6:3000/project/11/interface/api/67 **AlbumInfoApiController** 控制器 ```java /** * 根据专辑ID查询专辑统计信息 * @param albumId * @return */ @Operation(summary = "根据专辑ID查询专辑统计信息") @GetMapping("/albumInfo/getAlbumStatVo/{albumId}") public Result getAlbumStatVo(@PathVariable Long albumId){ AlbumStatVo albumStatVo = albumInfoService.getAlbumStatVo(albumId); return Result.ok(albumStatVo); } ``` **AlbumInfoService**接口 ```java /** * 根据专辑ID查询专辑统计信息 * @param albumId * @return */ AlbumStatVo getAlbumStatVo(Long albumId); ``` **AlbumInfoServiceImpl**实现类 ```java /** * 根据专辑ID查询专辑统计信息 * @param albumId * @return */ @Override public AlbumStatVo getAlbumStatVo(Long albumId) { return albumInfoMapper.getAlbumStatVo(albumId); } ``` **albumInfoMapper.java** ```java /** * 根据专辑ID查询统计信息 * @param albumId * @return */ AlbumStatVo getAlbumStatVo(@Param("albumId") Long albumId); ``` **albumInfoMapper.xml** ```sql ``` `service-album-client`模块**AlbumFeignClient** 接口中添加 ```java /** * 根据专辑ID查询专辑统计信息 * @param albumId * @return */ @GetMapping("/albumInfo/getAlbumStatVo/{albumId}") public Result getAlbumStatVo(@PathVariable Long albumId); ``` **AlbumDegradeFeignClient**熔断类: ```java @Override public Result getAlbumStatVo(Long albumId) { log.error("[专辑服务]远程调用getAlbumStatVo执行服务降级"); return null; } ``` ## 1.2 服务调用方汇总数据 回显时,后台需要提供将数据封装到map集合中; ```java result.put("albumInfo", albumInfo); 获取专辑信息 result.put("albumStatVo", albumStatVo); 获取专辑统计信息 result.put("baseCategoryView", baseCategoryView); 获取分类信息 result.put("announcer", userInfoVo); 获取主播信息 ``` > YAPI接口地址:http://192.168.200.6:3000/project/11/interface/api/69 在`service-search` 微服务**itemApiController** 控制器中添加 ```java package com.atguigu.tingshu.search.api; import com.atguigu.tingshu.common.result.Result; import com.atguigu.tingshu.search.service.ItemService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.Map; @Tag(name = "专辑详情管理") @RestController @RequestMapping("api/search") @SuppressWarnings({"all"}) public class itemApiController { @Autowired private ItemService itemService; /** * 根据专辑ID汇总详情页所需参数 * * @param albumId * @return */ @Operation(summary = "根据专辑ID汇总详情页所需参数") @GetMapping("/albumInfo/{albumId}") public Result> getItem(@PathVariable Long albumId) { Map map = itemService.getItem(albumId); return Result.ok(map); } } ``` 接口与实现 ```java package com.atguigu.tingshu.search.service; import java.util.Map; public interface ItemService { /** * 根据专辑ID汇总详情页所需参数 * * @param albumId * @return */ //ItemVo getItem(Long albumId); Map getItem(Long albumId); } ``` ```java package com.atguigu.tingshu.search.service.impl; import cn.hutool.core.lang.Assert; import com.atguigu.tingshu.album.AlbumFeignClient; import com.atguigu.tingshu.model.album.AlbumInfo; import com.atguigu.tingshu.model.album.BaseCategoryView; import com.atguigu.tingshu.search.service.ItemService; import com.atguigu.tingshu.user.client.UserFeignClient; import com.atguigu.tingshu.vo.album.AlbumStatVo; import com.atguigu.tingshu.vo.user.UserInfoVo; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.util.HashMap; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executor; @Slf4j @Service @SuppressWarnings({"all"}) public class ItemServiceImpl implements ItemService { @Autowired private AlbumFeignClient albumFeignClient; @Autowired private UserFeignClient userFeignClient; @Autowired @Qualifier("threadPoolTaskExecutor") private Executor executor; /** * 根据专辑ID汇总详情页所需参数 * * @param albumId * @return */ @Override public Map getItem(Long albumId) { //0.TODO 采用布隆过滤器解决缓存穿透问题 //1.创建Map对象用于封装详情页数据 Map map = new ConcurrentHashMap<>(); //2.远程调用专辑服务:根据专辑ID查询专辑信息 CompletableFuture albumInfoCompletableFuture = CompletableFuture.supplyAsync(() -> { log.info("获取专辑信息异步任务:{}", albumId); AlbumInfo albumInfo = albumFeignClient.getAlbumInfo(albumId).getData(); Assert.notNull(albumInfo, "专辑{}不存在", albumId); map.put("albumInfo", albumInfo); return albumInfo; }, executor); //3.远程调用专辑服务:根据专辑所属分类ID查询分类信息 CompletableFuture baseCategoryCompletableFuture = albumInfoCompletableFuture.thenAcceptAsync(albumInfo -> { BaseCategoryView categoryView = albumFeignClient.getCategoryView(albumInfo.getCategory3Id()).getData(); Assert.notNull(categoryView, "分类{}不存在", albumInfo.getCategory3Id()); map.put("baseCategoryView", categoryView); }, executor); //4.远程调用用户服务:根据专辑所属主播ID查询主播信息 CompletableFuture announcerCompletableFuture = albumInfoCompletableFuture.thenAcceptAsync(albumInfo -> { UserInfoVo userInfoVo = userFeignClient.getUserInfoVo(albumInfo.getUserId()).getData(); Assert.notNull(userInfoVo, "用户{}不存在", albumInfo.getUserId()); map.put("announcer", userInfoVo); }, executor); //5.远程调用专辑服务:根据专辑ID查询专辑统计信息 CompletableFuture albumStatVoCompletableFuture = CompletableFuture.runAsync(() -> { log.info("获取专辑统计异步任务:{}", albumId); AlbumStatVo albumStatVo = albumFeignClient.getAlbumStatVo(albumId).getData(); Assert.notNull(albumStatVo, "专辑统计{}不存在", albumId); map.put("albumStatVo", albumStatVo); }, executor); //6.组合异步任务 CompletableFuture.allOf( albumInfoCompletableFuture, baseCategoryCompletableFuture, announcerCompletableFuture, albumStatVoCompletableFuture ).orTimeout(1, TimeUnit.SECONDS) .join(); //7.响应Map对象 return map; } } ``` ## 1.3 获取专辑声音列表 ![](assets/image-20231005140148572.png) 需求:根据专辑ID分页查询声音列表,返回当前页10条记录,对每条声音付费标识处理。**关键点:哪个声音需要展示付费标识。** **默认每个声音付费标识为:false** 判断专辑付费类型:0101-免费、**0102-vip免费、0103-付费** - 用户未登录 - 专辑类型不是免费,将除了免费可以试听声音外,将本页中其余声音付费标识设置:true - 用户登录(获取是否为VIP) - 不是VIP,或者VIP过期(除了免费以外声音全部设置为付费) - 是VIP,专辑类型为付费 需要进行处理 - 统一处理需要付费情况 - 获取用户购买情况(专辑购买,或者声音购买)得到每个声音购买状态 - 判断根据用户购买情况设置声音付费标识 ### 1.3.1 获取用户声音列表付费情况 > YAPI接口地址:http://192.168.200.6:3000/project/11/interface/api/87 `user_paid_album` 这张表记录了用户购买过的专辑 `user_paid_track` 这张表记录了用户购买过的声音 如果购买过,则在map 中存储数据 key=trackId value = 1 未购买value则返回0 例如: - 某专辑第一页,除了试听的声音(前五)从6-10个声音需要在用户微服务中判断5个声音是否购买过 - 用户翻到第二页,从11-20个声音同样需要判断用户购买情况 **UserInfoApiController** 控制器: ```java /** * 提交需要检查购买状态声音ID列表,响应每个声音购买状态 * @param userId * @param albumId * @param needCheckPayStatusTrackIdList 待检查购买状态声音ID列表 * @return */ @Operation(summary = "提交需要检查购买状态声音ID列表,响应每个声音购买状态") @PostMapping("/userInfo/userIsPaidTrack/{userId}/{albumId}") public Result> userIsPaidTrack( @PathVariable Long userId, @PathVariable Long albumId, @RequestBody List needCheckPayStatusTrackIdList ) { Map map = userInfoService.userIsPaidTrack(userId, albumId, needCheckPayStatusTrackIdList); return Result.ok(map); } ``` **UserInfoService接口**: ```java /** * 提交需要检查购买状态声音ID列表,响应每个声音购买状态 * * @param userId * @param albumId * @param needCheckPayStatusTrackIdList * @return */ Map userIsPaidTrack(Long userId, Long albumId, List needCheckPayStatusTrackIdList); ``` **UserInfoServiceImpl实现类**: ```java /** * 提交需要检查购买状态声音ID列表,响应每个声音购买状态 * * @param userId 用户ID * @param albumId 专辑ID * @param needCheckPayStatusTrackIdList 待检查购买状态声音ID列表 * @return */ @Override public Map userIsPaidTrack(Long userId, Long albumId, List needCheckPayStatusTrackIdList) { Map map = new HashMap<>(); //1.根据用户ID+专辑ID查询专辑购买记录 Long count = userPaidAlbumMapper.selectCount( new LambdaQueryWrapper() .eq(UserPaidAlbum::getUserId, userId) .eq(UserPaidAlbum::getAlbumId, albumId) ); //2. 如果已购买专辑,将所有待检查购买状态声音 购买状态设置为 1 响应 if (count > 0) { for (Long trackId : needCheckPayStatusTrackIdList) { map.put(trackId, 1); } return map; } //3. 根据用户ID+专辑ID查询已购声音记录 List userPaidTrackList = userPaidTrackMapper.selectList( new LambdaQueryWrapper() .eq(UserPaidTrack::getUserId, userId) .eq(UserPaidTrack::getAlbumId, albumId) .select(UserPaidTrack::getTrackId) ); //4. 如果不存再已购声音,将所有待检查购买状态声音 购买状态设置为 0 响应 if (CollUtil.isEmpty(userPaidTrackList)) { for (Long trackId : needCheckPayStatusTrackIdList) { map.put(trackId, 0); } return map; } //5.如果存在已购声音,将提交检查声音ID列表中,已购声购买状态设置为:1。未购买设置为0 List userPaidTrackIdList = userPaidTrackList.stream().map(UserPaidTrack::getTrackId).collect(Collectors.toList()); for (Long trackId : needCheckPayStatusTrackIdList) { if (userPaidTrackIdList.contains(trackId)) { map.put(trackId, 1); } else { map.put(trackId, 0); } } return map; } ``` `service-user-client`模块中**UserFeignClient** 远程调用接口中添加: ```java /** * 提交需要检查购买状态声音ID列表,响应每个声音购买状态 * @param userId * @param albumId * @param needCheckPayStatusTrackIdList 待检查购买状态声音ID列表 * @return */ @PostMapping("/userInfo/userIsPaidTrack/{userId}/{albumId}") public Result> userIsPaidTrack( @PathVariable Long userId, @PathVariable Long albumId, @RequestBody List needCheckPayStatusTrackIdList ); ``` **UserDegradeFeignClient熔断类**: ```java @Override public Result> userIsPaidTrack(Long userId, Long albumId, List needCheckPayStatusTrackIdList) { log.error("【用户服务】提供userIsPaidTrack远程调用失败"); return null; } ``` ### 1.3.2 查询专辑声音列表 在`service-album` 微服务中添加控制器. 获取专辑声音列表时,我们将数据都统一封装到**AlbumTrackListVo**实体类中 > YAPI接口地址:http://192.168.200.6:3000/project/11/interface/api/89 **TrackInfoApiController控制器** ```java /** * 需求:用户未登录,可以给用户展示声音列表;用户已登录,可以给用户展示声音列表,并动态渲染付费标识 * 分页查询专辑下声音列表(动态渲染付费标识) * * @param albumId * @param page * @param limit * @return */ @GuiGuLogin(required = false) @Operation(summary = "分页查询专辑下声音列表(动态渲染付费标识)") @GetMapping("/trackInfo/findAlbumTrackPage/{albumId}/{page}/{limit}") public Result> findAlbumTrackPage( @PathVariable Long albumId, @PathVariable Long page, @PathVariable Long limit) { //1.尝试获取用户ID Long userId = AuthContextHolder.getUserId(); //2.构建分页对象:封装当前页码、每页记录数 IPage pageInfo = new Page<>(page, limit); //3.调用业务逻辑层->持久层:封装:总记录数,总页数,当前页数据 pageInfo = trackInfoService.findAlbumTrackPage(pageInfo, albumId, userId); //4.响应分页对象 return Result.ok(pageInfo); } ``` **TrackInfoService接口:** ```java /** * 需求:用户未登录,可以给用户展示声音列表;用户已登录,可以给用户展示声音列表,并动态渲染付费标识 * 分页查询专辑下声音列表(动态渲染付费标识) * * @param pageInfo MP分页对象 * @param albumId 专辑ID * @param userId 用户ID * @return */ IPage findAlbumTrackPage(IPage pageInfo, Long albumId, Long userId); ``` **TrackInfoServiceImpl实现类:** - 根据专辑Id 获取到专辑列表, - 用户为空的时候,然后找出哪些是需要付费的声音并显示付费 isShowPaidMark=true 付费类型: 0101-免费 0102-vip付费 0103-付费 - ​ 用户不为空的时候 - 判断用户的类型 - vip 免费类型 - 如果不是vip 需要付费 - 如果是vip 但是已经过期了 也需要付费 - 需要付费 - 统一处理需要付费业务 ​ 获取到声音Id列表集合 与 用户购买声音Id集合进行比较 将用户购买的声音存储到map中,key=trackId value = 1或0; 1:表示购买过,0:表示没有购买过 如果声音列表不包含,则将显示为付费,否则判断用户是否购买过声音,没有购买过设置为付费 ```java @Autowired private UserFeignClient userFeignClient; /** * 需求:用户未登录,可以给用户展示声音列表;用户已登录,可以给用户展示声音列表,并动态渲染付费标识 * 分页查询专辑下声音列表(动态渲染付费标识) * * @param pageInfo MP分页对象 * @param albumId 专辑ID * @param userId 用户ID * @return */ @Override public IPage findAlbumTrackPage(IPage pageInfo, Long albumId, Long userId) { //1.分页获取声音列表(包含统计数值) isShowPaidMark默认为false pageInfo = trackInfoMapper.findAlbumTrackPage(pageInfo, albumId); //动态渲染付费标识 //2.根据专辑ID查询专辑信息,得到专辑付费类型以及免费试听的集数 AlbumInfo albumInfo = albumInfoMapper.selectById(albumId); Assert.notNull(albumInfo, "专辑{}不存在", albumId); //2.1 付费类型:0101-免费、0102-vip免费、0103-付费 String payType = albumInfo.getPayType(); //2.2 免费实体集数 Integer tracksForFree = albumInfo.getTracksForFree(); //3.如果用户未登录 情况一:且专辑付费类型是:VIP免费或付费,将除试听以外其他声音付费标识设置true if (userId == null) { if (ALBUM_PAY_TYPE_VIPFREE.equals(payType) || ALBUM_PAY_TYPE_REQUIRE.equals(payType)) { pageInfo.getRecords() .stream() .filter(t -> t.getOrderNum() > tracksForFree) .forEach(t -> t.setIsShowPaidMark(true)); } } else { //4. 如果用户已登录 满足以下两种情况需要进一步获取当前页声音购买状态 //4.1 远程调用用户服务获取用户身份,判断是否为会员(有效期内会员) UserInfoVo userInfoVo = userFeignClient.getUserInfoVo(userId).getData(); Assert.notNull(userInfoVo, "用户{}不存在", userId); Boolean isVIP = false; if (userInfoVo.getIsVip().intValue() == 1 && userInfoVo.getVipExpireTime().after(new Date())) { isVIP = true; } //4.2 情况二:如果专辑 付费类型:VIP免费 且 当前用户是普通用户,满足进一步查询当前页声音购买状态 Boolean isNeedCheckPayStatus = false; if (!isVIP && ALBUM_PAY_TYPE_VIPFREE.equals(payType)) { isNeedCheckPayStatus = true; } //4.3 情况三:如果专辑 付费类型:付费,无论什么用户,满足进一步查询当前页声音购买状态 if (ALBUM_PAY_TYPE_REQUIRE.equals(payType)) { isNeedCheckPayStatus = true; } //4.4 如果满足情况二或情况三,远程调用用户服务,得到当前页除试听部分每个声音购买状态 if (isNeedCheckPayStatus) { //4.4.1 获取当前页中除试听以外其他声音ID列表 List needCheckPayStatusTrackIdList = pageInfo .getRecords() .stream() .filter(t -> t.getOrderNum() > tracksForFree) .map(AlbumTrackListVo::getTrackId) .collect(Collectors.toList()); //4.4.2 远程调用用户服务,获取当前页中声音购买状态Map Map payStatusMap = userFeignClient.userIsPaidTrack(userId, albumId, needCheckPayStatusTrackIdList).getData(); //4.5 根据响应声音购买状态,动态修改付费标识。购买状态为0的声音付费标识isShowPaidMark全部设置:true pageInfo .getRecords() .stream() .filter(t -> t.getOrderNum() > tracksForFree) .forEach(t -> t.setIsShowPaidMark(payStatusMap.get(t.getTrackId()) == 0)); } } return pageInfo; } ``` **TrackInfoMapper接口**:条件必须是当前已经开放并且是审核通过状态的数据,并且还需要获取到声音的播放量以及评论数量 ```java /** * 分页获取声音列表(包含统计数值) * @param pageInfo 分页对象,框架自动SQL后面拼接limit部分 * @param albumId * @return */ IPage findAlbumTrackPage(IPage pageInfo, @Param("albumId") Long albumId); ``` **TrackInfoMapper.xml** 映射文件 动态SQL ```sql #需求:分页查询指定专辑下包含声音列表(展示声音统计信息) 按照声音序号升序排列 select ti.id trackId, ti.track_title, ti.media_duration, ti.order_num, ti.create_time, max(if(stat_type='0701', stat_num, 0)) playStatNum, max(if(stat_type='0704', stat_num, 0)) commentStatNum from track_info ti inner join track_stat stat on stat.track_id = ti.id where ti.album_id = 1 and ti.status = '0501' and ti.is_deleted = 0 group by ti.id order by ti.order_num asc limit 0,10; ``` ```sql ``` 测试: - 手动增加用户购买专辑记录:**user_paid_album** - 手动增加用户购买声音记录:**user_paid_track** - 手动修改VIP会员:**user_info** 情况一:未登录情况,专辑付费类型:VIP免费 付费 查看声音列表->试听声音免费+其余都需要展示付费标识 情况二:登录情况 - 普通用户 - 免费 全部免费 - VIP付费 试听声音免费+用户购买过专辑/声音,未购买展示付费标识 - 付费:试听声音免费+用户购买过专辑/声音,未购买展示付费标识 - VIP用户 - 免费 全部免费 - VIP付费 全部免费 - 付费:试听声音免费+用户购买过专辑/声音,未购买展示付费标识 # 2、MongoDB文档型数据库 详情见:**第5章 MongoDB入门.md** **播放进度**对应的实体类 **UserListenProcess** # 3、声音详情 ![详情-获取播放记录进度](assets/详情-获取播放记录进度.gif) ## 3.1 获取声音播放进度 在播放声音的时候,会有触发一个获取播放进度的控制器!因为页面每隔10s会自动触发一次保存功能,会将数据写入MongoDB中。所以我们直接从MongoDB中获取到上一次声音的播放时间即可! ![](assets/tingshu013.png) > YAPI接口:http://192.168.200.6:3000/project/11/interface/api/71 在 `service-user` 微服务的 **UserListenProcessApiController** 控制器中添加 ```java /** * 查询当前用户指定声音播放进度 * * @param trackId 声音ID * @return 前端必须返回具体数值,返回null导致前端无法触发更新播放进度定时任务 */ @GuiGuLogin(required = false) @GetMapping("/userListenProcess/getTrackBreakSecond/{trackId}") public Result getTrackBreakSecond(@PathVariable Long trackId) { //1.获取当前用户ID Long userId = AuthContextHolder.getUserId(); if (userId != null) { //2.根据用户ID+声音ID查询播放进度 BigDecimal bigDecimal = userListenProcessService.getTrackBreakSecond(userId, trackId); return Result.ok(bigDecimal); } return Result.ok(new BigDecimal("0.00")); } ``` **UserListenProcessService接口**: ```java /** * 根据用户ID及声音ID查询播放进度 * @param userId * @param trackId * @return */ BigDecimal getTrackBreakSecond(Long userId, Long trackId); ``` **UserListenProcessServiceImpl**实现类: ```java package com.atguigu.tingshu.user.service.impl; import cn.hutool.core.date.DateUtil; import cn.hutool.core.util.IdUtil; import com.alibaba.fastjson.JSON; import com.atguigu.tingshu.common.constant.KafkaConstant; import com.atguigu.tingshu.common.constant.RedisConstant; import com.atguigu.tingshu.common.constant.SystemConstant; import com.atguigu.tingshu.common.service.KafkaService; import com.atguigu.tingshu.common.util.MongoUtil; import com.atguigu.tingshu.model.user.UserListenProcess; import com.atguigu.tingshu.user.service.UserListenProcessService; import com.atguigu.tingshu.vo.album.TrackStatMqVo; import com.atguigu.tingshu.vo.user.UserListenProcessVo; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.query.Criteria; import org.springframework.data.mongodb.core.query.Query; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import java.math.BigDecimal; import java.math.RoundingMode; import java.util.Date; import java.util.concurrent.TimeUnit; @Service @SuppressWarnings({"all"}) public class UserListenProcessServiceImpl implements UserListenProcessService { @Autowired private MongoTemplate mongoTemplate; /** * 根据用户ID及声音ID查询播放进度 * * @param userId * @param trackId * @return */ @Override public BigDecimal getTrackBreakSecond(Long userId, Long trackId) { //1.创建查询对象:封装查询条件 Query query = new Query(); query.addCriteria(Criteria.where("userId").is(userId).and("trackId").is(trackId)); //2.执行查询:注意每个用户都有自己播放进度集合 String collectionName = MongoUtil.getCollectionName(MongoUtil.MongoCollectionEnum.USER_LISTEN_PROCESS, userId); UserListenProcess listenProcess = mongoTemplate.findOne(query, UserListenProcess.class, collectionName); if (listenProcess != null) { return listenProcess.getBreakSecond(); } return new BigDecimal("0.00"); } } ``` ## 3.2 更新播放进度 页面每隔10秒左右更新播放进度. 1. 更新播放进度页面会传递 专辑Id ,秒数,声音Id 。后台会将这个三个属性封装到UserListenProcessVo 对象中。然后利用MongoDB进行存储到UserListenProcess实体类中! 2. 为了提高用户快速访问,将用户信息存储到缓存中。先判断当前用户Id 与 声音Id 是否存在,不存在的话才将数据存储到缓存,并且要发送消息给RabbitMQ。 3. RabbitMQ监听消息并消费,更新专辑与声音的统计数据。 ### 3.2.1 更新MongoDB ![](assets/tingshu015.png) > YAPI接口地址:http://192.168.200.6:3000/project/11/interface/api/73 在 **UserListenProcessApiController** 控制器中添加 ```java /** * 保存更新声音播放进度 * @param userListenProcessVo * @return */ @Operation(summary = "保存更新声音播放进度") @GuiGuLogin(required = false) @PostMapping("/userListenProcess/updateListenProcess") public Result updateListenProcess(@RequestBody UserListenProcessVo userListenProcessVo) { //1.获取当前用户ID Long userId = AuthContextHolder.getUserId(); if (userId != null) { //2.新增或修改播放进度 userListenProcessService.updateListenProcess(userId, userListenProcessVo); } return Result.ok(); } ``` **UserListenProcessService**接口: ```java /** * 保存更新声音播放进度 * @param userListenProcessVo * @return */ void updateListenProcess(Long userId, UserListenProcessVo userListenProcessVo); ``` **UserListenProcessServiceImpl**实现类: ```java @Autowired private RedisTemplate redisTemplate; @Autowired private RabbitService rabbitService; /** * 保存更新声音播放进度 * * @param userListenProcessVo * @return */ @Override public void updateListenProcess(Long userId, UserListenProcessVo userListenProcessVo) { //1.查询当前用户某个声音播放进度 //1.1 构建查询条件 Query query = new Query(); query.addCriteria(Criteria.where("userId").is(userId).and("trackId").is(userListenProcessVo.getTrackId())); //1.2 执行查询:注意每个用户都有自己播放进度集合 String collectionName = MongoUtil.getCollectionName(MongoUtil.MongoCollectionEnum.USER_LISTEN_PROCESS, userId); UserListenProcess listenProcess = mongoTemplate.findOne(query, UserListenProcess.class, collectionName); //2.如果不存在构建声音播放进度保存到MongoDB中 BigDecimal breakSecond = userListenProcessVo.getBreakSecond().setScale(0, RoundingMode.HALF_UP); if (listenProcess == null) { listenProcess = new UserListenProcess(); listenProcess.setUserId(userId); listenProcess.setTrackId(userListenProcessVo.getTrackId()); listenProcess.setAlbumId(userListenProcessVo.getAlbumId()); listenProcess.setBreakSecond(breakSecond); listenProcess.setCreateTime(new Date()); listenProcess.setUpdateTime(new Date()); } else { //3.如果已存在播放进度,更新播放进度时间及更新时间 包含文档注解ID listenProcess.setBreakSecond(breakSecond); listenProcess.setUpdateTime(new Date()); } mongoTemplate.save(listenProcess, collectionName); //4.采用MQ异步更新数据库及索引库中统计信息(播放量) //4.1 确保某个用户当日内只能更新一次播放统计量-生产者幂等性(由于播放进度更新会定时调用) //4.1.1 构建生产者幂等性Key 形式:前缀:userId_albumId_trackId String key = RedisConstant.USER_TRACK_REPEAT_STAT_PREFIX + userId + "_" + userListenProcessVo.getAlbumId() + "_" + userListenProcessVo.getTrackId(); //4.1.2 计算过期时间 要求:当前日内一次统计有效 long ttl = DateUtil.endOfDay(new Date()).getTime() - System.currentTimeMillis(); Boolean flag = redisTemplate.opsForValue().setIfAbsent(key, "", ttl, TimeUnit.MILLISECONDS); //4.2 发送更新声音统计VO消息到RabbitMQ if (flag) { //4.2.1 创建更新统“计消息VO对象 TrackStatMqVo mqVo = new TrackStatMqVo(); mqVo.setAlbumId(userListenProcessVo.getAlbumId()); mqVo.setTrackId(userListenProcessVo.getTrackId()); mqVo.setStatType(SystemConstant.TRACK_STAT_PLAY); mqVo.setCount(1); mqVo.setBusinessNo(IdUtil.randomUUID()); //4.2.2 发送MQ消息 rabbitService.sendMessage(MqConst.EXCHANGE_TRACK, MqConst.ROUTING_TRACK_STAT_UPDATE, mqVo); } } ``` 注意:要修改**TrackStatMqVo**实现序列化接口,否则会导致发送MQ消息失败 ![image-20240814161739086](assets/image-20240814161739086.png) ### 3.2.2 更新MySQL统计信息 在`service-album` 微服务中添加监听消息: ```java package com.atguigu.tingshu.album; import com.atguigu.tingshu.album.service.TrackInfoService; import com.atguigu.tingshu.common.constant.RedisConstant; import com.atguigu.tingshu.common.rabbit.constant.MqConst; import com.atguigu.tingshu.vo.album.TrackStatMqVo; import com.rabbitmq.client.Channel; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.springframework.amqp.core.Message; import org.springframework.amqp.rabbit.annotation.Exchange; import org.springframework.amqp.rabbit.annotation.Queue; import org.springframework.amqp.rabbit.annotation.QueueBinding; import org.springframework.amqp.rabbit.annotation.RabbitListener; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; import java.io.IOException; import java.util.concurrent.TimeUnit; /** * @author: atguigu * @create: 2024-08-14 14:23 */ @Slf4j @Component public class AlbumReceiver { @Autowired private RedisTemplate redisTemplate; @Autowired private TrackInfoService trackInfoService; /** * 监听到更新声音统计消息,更新MySQL中统计数值 * * @param mqVo * @param message * @param channel */ @SneakyThrows @RabbitListener(bindings = @QueueBinding( exchange = @Exchange(value = MqConst.EXCHANGE_TRACK, durable = "true"), value = @Queue(value = MqConst.QUEUE_TRACK_STAT_UPDATE, durable = "true"), key = MqConst.ROUTING_TRACK_STAT_UPDATE )) public void updateStat(TrackStatMqVo mqVo, Message message, Channel channel) { String key = ""; try { if (mqVo != null) { log.info("监听到更新声音统计消息:{}", mqVo); //1. 处理消费者幂等性问题(一个消息被多次消费带来数据不一致问题) //1.1 基于MQ消息对象中唯一标识作为Redis中Key 采用set k v ex nx命令写入Redis key = RedisConstant.BUSINESS_PREFIX + mqVo.getBusinessNo(); Boolean flag = redisTemplate.opsForValue().setIfAbsent(key, "", 10, TimeUnit.MINUTES); if (flag) { //2. 更新统计数值 业务处理可能抛出异常 trackInfoService.updateStat(mqVo); } } channel.basicAck(message.getMessageProperties().getDeliveryTag(), false); } catch (Exception e) { log.error("[专辑服务]监听更新声音统计消息异常:{}", e); //3.捕获到业务处理异常后,将消息再次进行入队,RabbitMQ再次投递消息 redisTemplate.delete(key); channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, true); } } } ``` 在**TrackInfoService** 中添加接口 ```java /** * 更新声音统计数值 * @param mqVo */ void updateStat(TrackStatMqVo mqVo); ``` 在**TrackInfoServiceImpl** 中添加实现 ```java @Autowired private AlbumStatMapper albumStatMapper; /** * 更新声音统计数值 * 注意:如果声音被播放,被评论 所属专辑也需要更新统计数值 * * @param mqVo */ @Override @Transactional(rollbackFor = Exception.class) public void updateStat(TrackStatMqVo mqVo) { //1.更新声音统计数值 LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); updateWrapper.eq(TrackStat::getTrackId, mqVo.getTrackId()); updateWrapper.eq(TrackStat::getStatType, mqVo.getStatType()); updateWrapper.setSql("stat_num=stat_num+" + mqVo.getCount()); trackStatMapper.update(null, updateWrapper); //2.如果统计类型是:声音播放 所属专辑统计信息也一并修改 if (SystemConstant.TRACK_STAT_PLAY.equals(mqVo.getStatType())) { LambdaUpdateWrapper albumStatLambdaUpdateWrapper = new LambdaUpdateWrapper<>(); albumStatLambdaUpdateWrapper.eq(AlbumStat::getAlbumId, mqVo.getAlbumId()); albumStatLambdaUpdateWrapper.eq(AlbumStat::getStatType, SystemConstant.ALBUM_STAT_PLAY); albumStatLambdaUpdateWrapper.setSql("stat_num = stat_num+" + mqVo.getCount()); albumStatMapper.update(null, albumStatLambdaUpdateWrapper); } //3.如果统计类型是:声音评论 所属专辑统计信息也一并修改 if (SystemConstant.TRACK_STAT_COMMENT.equals(mqVo.getStatType())) { LambdaUpdateWrapper albumStatLambdaUpdateWrapper = new LambdaUpdateWrapper<>(); albumStatLambdaUpdateWrapper.eq(AlbumStat::getAlbumId, mqVo.getAlbumId()); albumStatLambdaUpdateWrapper.eq(AlbumStat::getStatType, SystemConstant.ALBUM_STAT_COMMENT); albumStatLambdaUpdateWrapper.setSql("stat_num = stat_num+" + mqVo.getCount()); albumStatMapper.update(null, albumStatLambdaUpdateWrapper); } } ``` ## 3.3 用户上次播放专辑声音 ![image-20231012111356796](assets/image-20231012111356796.png) 我们需要根据用户Id 来获取播放记录 ,需要获取到专辑Id 与声音Id 封装到map中然后返回数据即可! > YAPI接口地址:http://192.168.200.6:3000/project/11/interface/api/83 控制器 **UserListenProcessApiController** ```java /** * 获取当前用户最近播放声音 * @return {albumId:1,trackId:12} */ @GuiGuLogin @GetMapping("/userListenProcess/getLatelyTrack") public Result> getLatelyTrack(){ Long userId = AuthContextHolder.getUserId(); Map map = userListenProcessService.getLatelyTrack(userId); return Result.ok(map); } ``` **UserListenProcessService接口:** ```java /** * 获取当前用户最近播放声音 * @return {albumId:1,trackId:12} */ Map getLatelyTrack(Long userId); ``` **UserListenProcessServiceImpl实现类**: ```java /** * 获取当前用户最近播放声音 * * @return {albumId:1,trackId:12} */ @Override public Map getLatelyTrack(Long userId) { //1.查询当前用户某个声音播放进度 //1.1 构建查询条件 Query query = new Query(); query.addCriteria(Criteria.where("userId").is(userId)); query.with(PageRequest.of(0, 1, Sort.Direction.DESC, "updateTime")); //1.2 执行查询:注意每个用户都有自己播放进度集合 String collectionName = MongoUtil.getCollectionName(MongoUtil.MongoCollectionEnum.USER_LISTEN_PROCESS, userId); UserListenProcess listenProcess = mongoTemplate.findOne(query, UserListenProcess.class, collectionName); if (listenProcess != null) { Map map = new HashMap<>(); map.put("albumId", listenProcess.getAlbumId()); map.put("trackId", listenProcess.getTrackId()); return map; } return null; } ``` ## 3.4 获取声音统计信息 ![](assets/tingshu014.png) > YAPI接口地址:http://192.168.200.6:3000/project/11/interface/api/75 统计声音需要更新的数据如下,我们将数据封装到一个实体类**TrackStatVo**中便于操作 在**TrackInfoApiController** 控制器中添加 ```java /** * 查询声音统计信息 * @param trackId * @return */ @Operation(summary = "查询声音统计信息") @GetMapping("/trackInfo/getTrackStatVo/{trackId}") public Result getTrackStatVo(@PathVariable Long trackId){ TrackStatVo trackStatVo = trackInfoService.getTrackStatVo(trackId); return Result.ok(trackStatVo); } ``` **TrackInfoService接口**: ```java /** * 查询声音统计信息 * @param trackId * @return */ TrackStatVo getTrackStatVo(Long trackId); ``` **TrackInfoServiceImpl实现类**: ```java /** * 查询声音统计信息 * @param trackId * @return */ @Override public TrackStatVo getTrackStatVo(Long trackId) { return trackInfoMapper.getTrackStatVo(trackId); } ``` **TrackInfoMapper**.java ```java /** * 根据声音ID查询声音统计信息 * @param trackId * @return */ TrackStatVo getTrackStatVo(@Param("trackId") Long trackId); ``` 映射文件: ```xml ``` # 4、专辑Redis排行榜 手动调用一次更新,查看排行榜。后续会整合xxl-job 分布式定时任务调度框架做定时调用。 ![详情-排行榜](assets/详情-排行榜.gif) > YAPI接口地址:http://192.168.200.6:3000/project/11/interface/api/77 `service-album `微服务中**BaseCategoryApiController**控制器中添加 ```java /** * 查询所有一级分类列表 * * @return */ @Operation(summary = "查询所有一级分类列表") @GetMapping("/category/findAllCategory1") public Result> getAllCategory1() { LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.select(BaseCategory1::getId); List list = baseCategoryService.list(queryWrapper); return Result.ok(list); } ``` **AlbumFeignClient** ```java /** * 查询所有一级分类列表 * * @return */ @GetMapping("/category/findAllCategory1") public Result> getAllCategory1(); ``` **AlbumDegradeFeignClient熔断类**: ```java @Override public Result> getAllCategory1() { log.error("远程调用专辑服务getAllCategory1服务降级"); return null; } ``` > YAPI接口地址:http://192.168.200.6:3000/project/11/interface/api/79 在**SearchApiController** 中添加控制器 ```java /** * 更新不同分类不同排行TOP20数据 * * @return */ @Operation(summary = "更新不同分类不同排行TOP20数据") @GetMapping("/albumInfo/updateLatelyAlbumRanking") public Result updateLatelyAlbumRanking() { searchService.updateLatelyAlbumRanking(); return Result.ok(); } ``` **SearchService**接口: ```java /** * 更新不同分类不同排行TOP20数据 * @return */ void updateLatelyAlbumRanking(); ``` **SearchServiceImpl实现类:** ```java @Autowired private RedisTemplate redisTemplate; /** * 更新不同分类不同排行TOP20数据 * * @return */ @Override public void updateLatelyAlbumRanking() { try { //1.远程调用专辑服务获取所有1级分类获取一级分类ID列表 List baseCategory1List = albumFeignClient.getAllCategory1().getData(); if (CollectionUtil.isNotEmpty(baseCategory1List)) { //2.遍历所有一级分类ID-根据分类ID+排序方式+限制返回文档数量 List category1IdList = baseCategory1List.stream().map(BaseCategory1::getId).collect(Collectors.toList()); for (Long category1Id : category1IdList) { //声明当前分类hash结构Key String key = RedisConstant.RANKING_KEY_PREFIX + category1Id; //处理某个一级分类排行数据,遍历5种不同排序字段 String[] rankingDimensionArray = new String[]{"hotScore", "playStatNum", "subscribeStatNum", "buyStatNum", "commentStatNum"}; for (String rankingDimension : rankingDimensionArray) { SearchResponse searchResponse = elasticsearchClient.search( s -> s.index(INDE_NAME) .query(q -> q.term(t -> t.field("category1Id").value(category1Id))) .sort(sort -> sort.field(f -> f.field(rankingDimension).order(SortOrder.Desc))) .size(20) .source(s1 -> s1.filter(f -> f.includes("id", "albumTitle", "albumIntro", "coverUrl", "includeTrackCount", "playStatNum", "createTime", "payType"))), AlbumInfoIndex.class ); //3.解析当前分类下当前排序方式命中TOP20专辑文档列表 List> hitList = searchResponse.hits().hits(); if (CollectionUtil.isNotEmpty(hitList)) { //3.1 遍历命中记录对象得到Hit对象中中_source(专辑对象) List top20List = hitList.stream() .map(hit -> { AlbumInfoIndex albumInfoIndex = hit.source(); //3.2 处理高亮 Map> highlightMap = hit.highlight(); if (CollectionUtil.isNotEmpty(highlightMap)) { String highlightText = highlightMap.get("albumTitle").get(0); albumInfoIndex.setAlbumTitle(highlightText); } return BeanUtil.copyProperties(albumInfoIndex, AlbumInfoIndexVo.class); }).collect(Collectors.toList()); //4.将不同分类不同维度的TOP20数据放入Redis redisTemplate.opsForHash().put(key, rankingDimension, top20List); } } } } } catch (Exception e) { log.error("[搜索服务]更新排行榜Redis异常:{}", e); throw new RuntimeException(e); } } ``` # 5、获取排行榜 ![image-20231012114420751](assets/image-20231012114420751.png) 点击排行榜的时候,能看到获取排行榜的地址 排行榜:key=ranking:category1Id field = hotScore 或 playStatNum 或 subscribeStatNum 或 buyStatNum 或albumCommentStatNum value=List ![](assets/tingshu016.png) > YAPI接口地址:http://192.168.200.6:3000/project/11/interface/api/81 **SearchApiController** 控制器中添加 ```java /** * 获取不同分类下不同排行维度TOP20 * @param category1Id * @param dimension * @return */ @Operation(summary = "获取不同分类下不同排行维度TOP20") @GetMapping("/albumInfo/findRankingList/{category1Id}/{dimension}") public Result> getRankingList(@PathVariable Long category1Id, @PathVariable String dimension) { List list = searchService.getRankingList(category1Id, dimension); return Result.ok(list); } ``` **SearchService**接口: ```java /** * 获取不同分类下不同排行维度TOP20 * @param category1Id * @param dimension * @return */ List getRankingList(Long category1Id, String dimension); ``` **SearchServiceImpl实现类**: ```java /** * 获取不同分类下不同排行维度TOP20 * * @param category1Id * @param dimension * @return */ @Override public List getRankingList(Long category1Id, String dimension) { //方式一:根据key+filed获取hash的VALUE String key = RedisConstant.RANKING_KEY_PREFIX + category1Id; //List list = (List) redisTemplate.opsForHash().get(key, dimension); //方式二:创建操作hash对象 BoundHashOperations> boundHashOperations = redisTemplate.boundHashOps(key); List list = boundHashOperations.get(dimension); return list; } ```