feat(music): 实现热门音乐功能并优化性能

- 新增 HotMusicController 提供热门音乐接口
- 实现基于新热度算法的热门音乐列表获取
- 添加手动刷新缓存接口
- 新增 MusicService 接口和 MusicServiceImpl 实现类
- 使用 Redis 缓存热门音乐列表,提高响应速度
- 引入 HotScoreUtil 工具类计算热度分数
- 在 Music 实体中添加点赞数、收藏数、评论数和热度分数字段
- 更新数据库结构,增加相关字段
- 使用异步任务定期更新热门音乐缓存
This commit is contained in:
ikmkj
2025-07-25 21:08:18 +08:00
parent 0f483a2399
commit 7c976b760a
7 changed files with 282 additions and 18 deletions

View File

@@ -2,6 +2,7 @@ package com.test.musichouduan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@@ -11,6 +12,7 @@ import org.springframework.transaction.annotation.EnableTransactionManagement;
@SpringBootApplication
@EnableTransactionManagement
@EnableScheduling
@EnableAsync
public class MusicHouduanApplication {
public static void main(String[] args) {

View File

@@ -0,0 +1,54 @@
package com.test.musichouduan.controller;
import com.test.musichouduan.dto.MusicDTO;
import com.test.musichouduan.dto.Result;
import com.test.musichouduan.service.MusicService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 热门音乐控制器
*/
@RestController
@RequestMapping("/api/hot-music")
public class HotMusicController {
@Autowired
private MusicService musicService;
/**
* 获取基于新热度算法的热门音乐列表
*
* @param limit 限制数量
* @return 响应结果
*/
/**
* 获取基于新热度算法的热门音乐列表
*
* @param limit 限制数量
* @return 响应结果
*/
@GetMapping("/list")
public Result<List<MusicDTO>> getHotMusic(@RequestParam(defaultValue = "10") Integer limit) {
// 调用 service 层的新方法,该方法会优先从 Redis 缓存获取
List<MusicDTO> musicList = musicService.getHotMusic(limit);
return Result.success(musicList);
}
/**
* 手动触发一次热门音乐缓存的更新。
* 主要用于测试或紧急情况。
* @return 响应结果
*/
@GetMapping("/refresh-cache")
public Result<String> refreshHotMusicCache() {
// 异步执行,立即返回,不阻塞当前请求
musicService.updateHotMusicCache();
return Result.success("热门音乐缓存更新任务已触发,请稍后查看结果。");
}
}

View File

@@ -64,6 +64,30 @@ public class Music {
@Column
private Long playCount;
/**
* 点赞数
*/
@Column
private Integer likeCount;
/**
* 收藏数
*/
@Column
private Integer collectCount;
/**
* 评论数
*/
@Column
private Integer commentCount;
/**
* 热度分数
*/
@Column
private Double hotScore;
/**
* 歌手列表
*/

View File

@@ -6,6 +6,7 @@ import com.test.musichouduan.entity.Music;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import java.util.concurrent.CompletableFuture;
/**
* 音乐服务接口
@@ -121,4 +122,15 @@ public interface MusicService {
* @return 音乐实体
*/
Music getById(Long id);
/**
* 定时任务接口,用于更新热门音乐缓存
*/
void scheduleUpdateHotMusic();
/**
* 异步更新热门音乐缓存的接口
* @return CompletableFuture 包装的音乐DTO列表
*/
CompletableFuture<List<MusicDTO>> updateHotMusicCache();
}

View File

@@ -9,21 +9,29 @@ import com.test.musichouduan.entity.Singer;
import com.test.musichouduan.entity.User;
import com.test.musichouduan.entity.UserMusic;
import com.test.musichouduan.exception.BusinessException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.test.musichouduan.repository.*;
import com.test.musichouduan.service.FileService;
import com.test.musichouduan.service.MusicService;
import com.test.musichouduan.util.HotScoreUtil;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.*;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.util.Comparator;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import jakarta.persistence.criteria.Predicate;
import java.util.ArrayList;
@@ -38,9 +46,22 @@ import java.util.stream.Collectors;
@Service
public class MusicServiceImpl implements MusicService {
// Redis 缓存键
private static final String HOT_MUSIC_CACHE_KEY = "music:hot_list";
// Redis 缓存过期时间(小时)
private static final long CACHE_EXPIRATION_HOURS = 2;
// 热门音乐列表缓存数量
private static final int HOT_MUSIC_LIMIT = 50;
@Autowired
private MusicRepository musicRepository;
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private MusicTypeRepository musicTypeRepository;
@@ -146,14 +167,106 @@ public class MusicServiceImpl implements MusicService {
@Override
public List<MusicDTO> getHotMusic(Integer limit) {
// 查询热门音乐
List<Music> musics = musicRepository.findMostPlayed(PageRequest.of(0, limit));
// 1. 尝试从 Redis 缓存中获取热门音乐列表
String hotMusicJson = redisTemplate.opsForValue().get(HOT_MUSIC_CACHE_KEY);
// 转换为DTO
if (StringUtils.hasText(hotMusicJson)) {
try {
// 如果缓存命中,则反序列化并返回
List<MusicDTO> cachedList = objectMapper.readValue(hotMusicJson, new TypeReference<List<MusicDTO>>() {});
// 根据请求的 limit 参数返回相应数量的音乐
return cachedList.stream().limit(limit).collect(Collectors.toList());
} catch (Exception e) {
// 日志记录反序列化失败
System.err.println("Failed to deserialize hot music list from Redis: " + e.getMessage());
}
}
// 2. 如果缓存未命中或反序列化失败,则立即计算并阻塞等待结果
// 这是一个同步调用,确保首次请求能拿到数据
List<MusicDTO> hotList = calculateAndCacheHotMusicSync();
return hotList.stream().limit(limit).collect(Collectors.toList());
}
/**
* 同步计算并缓存热门音乐列表。
* 用于在缓存失效时,立即为用户提供数据。
* @return 计算后的热门音乐列表
*/
private List<MusicDTO> calculateAndCacheHotMusicSync() {
try {
// 调用异步方法并等待其完成
return updateHotMusicCache().get();
} catch (Exception e) {
// 在异常情况下,返回一个降级方案:例如按播放量排序的列表
System.err.println("Failed to calculate hot music list synchronously: " + e.getMessage());
List<Music> musics = musicRepository.findMostPlayed(PageRequest.of(0, HOT_MUSIC_LIMIT));
return musics.stream()
.map(this::convertToDTO)
.collect(Collectors.toList());
}
}
/**
* 定时任务,定期更新热门音乐缓存。
* cron表达式表示每小时的0分执行 (e.g., 1:00, 2:00, 3:00)
*/
@Scheduled(cron = "0 0 * * * ?")
@Override
public void scheduleUpdateHotMusic() {
System.out.println("Scheduled task started: Updating hot music cache...");
updateHotMusicCache();
}
/**
* 异步计算所有音乐的热度并将热度最高的前50首缓存到Redis。
* 使用 @Async 注解使其在独立的线程中执行。
* @return 返回一个 CompletableFuture包含计算后的热门音乐列表
*/
@Async
@Transactional(readOnly = true) // 使用只读事务,提高查询性能
@Override
public CompletableFuture<List<MusicDTO>> updateHotMusicCache() {
// 为了处理大数据量,我们使用分页查询来代替一次性加载所有数据
List<Music> allMusic = new ArrayList<>();
Page<Music> musicPage;
int pageNum = 0;
int pageSize = 500; // 每次查询500条
do {
Pageable pageable = PageRequest.of(pageNum, pageSize);
musicPage = musicRepository.findAll(pageable);
allMusic.addAll(musicPage.getContent());
pageNum++;
} while (musicPage.hasNext());
// 使用并行流来加速计算过程
allMusic.parallelStream().forEach(music -> {
double score = HotScoreUtil.calculateHotScore(music);
music.setHotScore(score);
});
// 对所有音乐按计算出的热度分进行降序排序
List<MusicDTO> sortedHotMusic = allMusic.stream()
.sorted(Comparator.comparing(Music::getHotScore).reversed())
.limit(HOT_MUSIC_LIMIT) // 取热度最高的前50
.map(this::convertToDTO)
.collect(Collectors.toList());
try {
// 将排序后的列表序列化为JSON字符串
String jsonToCache = objectMapper.writeValueAsString(sortedHotMusic);
// 存入Redis并设置过期时间
redisTemplate.opsForValue().set(HOT_MUSIC_CACHE_KEY, jsonToCache, CACHE_EXPIRATION_HOURS, TimeUnit.HOURS);
System.out.println("Hot music cache updated successfully with " + sortedHotMusic.size() + " items.");
} catch (Exception e) {
// 日志记录序列化或Redis操作失败
System.err.println("Failed to cache hot music list to Redis: " + e.getMessage());
}
// 返回一个已完成的 CompletableFuture
return CompletableFuture.completedFuture(sortedHotMusic);
}
@Override
@Transactional

View File

@@ -0,0 +1,51 @@
package com.test.musichouduan.util;
import com.test.musichouduan.entity.Music;
import java.time.Duration;
import java.time.LocalDateTime;
/**
* 热度分数计算工具类
*/
public class HotScoreUtil {
// 定义各种交互的权重
private static final double LIKE_WEIGHT = 1.0;
private static final double COLLECT_WEIGHT = 2.0;
private static final double COMMENT_WEIGHT = 1.5;
// 时间衰减因子中的指数,可以调整以改变时间衰减的速度
private static final double TIME_DECAY_EXPONENT = 1.8;
/**
* 计算单首音乐的热度分数
* 采用类似 Hacker News 的热度算法: 热度 = (交互权重和) / (时间衰减因子)
*
* @param music 音乐实体对象
* @return 计算出的热度分数
*/
public static double calculateHotScore(Music music) {
// 1. 计算交互权重和
// 如果交互数据为 null则默认为 0
long likeCount = music.getLikeCount() != null ? music.getLikeCount() : 0;
long collectCount = music.getCollectCount() != null ? music.getCollectCount() : 0;
long commentCount = music.getCommentCount() != null ? music.getCommentCount() : 0;
double interactionScore = (likeCount * LIKE_WEIGHT) +
(collectCount * COLLECT_WEIGHT) +
(commentCount * COMMENT_WEIGHT);
// 2. 计算时间衰减因子
// (当前时间 - 创建时间)的小时数
LocalDateTime createTime = music.getCreateTime();
LocalDateTime now = LocalDateTime.now();
long hoursElapsed = Duration.between(createTime, now).toHours();
// 为了避免新发布内容因分母过小接近0而导致分数无限大的问题
// 我们给分母加上一个常数这里是2确保其初始值不会太小。
double timeDecayFactor = Math.pow(hoursElapsed + 2, TIME_DECAY_EXPONENT);
// 3. 计算最终热度分数
return interactionScore / timeDecayFactor;
}
}

View File

@@ -61,17 +61,25 @@ CREATE TABLE `comment` (
-- ----------------------------
DROP TABLE IF EXISTS `music`;
CREATE TABLE `music` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '音乐ID',
`name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '音乐名称',
`cover` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '封面URL',
`url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '音乐文件URL',
`lyric` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '歌词',
`duration` int NULL DEFAULT 0 COMMENT '时长(秒)',
`play_count` bigint NULL DEFAULT 0 COMMENT '播放次数',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`album` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '音乐ID',
`name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '音乐名称',
`cover` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '封面URL',
`url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '音乐文件URL',
`lyric` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '歌词',
`duration` int NULL DEFAULT 0 COMMENT '时长(秒)',
`album` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
-- Interaction and Hot Score Fields
`play_count` bigint NULL DEFAULT 0 COMMENT '播放次数',
`like_count` int NULL DEFAULT 0 COMMENT '点赞数',
`collect_count` int NULL DEFAULT 0 COMMENT '收藏数',
`comment_count` int NULL DEFAULT 0 COMMENT '评论数',
`hot_score` double NULL DEFAULT 0.0 COMMENT '热度分数',
-- Timestamps
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '音乐表' ROW_FORMAT = Dynamic;
-- ----------------------------