记录专辑声音列表、声音详情与播放进度处理,梳理跨服务调用和用户身份传递的问题。

本篇要点

  • 查询专辑下的声音列表
  • 分析声音详情数据来源
  • 记录播放进度与远程调用边界

内容回顾

1、根据一级分类Id获取全部分类

2、关键字自动补全功能

  • completion :数据加载到内存中,通过前缀匹配
  • 自动补全接口

3、logstash

ELK

  • 使用logstash收集项目日志信息
  • 把收集到日志信息存储到es里面
  • 使用kibana分析es日志信息

4、专辑详情

  • 基础功能

  • 根据专辑id查询声音列表分析

今天内容

1、根据专辑id查询声音列表(复杂)

流程:

1、根据专辑id获取专辑下面所有声音列表,但是声音包含免费和收费的

2、判断用户是否登录状态,如果用户没有登录:

– 判断如果专辑不是免费的,去掉试看的集数,其他是收费的

3、如果用户当前是登录状态:根据专辑类型判断

– 如果vip免费专辑:判断当前登录用户是否是vip用户,如果不是vip收费,如果是vip但是vip过期了也是收费

– 如果付费专辑:需要收费,但是查询当前用户是否购买专辑或者声音

— 如果用户购买过专辑,专辑里面声音可以免费看

— 如果用户购买过专辑里面某些声音,购买的声音可以免费看

实现

  • 用户没有登录时候
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
//1 根据userId判断当前是否登录
//1.1 如果userId==null,当前没有进行登录
if(userId == null) {
// 如果专辑免费直接观看
// 如果专辑不是免费的,根据tracks_for_free去掉试听集数,其他的是收费的
String payType = albumInfo.getPayType();
//付费类型: 0101-免费、0102-vip免费、0103-付费
if(!"0101".equals(payType)) {
//获取专辑所有声音集合
List<AlbumTrackListVo> allAlbumTrackList = pageInfo.getRecords();
//获取专辑试听集数
Integer tracksForFree = albumInfo.getTracksForFree();
//从所有声音集合过滤掉试听集数,声音表有order_num排序 order_num>试听集数
List<AlbumTrackListVo> trackListVoList =
allAlbumTrackList.stream().filter(albumTrackListVo ->
albumTrackListVo.getOrderNum().intValue()>tracksForFree)
.collect(Collectors.toList());
//trackListVoList集合每个对象中 isShowPaidMark = true
if(!CollectionUtils.isEmpty(trackListVoList)) {
trackListVoList.forEach(albumTrackListVo -> {
// 显示付费通知
// isShowPaidMark=flase免费的 isShowPaidMark=true收费的
albumTrackListVo.setIsShowPaidMark(true);
});
}
}
}
  • 用户登录时候
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
} else {  //1.2 如果登录状态
boolean isNeedPaid = false;

//付费类型: 0101-免费、0102-vip免费、0103-付费
//判断如果专辑vip免费的
if("0102".equals(payType)) {
// 根据当前登录的用户id获取用户信息,判断用户是否开通vip
// 如果用户没有开通vip, 收费
Result<UserInfoVo> userInfoVoResult =
userInfoFeignClient.getUserInfoVo(userId);
UserInfoVo userInfoVo = userInfoVoResult.getData();
Assert.notNull(userInfoVo,"用户信息为空");
Integer isVip = userInfoVo.getIsVip();
if (isVip.intValue() == 0){
isNeedPaid = true;
}

//* 如果用户开通VIP但是过期了,收费
Date vipExpireTime = userInfoVo.getVipExpireTime();
if(isVip.intValue() == 1 && vipExpireTime.before(new Date())) {
isNeedPaid = true;
}
} else if("0103".equals(payType)){ //0103-付费
isNeedPaid = true;
}
}
  • 付费情况处理
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
    //判断如果专辑付费 收费
if(isNeedPaid) {
//* 判断当前用户是否购买专辑,购买过,免费
// 如果用户购买整个专辑,专辑里面所有声音免费
// 如果用户只是购买了专辑某些声音,购买的声音免费的,其他收费
//获取排除试听声音列表
List<AlbumTrackListVo> albumTrackNeedPaidListVoList =
pageInfo.getRecords().stream()
.filter(albumTrackListVo ->
albumTrackListVo.getOrderNum().intValue() > albumInfo.getTracksForFree())
.collect(Collectors.toList());

if(!CollectionUtils.isEmpty(albumTrackNeedPaidListVoList)) {
//albumTrackNeedPaidListVoList获取所有声音id
List<Long> trackIdList =
albumTrackNeedPaidListVoList.stream()
.map(AlbumTrackListVo::getTrackId).collect(Collectors.toList());

//远程调用接口,查询用户购买过的专辑或者声音
Result<Map<Long, Integer>> mapResult =
userInfoFeignClient.userIsPaidTrack(albumId,trackIdList);
//map的key是声音id value:1购买过 0没有购买过
Map<Long, Integer> map = mapResult.getData();
Assert.notNull(map,"为空");
//albumTrackNeedPaidListVoList集合,声音id比较,1:isShowPaidMark = false
albumTrackNeedPaidListVoList.forEach(albumTrackListVo -> {
Long trackId = albumTrackListVo.getTrackId();

/*Integer mark = map.get(trackId);
if(mark == 1) {//购买过
albumTrackListVo.setIsShowPaidMark(false);
} else if(mark==0) { //没有买过
albumTrackListVo.setIsShowPaidMark(true);
}*/

boolean isMark = map.get(trackId)==1?false:true;
albumTrackListVo.setIsShowPaidMark(isMark);
});
}
}
}
  • 远程调用:查询用户是否购买专辑或者声音
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@FeignClient(value = "service-user", fallback = UserInfoDegradeFeignClient.class)
public interface UserInfoFeignClient {

/**
* 根据userId 获取到用户信息
* @param userId
* @return
*/
@GetMapping("api/user/userInfo/getUserInfoVo/{userId}")
Result<UserInfoVo> getUserInfoVo(@PathVariable Long userId);


/**
* 判断用户是否购买声音列表
* @param albumId
* @param trackIdList
* @return
*/
@PostMapping("api/user/userInfo/userIsPaidTrack/{albumId}")
Result<Map<Long, Integer>> userIsPaidTrack(@PathVariable("albumId") Long albumId,
@RequestBody List<Long> trackIdList);

}
1
2
3
4
5
6
7
8
9
10
11
12
13
//根据用户id查询用户是否购买专辑或者声音
@GuiguLogin(required = false)
@Operation(summary = "判断用户是否购买声音列表")
@PostMapping("userIsPaidTrack/{albumId}")
public Result<Map<Long, Integer>> userIsPaidTrack(@PathVariable Long albumId,
@RequestBody List<Long> trackIdList) {
// 获取用户Id
Long userId = AuthContextHolder.getUserId();
// 调用服务层方法
Map<Long, Integer> map = userInfoService.userIsPaidTrack(userId, albumId, trackIdList);
// 返回map 集合数据
return Result.ok(map);
}
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
////根据用户id查询用户是否购买专辑或者声音
@Override
public Map<Long, Integer> userIsPaidTrack(Long userId,
Long albumId,
List<Long> trackIdList) {
//1 根据用户id + 专辑id查询用户是否购买专辑 user_paid_album
LambdaQueryWrapper<UserPaidAlbum> wrapper01 = new LambdaQueryWrapper<>();
wrapper01.eq(UserPaidAlbum::getUserId, userId);
wrapper01.eq(UserPaidAlbum::getAlbumId, albumId);
UserPaidAlbum userPaidAlbum = userPaidAlbumMapper.selectOne(wrapper01);

//2 如果用户购买过专辑,把专辑里面所有声音购买过 map的key声音id value是1
if(userPaidAlbum != null) {
Map<Long, Integer> map = new HashMap<>();
trackIdList.forEach(trackId->{
map.put(trackId,1);
});
return map;
} else {
//3 如果用户没有买过专辑,根据用户id + 声音id查询购买哪些声音 user_paid_track
LambdaQueryWrapper<UserPaidTrack> wrapper02 = new LambdaQueryWrapper<>();
wrapper02.eq(UserPaidTrack::getUserId, userId);
// in (1,2,3)
wrapper02.in(UserPaidTrack::getTrackId, trackIdList);
List<UserPaidTrack> userPaidTrackList =
userPaidTrackMapper.selectList(wrapper02);

// 获取到用户购买声音Id 集合
List<Long> userPaidTrackIdList = userPaidTrackList.stream()
.map(UserPaidTrack::getTrackId)
.collect(Collectors.toList());

Map<Long, Integer> map = new HashMap<>();

// 所有:[1,2,3,4,5] trackIdList
// 购买:[1,2,3] userPaidTrackIdList
//4 根据查询购买声音,和所有声音比较,如果购买过 map的key声音id value是1
// 如果没有购买过 map的key声音id value是0
trackIdList.forEach(trackId->{
boolean contains = userPaidTrackIdList.contains(trackId);
if(contains) { //包含,购买过
// 用户已购买声音
map.put(trackId,1);
} else { //不包含
map.put(trackId,0);
}
});
return map;
}
}

问题

  • 远程调用时候,请求头不会传递token值,造成远程调用时候,无法获取当前userId
  • 第一种:远程调用时候在方法里面直接传递userId
  • 第二种:使用远程调用专用的拦截器,每次远程调用时候,手动把token放到请求头传递

image-20251031113030271

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Component
public class FeignInterceptor implements RequestInterceptor {

public void apply(RequestTemplate requestTemplate){
// 获取请求对象
RequestAttributes requestAttributes =
RequestContextHolder.getRequestAttributes();
//异步编排 与 MQ消费者端 为 null
if(null != requestAttributes) {
ServletRequestAttributes servletRequestAttributes =
(ServletRequestAttributes)requestAttributes;
HttpServletRequest request = servletRequestAttributes.getRequest();
String token = request.getHeader("token");
requestTemplate.header("token", token);
}
}
}

2、声音详情

分析

  • 在专辑详情页面,点击某个声音,进入声音详情界面,播放声音文件(存储到腾讯云里面)

  • 记录当前声音播放进度,比如这一次播放到第10s,下次再播放这个声音从第10s向后播放

  • 首先,使用MongoDB存储声音播放进度

  • 其次,前端每隔10s会一次我们后端接口,记录一次当前播放进度

  • 接口1:获取声音上一次播放记录

image-20251031141039597

  • 接口2:更新(添加)声音播放记录

image-20251031141218930

更新(添加)声音播放记录

  • 前端每隔10s调用一次接口,更新当前播放进度
  • 在service-user模块编写接口

controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Tag(name = "用户声音播放进度管理接口")
@RestController
@RequestMapping("api/user/userListenProcess")
@SuppressWarnings({"all"})
public class UserListenProcessApiController {

@Autowired
private UserListenProcessService userListenProcessService;

//保存(更新)声音播放进度
@GuiguLogin
@Operation(summary = "更新播放进度")
@PostMapping("/updateListenProcess")
public Result
updateListenProcess(@RequestBody UserListenProcessVo userListenProcessVo) {
//获取userId
Long userId = AuthContextHolder.getUserId();
userListenProcessService.saveOrUpdateListenProcess(userId,userListenProcessVo);
return Result.ok();
}
}
  • Redis里面bitmap类型基本使用

  • setbit key名称 偏移量(数字) 值(1/0)

image-20251031145150527

service

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
//保存(更新)声音播放进度
@Override
public void saveOrUpdateListenProcess(Long userId,
UserListenProcessVo userListenProcessVo) {
//1 查询 当前用户id+声音id是否有播放进度
Criteria criteria = Criteria.where("userId").is(userId)
.and("trackId").is(userListenProcessVo.getTrackId());
Query query = new Query(criteria);
UserListenProcess userListenProcess = mongoTemplate.findOne(query,
UserListenProcess.class,
MongoUtil.getCollectionName(
MongoUtil.MongoCollectionEnum.USER_LISTEN_PROCESS,
userId));

//2 如果有播放进度,进行更新
if (userListenProcess != null) {
userListenProcess.setBreakSecond(userListenProcessVo.getBreakSecond());
userListenProcess.setUpdateTime(new Date());

mongoTemplate.save(userListenProcess,
MongoUtil.getCollectionName(
MongoUtil.MongoCollectionEnum.USER_LISTEN_PROCESS,
userId));

} else {//3 如果没有播放进度,第一次播放,添加
userListenProcess = new UserListenProcess();
BeanUtils.copyProperties(userListenProcessVo, userListenProcess);
// 设置Id
userListenProcess.setId(ObjectId.get().toString());
// 设置用户Id
userListenProcess.setUserId(userId);
// 设置是否显示
userListenProcess.setIsShow(1);
// 创建时间
userListenProcess.setCreateTime(new Date());
// 更新时间
userListenProcess.setUpdateTime(new Date());
mongoTemplate.save(userListenProcess,
MongoUtil.getCollectionName(
MongoUtil.MongoCollectionEnum.USER_LISTEN_PROCESS,
userId));
}

//更新声音播放量
//同一个用户,对于同一个声音播放,24小时只是算一次播放
//使用redis实现 bitmap类型
//** redis key:userId 偏移量:声音id value:1
//1 getbit方法根据userId + 声音id 获取数据
String key = "user:track:"+userListenProcessVo.getTrackId() + userId;
Boolean isExist =
redisTemplate.opsForValue().getBit(key,
userListenProcessVo.getTrackId());
//2 如果获取不到,表示是第一次更新播放量,使用setbit方法放数据
if (!isExist) {
//使用setbit方法放数据
// key:userId 偏移量:声音id value:1
redisTemplate.opsForValue().setBit(key,
userListenProcessVo.getTrackId(),
true);
//3 设置key过期时间24小时
redisTemplate.expire(key,24*60*60,TimeUnit.SECONDS);

//4 调用方法更新声音播放量
// 发送消息,更新播放量统计
TrackStatMqVo trackStatMqVo = new TrackStatMqVo();
trackStatMqVo.setBusinessNo(UUID.randomUUID().toString().replaceAll("-",""));
trackStatMqVo.setAlbumId(userListenProcessVo.getAlbumId());
trackStatMqVo.setTrackId(userListenProcessVo.getTrackId());
trackStatMqVo.setStatType(SystemConstant.TRACK_STAT_PLAY);
trackStatMqVo.setCount(1);
rabbitService.sendMessage(MqConst.EXCHANGE_TRACK,
MqConst.ROUTING_TRACK_STAT_UPDATE,
JSON.toJSONString(trackStatMqVo));
}
}

MQ接收端更新播放量

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
@Component
public class TrackReceiver {

@Autowired
private TrackInfoService trackInfoService;

@Autowired
private RedisTemplate redisTemplate;

@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(String content, Message message, Channel channel) {
if(!StringUtils.isEmpty(content)) {
TrackStatMqVo trackStatMqVo =
JSON.parseObject(content, TrackStatMqVo.class);
if(trackStatMqVo != null) {
trackInfoService.updateStat(trackStatMqVo);
}
}

channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//更新声音播放量
@Override
public void updateStat(TrackStatMqVo trackStatMqVo) {
Long trackId = trackStatMqVo.getTrackId();
Integer count = trackStatMqVo.getCount();
String statType = trackStatMqVo.getStatType();

//根据声音id+ 0701获取数据
LambdaQueryWrapper<TrackStat> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(TrackStat::getTrackId, trackId);
wrapper.eq(TrackStat::getStatType, statType);
TrackStat trackStat = trackStatMapper.selectOne(wrapper);

//设置修改值
trackStat.setStatNum(trackStat.getStatNum()+count);

//调用方法更新
trackStatMapper.updateById(trackStat);
}

获取声音播放进度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 获取声音播放的时间
* @param trackId
* @return
*/
@GuiguLogin
@Operation(summary = "获取声音的上次跳出时间")
@GetMapping("/getTrackBreakSecond/{trackId}")
public Result<BigDecimal> getTrackBreakSecond(@PathVariable Long trackId) {
// 获取用户Id
Long userId = AuthContextHolder.getUserId();
// 调用服务层方法
BigDecimal trackBreakSecond =
userListenProcessService.getTrackBreakSecond(userId, trackId);
// 返回数据
return Result.ok(trackBreakSecond);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Override
public BigDecimal getTrackBreakSecond(Long userId, Long trackId) {
// 根据用户Id,声音Id获取播放进度对象
Query query = Query.query(Criteria.where("userId")
.is(userId).and("trackId").is(trackId));
UserListenProcess userListenProcess =
mongoTemplate.findOne(query,
UserListenProcess.class,
MongoUtil.getCollectionName(
MongoUtil.MongoCollectionEnum.USER_LISTEN_PROCESS,
userId));
// 判断
if (null != userListenProcess){
// 获取到播放的跳出时间
return userListenProcess.getBreakSecond();
}
return new BigDecimal("0");
}

完善两个地方

问题一

  • 代码中,实现同一个用户对于同一个声音,24小时只是计算一次播放量
  • 一个用户播放不同声音,redis里面使用相同key,造成过期时间混乱
  • 解决: redis里面的key唯一的,比如根据用户id+声音id构建key
1
String key = "user:track:"+userListenProcessVo.getTrackId() + userId;

问题二

  • 保证RabbitMQ消息幂等性

  • 相同消息发送多次,只是消费一次

image-20251031163143761

  • 解决方案:使用redis里面setnx实现

第一步 通过setnx向redis加数据,key是发送端传递过来业务编号

第二步 如果可以添加成功,证明第一次消费,更新播放量

第三步 如果添加失败,证明消息重复发送了,不进行消费

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
@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(String content, Message message, Channel channel) {
if(!StringUtils.isEmpty(content)) {
TrackStatMqVo trackStatMqVo =
JSON.parseObject(content, TrackStatMqVo.class);
if(trackStatMqVo != null) {
//1 获取发送端传递业务编号
String businessNo = trackStatMqVo.getBusinessNo();
//2 redis添加数据,使用setnx方法
Boolean setIfAbsent = redisTemplate.opsForValue().setIfAbsent(businessNo, 1, 1, TimeUnit.HOURS);
//3 如果添加成功,证明第一次消费,
if(setIfAbsent) {
//更新
trackInfoService.updateStat(trackStatMqVo);
}
}
}

channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
}