当前位置: 首页 > news >正文

随心听(OnlineMusic)项目 保姆级教程

目录

整体演示

准备工作

创建 SpringBoot 项目

创建数据库以及表

配置文件

建立实体类

统一返回类型处理

登录模块

后端

登录功能

BCrypt 加密功能

前端

上传音乐模块

后端

上传重复音乐功能

校验 MP3 文件功能

前端

查询音乐列表模块

后端

前端

播放音乐模块

后端

前端

删除音乐模块

后端

前端

批量删除模块

后端

前端

收藏音乐模块

后端

前端

查询收藏音乐模块

后端

前端

删除音乐模块补充

后端

播放收藏音乐模块

前端

移除收藏音乐模块

后端

前端

登录拦截器

错误页面模块

Linux 部署项目

配置文件的修改

数据库的创建

部分源码展示

controller

UserController

MusicController

LoveMusicController

service

UserService

MusicService

LoveMusicService

mapper

UserMapper

MusicMapper

MusicMapper.xml

LoveMusicMapper

LoveMusicMapper.xml

utils

BCryptUtil

Mp3Util

ResponseBodyMessage

config

LoginInterceptor

AppConfig

完整源码

补充功能

注册音乐模块

后端

前端

评论功能模块

后端

前端

推荐音乐模块

准备工作

后端

前端

添加缓存

部署项目


整体演示

登录页面:

输入正确的用户密码后,进入的音乐列表页面:

收藏音乐页面:

上传音乐页面:

前端出错时的错误页面:

好的,展示完了,就让我们开始写代码吧!

准备工作

我的开发环境是 IDEA 2022.1.4 社区版,MySQL 5.7 ,Navicat Premium 15,Postman,Xshell,Visual Studio Code 

创建 SpringBoot 项目

然后点 Next。

刷新下 pom.xml文件 

再点那个右上角的 m 刷一下。

再点那个右上角的 m 刷一下。

把没用的删掉。

改完之后点 OK。

然后新建几个包,分别是 controller,service,mapper,utils,model。

建完包之后,

我们来弄一下建个数据库。

创建数据库以及表

因为是在线音乐播放器,有关两个实体表 user 表和 music 表,

而 user 和 music 是多对多的关系,所以我们还需要一个表来存储 user 和 music 的关系。

然后就是属性设计,就普通 user 和 music 应该有的属性直接设计到里面就行

打开你的 mysql 客户端,然后粘贴下面的东西,放到你的数据库里。

-- 数据库
drop database if exists `online_music`;
create database if not exists `online_music` character set utf8;
-- 使用数据库
use `online_music`;-- ⽤户表
DROP TABLE IF EXISTS online_music.user;
CREATE TABLE online_music.user(`id` INT NOT NULL AUTO_INCREMENT,`user_name` VARCHAR ( 128 ) NOT NULL,`password` VARCHAR ( 128 ) NOT NULL,`delete_flag` TINYINT ( 4 ) NULL DEFAULT 0,`create_time` DATETIME DEFAULT now(),`update_time` DATETIME DEFAULT now(),PRIMARY KEY ( id ),
UNIQUE INDEX user_name_UNIQUE ( user_name ASC )) ENGINE = INNODB DEFAULT
CHARACTER SET = utf8mb4 COMMENT = '用户表';-- 音乐表
drop table if exists online_music.music;
CREATE TABLE online_music.music (`id` int NOT NULL AUTO_INCREMENT,`title` varchar(50) NOT NULL,`singer` varchar(30) NOT NULL,`create_time` DATETIME DEFAULT now(),
--          varchar(13) NOT NULL,`update_time` DATETIME DEFAULT now(),`delete_flag` TINYINT ( 4 ) NULL DEFAULT 0,`url` varchar(1000) NOT NULL,`user_id` int(11) NOT NULL,PRIMARY KEY (id))ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '音乐表';-- 收藏表
DROP TABLE IF EXISTS online_music.love_music;
CREATE TABLE online_music.love_music (`id` int PRIMARY KEY AUTO_INCREMENT,`user_id` int(11) NOT NULL,`music_id` int(11) NOT NULL,`delete_flag` TINYINT ( 4 ) NOT NULL DEFAULT 0,`create_time` DATETIME DEFAULT now(),`update_time` DATETIME DEFAULT now());-- 123 是密码
INSERT INTO user(user_name,password) VALUES("mika","$2a$10$hSQvbzhZdwvq4dm/uVHYQe8eh74CEWz7tHqjYDdlzxLS51UB1E1Qu");
INSERT INTO user(user_name,password) VALUES("zhangsan","$2a$10$hSQvbzhZdwvq4dm/uVHYQe8eh74CEWz7tHqjYDdlzxLS51UB1E1Qu");
INSERT INTO user(user_name,password) VALUES("lisi","$2a$10$hSQvbzhZdwvq4dm/uVHYQe8eh74CEWz7tHqjYDdlzxLS51UB1E1Qu");
INSERT INTO user(user_name,password) VALUES("wangwu","$2a$10$hSQvbzhZdwvq4dm/uVHYQe8eh74CEWz7tHqjYDdlzxLS51UB1E1Qu");
INSERT INTO user(user_name,password) VALUES("zhaoliu","$2a$10$hSQvbzhZdwvq4dm/uVHYQe8eh74CEWz7tHqjYDdlzxLS51UB1E1Qu");

配置文件

然后我们点开配置文件,粘贴下面这个,但是得改一改,

# 配置数据库
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/online_music?characterEncoding=utf8&serverTimezone=UTC
spring.datasource.username=root
spring.datasource.password=1234
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
# 配置xml
mybatis.mapper-locations=classpath:mapper/**Mapper.xml
mybatis.configuration.map-underscore-to-camel-case=true 
#自动驼峰转换
# 配置springboot上传文件的大小,默认每个文件的配置最大为15Mb,单次请求的文件的总数不能大于100Mb
spring.servlet.multipart.max-file-size=15MB
spring.servlet.multipart.max-request-size=100MB
# 配置springboot日志调试模式是否开启
debug=true
# 设置打印日志的级别,及打印sql语句
# 日志级别:trace,debug,info,warn,error
# 基本日志
logging.level.root=INFO
logging.level.com.mika.music.mapper=debug
# 扫描的包:druid.sql.Statement类和frank包
logging.level.druid.sql.Statement=DEBUG
logging.level.com.mika=DEBUG
# 音乐上传路径
music.local.path=D:/MusicProject/

标黑的内容改成你自己的。

建立实体类

然后再建立实体表所对应的实体类。

因为我们有三个实体表,所以要建三个实体类,

分别是 User 类,Music 类,LoveMusic 类

表中的属性与实体类的属性一一对应,直接抄下来就行。

User 类:

@Data
public class User {private Integer id;private String userName;private String password;private Integer deleteFlag;private Date createTime;private Date updateTime;public User() {}public User(String userName, String password) {this.userName = userName;this.password = password;}
}

Music 类:

@Data
public class Music {private Integer id;private String title;private String singer;private String url;private Integer userId;private Integer deleteFlag;private Date createTime;private Date updateTime;public Music(String title, String singer, String url, Integer userId) {this.title = title;this.singer = singer;this.url = url;this.userId = userId;}public Music() {}// 这个是做日期的格式化的public String getCreateTime() {SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");return simpleDateFormat.format(createTime);}
}

LoveMusic 类:

@Data
public class LoveMusic {private Integer id;private Integer userId;private Integer musicId;private Integer deleteFlag;private Date createTime;private Date updateTime;
}

然后再把前端的模板放进 static 目录里。

到这一步,准备工作就完成啦!

然后我们开始正式进入项目模块的设计!

统一返回类型处理

我们先来处理统一返回类型,这是基本上每个项目都会有的标配。

在 utils 包建个类,叫做  ResponseBodyMessage ,就将它作为我们统一结果返回的类。

一般包含三个信息,

分别是 code(状态码,自己定义的),errMsg(错误信息),data(返回的数据)。

再加上 @Data 注解,方便我们的开发,最后再创建个构造方法。

这个模块就做好了,可以顺便设成泛型。(用 Object 代替也问题不大)

源码:

@Data
public class ResponseBodyMessage<T> {private Integer code; // 200成功 -1失败 -2未登录private String errMsg;// 错误信息private T data;public ResponseBodyMessage(Integer code, String errMsg, T data) {this.code = code;this.errMsg = errMsg;this.data = data;}
}

登录模块

在设计请求和响应前,我们可以先思考以下问题:

我们要干的是什么?后端需要什么?

前端能给我们什么?前端需要什么?后端可以给前端返回什么?

很明显,我们现在要实现的是登录功能,我们需要的是用户输入的用户名和密码。

那前端能给我们用户名和密码吗?很明显,当然可以,前端又需要啥呢?

很明显,前端需要我们告诉它,用户登录成功没?

到这一步,我们就下个约定。

我们可以约定 :

下完约定之后,我们就能根据这个,轻松的写代码啦。

先写后端的代码。

后端

登录功能

使用的是 SpringMVC 的架构思想,所以我们先分别建两个类,一个接口

分别是 UserController,UserService,UserMapper

该如何验证用户输入的密码是否正确呢?

我们可以从数据库中查找用户输入的用户名,

然后再来匹配,看看用户输入的密码与数据库的密码是否一样。

我们先来实现 UserMapper,首先给类加上 @Mapper 注解。

根据以上所得,我们可以写一个 select 语句。

实现完 mapper 后,我们可以去实现 UserController。

首先给类加上注解,加 @Slf4j 方便打印日志

然后注入 UserService,按照之前的约定,写对应的 login 方法

这里我们采用 Session 的方式,来存储用户信息。

对前端传来的参数进行校验,判断参数是否为空,为空的话就别登录了,直接返回

如果参数不为空,那么就让 controller 去调用 service,然后 service 去调用 mapper

这里我们先把 controller 逻辑写完

在这里我们可以将 "user_session" 存到一个常量类里,所以我决定创建个常量类。

一般把常量类放在 constants 包下,如果没有 constants 包,那就新建一个。

于是 controller 的代码就变成了这样:

然后我们可以去实现 UserService 了。

还是先给类加上注解,注入 UserMapper,毕竟 service 是去调用数据库的。

然后调用 mapper 的方法即可。

这下后端的代码就写完啦,启动服务,我们用 postman 测试看看。

可以先把其中一个用户的密码改成123,等测试完后端接口没问题后,我们再来实现加密功能。

点 Send 进行测试。

好的,结果没问题,我们再来测测失败情况。

嗯,都没有问题,那我们接下来实现加密功能。

BCrypt 加密功能

先导入这两个相关的依赖

        <!-- security依赖包 (加密)--><dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-web</artifactId></dependency><dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-config</artifactId></dependency>

然后还得在启动类上加上这个:

@SpringBootApplication(exclude = {org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration.class})

不加这个就会不能登录。

我们会用到 BCryptPasswordEncoder 类中的两个方法,

一个是 encode,一个是 matches 方法,一个是用来加密的,一个是用来验证密码是否正确的

我们可以定义一个 BCryptUtil 类,直接把下面代码抄上去就行,

加密我们是用不到的,毕竟我没实现注册功能。

解密的话就是常规参数判断,然后再调用 matches 方法,把参数传过去就行,先是传没加密的明文,然后再是传密文。

@Slf4j
public class BCryptUtil {// 加密public static String encrypt(String inputPassword) {BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();String newPassword = bCryptPasswordEncoder.encode(inputPassword);log.info("BCrypt加密后密码: " + newPassword);return newPassword;}// 解密public static Boolean verify(String inputPassword, String sqlPassword) {if (!StringUtils.hasLength(sqlPassword)) {log.info("数据库密码为空");return false;}return new BCryptPasswordEncoder().matches(inputPassword, sqlPassword);}
}

为了方便我们注入,我们实现一个 AppConfig 类。

这里我发现还没有建 config 包,那就先把 config 包建立,然后再把 AppConfig 类放在下面。

让 AppConfig 实现 WebMvcConfigurer ,加上注解。

然后简单的实现一个 BCryptPasswordEncoder 的 getter 方法,到时候如果我们需要调用会方便很多。

这下,加密功能也就彻底实现啦!

然后我们去修改下  UserController 的代码,将原来的明文比较,换成这个加密功能的解密方法。 

@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {@Autowiredprivate UserService userService;@RequestMapping("/login")public ResponseBodyMessage<Boolean> login(String userName, String password, HttpServletRequest request) {// 1. 进行参数校验// 2. 查询数据库, 对密码进行校验// 3. 密码校验成功,设置 session, 返回结果log.info("login,接收参数 userName: " + userName + ", password: " + password);if (!StringUtils.hasLength(userName) || !StringUtils.hasLength(password)) {return new ResponseBodyMessage<>(-2, "用户或密码不能为空", false);}User user = userService.getUserByName(userName);log.info("login,user: " + user);if (user == null || !BCryptUtil.verify(password, user.getPassword())) {return new ResponseBodyMessage<>(-1, "用户名或者密码错误", false);}user.setPassword("");request.getSession().setAttribute(Constant.USERINFO_SESSION_KEY, user);return new ResponseBodyMessage<>(200, null, true);}}

然后我们再去测试下。可以顺便把之前的明文密码改成密文密码。

没啥问题的话,我们就可以开始写前端代码啦!

前端

先用 vs code 打开 static 文件夹。

找到 script 标签,开始敲代码。

找到登录按钮标签,监视标签情况,如果被点击了的话,就发送 ajax 请求到后端。

然后按照统一结果来处理响应,写出来就是这样子的。

好啦,这样登录模块就写完了!我们立刻启动服务,测试下功能是否正常。

不错嗷,能正常登录。

乱输密码,不输密码也没问题,那么,我们就可以进入下一个模块的实现啦!

上传音乐模块

还是先约定接口,

后端

我们还是先建两个类,一个接口,

分别是 MusicController 类,MusicService 类,MusicMapper 接口。

先从 MusicMapper 下手,因为是上传音乐,所以我们需要写 insert 语句。

给 MusicMapper 加上注解 @Mapper,之后开始敲代码,敲的时候要注意 music 后跟的是数据库中的字段,而 values 后面跟的是 Java 属性。

mapper 写完之后我们来写 MusicController。

加上对应的注解。

然后我们按照之前的约定来写对应的方法。

我们可以写一个 addMusic 方法,参数有两个,分别是音乐本体和歌手名字。

我们需要清楚,上传音乐分为两个步骤:

第一,将音乐上传到本地服务器(存在硬盘中),第二,将音乐信息存储在数据库中。

我们就可以根据这两点展开,首先进行参数校验。

如果参数都不为空,那我们就可以将音乐上传到服务器 了。

而在这之前,我们还需要约定音乐应该上传到哪个路径。

那这个 路径我们就可以放在配置文件中,自定义一个配置项 music.local.path 。

我将它配置为 D:/Music/ ,方便我进行观察。

这样,我们就能敲上传音乐的代码了。

写到这,我发现了两个问题:

1. 如果文件不是 mp3 文件,而是图片改名成 图片.mp3 ,我们还应该上传吗?

2. 如果数据库中已经存在这首音乐,那再次上传还能成功吗?

上传重复音乐功能

我们可以先来解决第二个比较简单的问题。

我的方案是,歌曲可以有翻唱。

也就是说,歌名相同,歌手不同,能上传。

歌名相同,歌手相同,就上传失败,万一人家手快点错,并不想重复上传呢?

此时,就需要知道数据库是否存在这首音乐,以及歌手是否相同了。

也就是说,我们还需在 mapper 写一个通过歌名查询歌曲的功能,

以及需要判断现在登录的上传用户与歌曲的上传用户是否是同一人。

所以我们要回到方法开头,校验用户是否登录。

回到 MusicMapper ,新增一个根据歌名查询歌曲的功能。

因为我删除音乐用的是逻辑删除的方式,所以我顺势写了两种查找。

写完 mapper,回到 controller。

首先得获取歌名。

然后通过歌名来获取音乐详情。

先用 @Autowired 注入 MusicService,然后再调用 musicService 的方法。

跳到 service,先加上 @Service 注解,然后注入 MusicMapper。

顺势把之前写的 MusicMapper 里的对应功能都写对应的方法来调用。

回到 MusicController,继续往下写逻辑。

判断是否在数据库中查找到了音乐详情:

如果找到了,说明音乐在数据库中存在,然后再来判断音乐上传者和用户是否为同一人,如果是同一人,就不能上传,如果不是同一人,就可以在数据库中修改 delete_flag 为 0。

如果音乐不存在,那么就可以继续进行上传音乐。

根据以上逻辑,来写代码。

因为要获得用户信息,所以我们得在方法参数列表新增 request 参数 和 resp 参数

抛下异常。

然后在 MusicMapper 中增加 updateDeleteFlag 方法。

根据音乐 id ,来更新 delete_flag 和 user_id。

再在 MusicService 加上对应方法。

这个上传重复音乐的问题就得到了解决。

校验 MP3 文件功能

接下来再处理第一个问题:校验 mp3 文件。

每一种文件都会有格式,同样的 mp3 文件也会有格式。

参考:

MP3格式音频文件结构解析

MP3文件格式解析

mp3文件格式详解

下面两张图都不是我的,是我截图截过来的。

如图,mp3 文件有个结构叫 ID3V1,它的前三个字节必然包含了 ‘TAG’。

我们可以利用这点来校验文件是不是 mp3 文件。

引入判断 mp3 格式的依赖

        <!-- https://mvnrepository.com/artifact/net.jthink/jaudiotagger --><dependency><groupId>net.jthink</groupId><artifactId>jaudiotagger</artifactId><version>2.2.6-PATHRIK</version></dependency>

我们来写个主要功能是校验文件是否为MP3的工具类,就只是调用里面的两个方法就好了。

public class Mp3Util {// 判断文件是否是曲子public static Boolean isMp3File(String filePath) throws CannotReadException, TagException, InvalidAudioFrameException, ReadOnlyFileException, IOException {File file = new File(filePath);// 将文件转成MP3格式的MP3File audioFile = (MP3File) AudioFileIO.read(file);// 看看该文件是否有 TAG 标签return audioFile.hasID3v1Tag() || audioFile.hasID3v2Tag();}}

这样一来,校验 MP3 文件格式的问题也解决了。

那我们继续来写上传音乐到服务器模块。

上传到服务器就完成了,接下来写音乐上传到数据库。

为了方便,我直接把音乐的 url 写成请求的 url,然后调用下 service 即可。

这样上传音乐的后端就写完了,启动服务,我们先来测试一下。

登录前:

登录后,再发送请求:

再次发送请求:

不传或者只传一个参数:

后端没问题,再来写前端代码。

前端

用 form 表单发请求,写出来是这样子的:

<!DOCTYPE html>
<html><head><meta http-equiv="Content-Type" content="text/html;charset=utf-8"><meta name="viewport"content="width=device-width,initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no" /><!-- 1. 导入CSS的全局样式 --><link href="css/bootstrap.min.css" rel="stylesheet"><!-- 2. jQuery导入,建议使用1.9以上的版本 --><script src="js/jquery-3.1.1.min.js"></script><script src="js/md5.min.js"></script><script src="js/common.js"></script><!-- 3. 导入bootstrap的js文件 --><!--<script src="js/bootstrap.min.js"></script>--><script type="text/javascript"></script><style>#body {background-image: url("images/495.jpg");/*background-size:100% 100%;background-attachment: fixed;*/}</style></head><body background="images/495.jpg"style="background-repeat: no-repeat; background-attachment: fixed; background: size 100% 100%;"><!-- <img src="images/9.png" width="300" height="300"> --><!--enctype="multipart/form-data"--><form method="post" enctype="multipart/form-data" action="/music/upload">文件上传:<input type="file" name="filename" />歌手名: <label><input type="text" name="singer" placeholder="请输入歌手名" /></label><input type="submit" value="上传" /></form>
</body></html>

前端写完了,启动服务测试下。

先登录:

跳到上传音乐界面,输入信息:

点上传,看看服务器和数据库里有没有歌。

再试试只传一个参数:

上传同一首歌:

没啥问题,可以去写下一个模块了。

查询音乐列表模块

还是先约定接口:

查询指定音乐,可以不传参,查的就是所有音乐,传参,查的就是指定音乐

后端

先写 mapper,因为是查询指定音乐,还得考虑没有歌名的情况,所以得写两个查询。

一个是模糊查询,一个是查询所有未删除的音乐。

也可以使用 xml 的方式实现,将两个 sql 合并成一个,大家可以自行尝试,挺简单的。

mapper 写完,我们再去写 controller。

根据参数是否为空,来调用 service 中的不同方法即可。

controller 写完了,再来写 service ,调用下 mapper 就行。

后端写完了,启动服务来测试下。

无参的没问题,看看有参的。

也没问题,那接下来写前端代码。

前端

进到 list.html,找到 script 标签,开始敲代码。

因为查询音乐可能会在多个地方被用到,所以我们可以把它定义成一个方法。

然后在这个方法里写 ajax 发送请求,再进行参数校验,迭代,最后将 html 拼接上即可。

还有就是在点击查询时也要调用刚刚写好的方法,直接拿到歌名然后传参过去即可。

<script type="text/javascript"><!-- 核心代码实现 -->load();function load(musicName) {$.ajax({type: "get",url: "/music/getMusics",data: {"musicName": musicName},success: function (result) {console.log(result);if (result.code == 200 && result.data != null) {var musics = result.data;var finalHtml = '';for (var music of musics) {var url = music.url + ".mp3";finalHtml += '<tr>';finalHtml += '<th> <input id="' + music.id + '" type="checkbox"> </th>';finalHtml += '<td>' + music.title + '</td>';finalHtml += '<td>' + music.singer + '</td>';finalHtml += '<td> <button class="btn btn-primary" onclick="playerSong(\'' + url + '\')"> 播放歌曲 </button> </td>';finalHtml += '<td> <button class="btn btn-primary" onclick="deleteInfo(' + music.id + ')"> 删除 </button>' +'<button class="btn btn-primary" onclick="loveInfo(' + music.id + ')"> 喜欢 </button>' + '</td>';finalHtml += "</tr>"}$("#info").html(finalHtml);}}});}$(function () {$("#submit1").click(function () {var musicName = $("#exampleInputName2").val();load(musicName);});});</script>

这样一来,前端也写好了,启动服务测试下看看。

直接跳转到 list.html 可以发现不再是空白一片,而是有了歌曲。

查询一下:

没什么问题就可以进行下一模块的开发了。

播放音乐模块

后端

直接写 controller,要用到 ResponseEntity 里面的 ok 方法。

先将文件转成字节码,然后再返回即可。

后端写完了,启动服务来测试下。

没问题,来写前端代码。

前端

前端用的播放器是一个大佬写的开源音乐播放器。

链接是下面那个。

开源音乐播放器

按照大佬写的说明来操作就行,可以看大佬写的示例来操作。

把下面这段代码,拷贝到这里

<div style="width: 180px; height: 140px; position:absolute; bottom:10px; right:10px"><script type="text/javascript" src="player/sewise.player.min.js"></script><script type="text/javascript">SewisePlayer.setup({server: "vod",type: "mp3",//这里是默认的一个网址videourl: "http://jackzhang1204.github.io/materials/where_did_time_go.mp3",skin: "vodWhite",//这里需要设置falseautostart: "false",});</script>
</div>

通过看大佬的使用说明,能知道调用大佬写的 toPlay 方法就能播放音乐。

找到 script 标签,开始写 playerSong 方法。

        function playerSong(url) {var name = url.substring(url.lastIndexOf('=') + 1);console.log(name);//url:播放地址 name:歌曲或者视频名称 0:播放的开始时间 true:开始自动播放SewisePlayer.toPlay(url, name, 0, true);}

写完了来测试下,播放前记得把音量调小点。

放着了放着了,没啥问题,那就进行下一个模块的开发!

删除音乐模块

还是先约定接口:

后端

还是先写 mapper ,根据 musicId 来修改 delete_flag 就行。

写完 mapper 后再来写 controller。

先进行参数校验,然后再来调用方法即可。

然后再来写 service ,调用下方法就行。

这样后端就写完了,来测试下。

没问题,再来写前端代码。

前端

进入 list.html ,写 deleteInfo 方法,发送 ajax 请求,最后判断下结果即可。

        function deleteInfo(musicId) {$.ajax({type: "post",url: "/music/delete",data: {"id": musicId},success: function (result) {if (result.code == 200 && result.data == true) {alert("删除成功");location.href = "list.html";} else {alert(result.errMsg);}}});}

前端写完后,来测试下。

觉得所剩歌曲数量太少的话,可以多加几首歌进去。

没啥问题,可以进行下一个模块的开发了。

批量删除模块

还是先约定接口:

后端

还是先写 mapper,根据传过来的 ids 来修改 delete_flag 即可。

这个得用 xml 的方式来实现。

在 resource 目录下新建一个 mapper 文件夹,然后再在 mapper 下新建一个 MusicMapper.xml

把下面这段代码复制到 MusicMapper.xml 中,然后把报错的地方改一改,全限定类名改下,把中间的 update 代码全删了。然后就可以开始写代码了。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.mika.blog.mapper.BlogMapper"><update id="updateBlog">update blog<set><if test="title!=null">title=#{title},</if><if test="content!=null">content=#{content},</if><if test="deleteFlag!=null">delete_flag=#{deleteFlag}</if></set>where id = #{id}</update></mapper>

最后写出来是这样的:

写完 mapper 后,我们来写 controller。

与 删除思路类似,还是先进行参数校验,然后再调用方法,最后根据结果来判断返回值就行。

但是有一点需要注意,就是参数类型为 List 的话得用 @RequestParam 来绑定,不然会默认是数组类型。

写完 controller 之后,我们来写 service,还是调用下 mapper 就行。

这次可以等前端写完再一起测试,直接测的话会报错,会说找不到这个类型的参数。

因为用了 @RequestParam ,又没设置 required,所以不传 id 是肯定会报错的。

前端

跟删除的前端类似。

            // 在全部音乐加载完毕之后再进行删除$.when(load).done(function () {$("#delete").click(function () {var arr = new Array();var i = 0;// 判断 checkbox 是否被勾选,被选中就加入数组 arr$("input:checkbox").each(function () {if ($(this).is(":checked")) {arr[i] = $(this).attr("id");i++;}});console.log(arr);$.ajax({type: "post",url: "/music/deleteSel",data: {"id": arr},success: function (result) {if (result.code == 200 && result.data == true) {alert("删除成功");location.href = "list.html";} else {alert(result.errMsg);}}});});});

写完之后来测试下。

没问题。

收藏音乐模块

还是先约定接口:

可以实现成点一下收藏,再点一下取消收藏。

后端

开新篇了,还是得新建两个类一个接口。

分别是 LoveMusicController 类,LoveMusicService 类,LoveMusicMapper 接口。

分别加上对应的注解,顺便注入依赖。

开始写 mapper ,因为要实现增加收藏,检查是否收藏过,删除收藏,所以得写 3 个 sql 。

分析下插入需要什么参数。

根据上面的信息,写个 insert 和 select 和 delete 语句即可。

写完 mapper 后,去写 controller 了,还是先进行参数判断和判断用户是否登录。

如果校验都成功了,那就判断音乐是否已经收藏过,如果收藏过就取消收藏,如果没收藏过就新增收藏。

写完 controller 后去写 service,调用下 mapper 就行。

后端写完测试下。

登录前:

登录后:

再点一下:

没啥问题就可以去写前端了。

前端

点击喜欢,就调用 loveInfo 方法,发送 ajax 请求即可。

        function loveInfo(musicId) {console.log(musicId);$.ajax({type: "post",url: "/lovemusic/like",data: {"musicId": musicId},success: function (result) {if (result.code == 200 && result.data == true) {alert("收藏成功");} else {alert(result.errMsg);}}});}

前端写完之后来测试看看有没有问题。

没啥问题,这样 list.html 就写完了。

接下来就是写 lovemisc.html 收藏页面了。

查询收藏音乐模块

还是先约定接口:

后端

跟前面写过的查询音乐一样,分两种情况。

第一种,歌名为空,查询指定用户的收藏的所有音乐。

第二种,歌名不为空,查询指定用户的收藏的指定音乐。

所以要写两个 sql,联合查询和模糊查询。

这里就先来写 controller 吧。

还是先进行是否登录判断,然后进行参数判断,根据参数调用不同的方法。

然后写 mapper。

    @Select("select m.* from music m, love_music ml where ml.music_id = m.id and ml.user_id = #{userId} and m.delete_flag = 0")List<Music> getLovedMusics(Integer userId);@Select("select m.* from music m, love_music ml where ml.music_id = m.id and ml.user_id = #{userId} and m.title like CONCAT('%',#{name},'%') and m.delete_flag = 0")List<Music> getLovedMusicsByName(String name, Integer userId);

最后写 service。

后端写完后来测试一下,数据不够可以多收藏几首音乐。

没问题,接下来就可以写前端代码了。

前端

进入收藏音乐页面时要发送 ajax 请求,点击查询时也要发送 ajax 请求。

跟查询音乐前端类似,可以直接把查询音乐的前端代码抄过来,再自己改改。

    <script><!-- 核心代码实现 -->load();function load(musicName) {$.ajax({type: "get",url: "/lovemusic/get",data: {"musicName": musicName},success: function (result) {console.log(result);if (result.code == 200 && result.data != null) {var musics = result.data;var finalHtml = '';for (var music of musics) {var url = music.url + ".mp3";finalHtml += '<tr>';finalHtml += '<td>' + music.title + '</td>';finalHtml += '<td>' + music.singer + '</td>';finalHtml += '<td> <button class="btn btn-primary" onclick="playerSong(\'' + url + '\')"> 播放歌曲 </button> </td>';finalHtml += '<td> <button class="btn btn-primary" onclick="deleteInfo(' + music.id + ')"> 移除 </button> </td>';finalHtml += "</tr>"}$("#info").html(finalHtml);}}});}$(function () {$("#submit1").click(function () {var musicName = $("#exampleInputName2").val();load(musicName);});});</script>

前端写完之后,测试下看看有没有问题。

看起来没啥问题的样子。

但是有一点,现在新增了收藏音乐的页面,在之前写删除音乐的时候,并没有考虑到收藏页面。

所以删除音乐的后端代码得改一改,在删除音乐的时候,要把收藏页面的音乐也给删了。

删除音乐模块补充

后端

先写 LoveMusicMapper,通过 musicId,来删除数据库中的记录。

顺便把批量删除的也一起写了。

用注解的方式来实现指定音乐删除,用 xml 的方式来实现批量收藏音乐删除。

在 resource 的 mapper 下,新建个 LoveMusicMapper.xml 。

抄一抄前面批量删除音乐写过的代码,再手动改改。

写完 mapper 之后,再去补充 MusicController ,记得先将依赖注入。

再去写 LoveMusicService ,调用下 mapper 即可。

然后再来测试下看看功能是否正常。

删除前:

删除后:

没问题,接下来就是完成收藏页面的播放和移除功能了。

播放收藏音乐模块

前端

直接把之前写过的播放音乐的方法和音乐播放器本体给抄到收藏音乐页面。

        function playerSong(url) {var name = url.substring(url.lastIndexOf('=') + 1);console.log(name);//url:播放地址 name:歌曲或者视频名称 0:播放的开始时间 true:开始自动播放SewisePlayer.toPlay(url, name, 0, true);}

抄完之后看看要不要改改,改完之后测试看看。

没问题,能正常播放。

接下来就是最后一个功能了,写完这个再加个登录拦截器,这个项目就正式写完啦!

移除收藏音乐模块

还是先约定接口:

后端

还是先写 mapper,删除指定用户的指定音乐(虽然移出了收藏,但是音乐列表还是会存在这首歌的,别把歌从音乐列表删除了哦)

之前已经写过这个接口了,就在删除音乐模块补充那里,这里我们就直接写 controller 即可。

因为要删除指定用户的指定音乐,所以得先判断用户是否登录和进行参数校验,然后再进行方法的调用即可。

前端

可以直接将之前删除指定音乐的代码抄过来,然后再改改就行。

        function deleteInfo(musicId) {$.ajax({type: "post",url: "/lovemusic/delete",data: {"musicId": musicId},success: function (result) {if (result.code == 200 && result.data == true) {alert("删除成功");location.href = "loveMusic.html";} else {alert(result.errMsg);}}});}

然后进行测试。

没问题,接下来加个拦截器,再改下配置项,就可以部署项目了。

登录拦截器

定义一个  LoginInterceptor 类,实现 HandlerInterceptor 然后重写里面的 preHandle 方法。

直接从 session 里面拿值,如果拿不到就是没登录。

没登录我们就可以使用 sendRedirect 跳转到 login.html,然后不给放行。

是登录状态就放行。

定义完拦截器之后,得注册拦截器了,就在 AppConfig 中去注册,注入依赖。

重写 addInterceptors 方法,注册拦截器,设置生效路径和排除路径即可。

然后测试一下。

在未登录状态下直接访问 list.html,upload.html,loveMusic.html 会跳转到 login.html。

这样就成功了。

错误页面模块

还有最后一个页面,就是 error.html。

可以在 js 目录下建立一个 common.js ,然后写一个前端的 ajaxError 方法,当前端发送错误时就自动跳转到 error.html 页面这样子。

error.html 的逻辑很简单,就是点击返回主页的按钮,弹出个确认框问你要不要返回主页,选确定就会返回主页。

写完 error.html,要在每一个页面的 jquery 后面(顺序不能乱)加上一行代码:

<script src="js/common.js"></script>

这样,每当页面出错时就会自动跳转到 error.html 。

源代码是这样的:

<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="utf-8" /><meta http-equiv="X-UA-Compatible" content="IE=edge" /><meta name="viewport" content="width=device-width, initial-scale=1" /><meta name="viewport"content="width=device-width,initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no" /><title>出错啦!</title><!-- 1. 导入CSS的全局样式 --><link href="css/bootstrap.min.css" rel="stylesheet"><!-- 2. jQuery导入,建议使用1.9以上的版本 --><script src="js/jquery-3.1.1.min.js"></script><script src="js/md5.min.js"></script><script src="js/common.js"></script><!-- 3. 导入bootstrap的js文件 --><!--<script src="js/bootstrap.min.js"></script>--><script type="text/javascript"></script><style>#body {background-image: url("images/like.jpg");/*background-size:100% 100%;background-attachment: fixed;*/}</style><script>$(function () {$("#submit").click(function () {if (confirm("是否要返回主页?")) {location.href = "list.html";}});});</script></head><body background="images/aniya.jpeg"><div class="form-group" style="text-align: center;"><!--class="form-group"--><input style="width: 200px;height: 40px" id="submit" class="btn btn btn-primary" type="button" value="返回主页"></div>
</body></html>

Linux 部署项目

配置文件的修改

觉得要手动改配置文件很麻烦,所以就利用 maven 来帮我们打包时打正确的配置文件。

首先将 application.properties 复制两份出来,然后改名字。

改成这种形式的  application-xxxx.properties

将 dev 后缀的配置文件作为开发环境的配置文件,而 prod 为部署时发布的配置文件。

然后就可以将 application.properties 里面的内容全删掉了。

然后点开 pom.xml 文件。

将下面这段文件配置粘贴到 pom 文件中。

    <profiles><profile><id>dev</id><properties><!--自定义配置--><profile.name>dev</profile.name></properties></profile><profile><id>prod</id><properties><!--自定义配置--><profile.name>prod</profile.name></properties></profile></profiles>

然后刷新一下,就是点右上角的 m。

此时再点开 maven,就会发现有个新的选项:

点开它,就能看到之前的 dev 和 prod。

选谁就能打谁的配置文件。

但是还有一步,就是原来的配置文件也要改一下。

在原来的配置文件加上:

spring.profiles.active=@profile.name@

然后把你的 prod 改一改,包括你的数据库密码啊,存放音乐的路径等等。

这些都得改,改成你云服务器上的。

确定没什么 bug 之后,就是打包,不然有 bug 还要重新打包部署有点麻烦。

把包的路径(到 target 之后就别复制了)复制到文件路径中。

这个就是你刚才打的包,然后点开 Xshell,连上你的云服务器。

建个新的文件夹用来放你的音乐。

切换到你的新文件夹中,查看路径。

把你的路径复制到 idea 的 prod 的 音乐存放路径中,记得多加个 /

然后打包。

数据库的创建

然后回到你的 xshell ,输入 :

mysql -uroot -p

输入正确的密码后,进入你的数据库。

把你的 idea 上的 那个 sql 文件的内容全部复制,然后粘贴到云服务器的数据库里。

粘完之后看看对应的表,数据是否完成,有无错漏。

没啥问题后输入 exit;  退出数据库。

然后找个地方,把你刚刚打好的包拖动到 Xshell 中。

直接把包拖过去。 

之后会弹出这个框。

看到这个,说明你的包传完了 。

不放心的话可以输入 ll 看一看。

接下来就用命令启动服务即可。

输入 :

nohup  命令  &

输入这个: 

会弹出这个。

 然后新开一个窗口,输入 :

ps -aux | grep java

看到下面这张图,也就是存在你的包的名字,那就是部署成功了。

接下来就是查看你云服务器的 ip。

然后用你云服务器的 ip 去访问即可。

最后再测测功能是否正常即可。 

如果点击播放音乐看起来跟没反应一样,这时候别慌。可以按 F12 看看,音乐是否有在下载。

很有可能是你的云服务器网速太慢,音乐下载太慢,才会看起来好像没反应一样。

部分源码展示

controller

UserController

@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {@Autowiredprivate UserService userService;@RequestMapping("/login")public ResponseBodyMessage<Boolean> login(String userName, String password, HttpServletRequest request) {// 1. 进行参数校验// 2. 查询数据库, 对密码进行校验// 3. 密码校验成功,设置 session, 返回结果log.info("login,接收参数 userName: " + userName + ", password: " + password);if (!StringUtils.hasLength(userName) || !StringUtils.hasLength(password)) {return new ResponseBodyMessage<>(-2, "用户或密码不能为空", false);}User user = userService.getUserByName(userName);log.info("login,user: " + user);if (user == null || !BCryptUtil.verify(password, user.getPassword())) {return new ResponseBodyMessage<>(-1, "用户名或者密码错误", false);}user.setPassword("");request.getSession().setAttribute(Constant.USERINFO_SESSION_KEY, user);return new ResponseBodyMessage<>(200, null, true);}}

MusicController

@Slf4j
@RestController
@RequestMapping("/music")
public class MusicController {// 获取本地音乐路径@Value("${music.local.path}")private String SAVE_PATH;@Autowiredprivate MusicService musicService;@Autowiredprivate LoveMusicService loveMusicService;// 音乐 上传到服务器+上传到数据库@RequestMapping("/upload")public ResponseBodyMessage<Boolean> addMusic(@RequestParam String singer, @RequestParam("filename") MultipartFile file, HttpServletRequest request, HttpServletResponse resp) throws IOException {// 检查是否登录HttpSession session = request.getSession(false);if (session == null || session.getAttribute(Constant.USERINFO_SESSION_KEY) == null) {return new ResponseBodyMessage<>(-2, "请登录后再操作", false);}if (!StringUtils.hasLength(singer) || file == null) {return new ResponseBodyMessage<>(-1, "请输入歌手后再来操作!", false);}// 上传到服务器String fileNameAndType = file.getOriginalFilename();// 获取 xxx.mp3log.info("获取路径,fileNameAndType: " + fileNameAndType);if (!StringUtils.hasLength(fileNameAndType)) {return new ResponseBodyMessage<>(-1, "文件名不合法!", false);}// 获取保存文件的绝对路径String path = SAVE_PATH + fileNameAndType;// 获取曲名int point = fileNameAndType.lastIndexOf(".");String title = fileNameAndType.substring(0, point);Music music = musicService.getSongByName2(title);User user = (User) request.getSession().getAttribute(Constant.USERINFO_SESSION_KEY);if ((music != null && music.getUserId() == user.getId()) && music.getDeleteFlag() == 0) {// 相同曲子相同歌手还没删除歌曲return new ResponseBodyMessage<>(-1, "该曲子在曲库中已存在", false);} else if (music != null && music.getDeleteFlag() != 0) {// 曲子存在,但是被逻辑删除了// 将 delete_flag 改为 0Integer result = musicService.updateDeleteFlag(music.getId(), user.getId());if (result > 0) {resp.sendRedirect("/list.html");return new ResponseBodyMessage<>(200, null, true);} else {return new ResponseBodyMessage<>(-1, "服务器上传失败,请联系管理员", false);}}File dest = new File(path);if (!dest.exists()) {// 如果目录不存在,就创建目录dest.mkdir();}try {// 上传文件file.transferTo(dest);} catch (IOException e) {log.error(e.getMessage());return new ResponseBodyMessage<>(-1, "服务器上传失败,请联系管理员", false);}// 判断该文件是否是歌曲try {if (!Mp3Util.isMp3File(path)) {dest.delete();return new ResponseBodyMessage<>(-1, "该文件不是mp3格式", false);}} catch (Exception e) {dest.delete();log.error(e.getMessage());return new ResponseBodyMessage<>(-1, "该文件不是mp3格式", false);}// 数据库上传存路径时,title(歌名) 没有加后缀.mp3String url = "/music/get?path=" + title;Integer result = -1;try {result = musicService.addMusic(new Music(title, singer, url, user.getId()));if (result > 0) {resp.sendRedirect("/list.html");return new ResponseBodyMessage<>(200, null, true);} else {dest.delete();return new ResponseBodyMessage<>(-1, "数据库上传失败,请联系管理员", false);}} catch (Exception e) {dest.delete();log.error(e.getMessage());return new ResponseBodyMessage<>(-1, "数据库上传失败,请联系管理员", false);}}@RequestMapping("/getMusics")public ResponseBodyMessage<List<Music>> getMusics(String musicName) {if (!StringUtils.hasLength(musicName)) {// 音乐名为空,查询所有音乐List<Music> result = musicService.getMusics();return new ResponseBodyMessage<>(200, null, result);}List<Music> result = musicService.getMusicsByName(musicName);return new ResponseBodyMessage<>(200, null, result);}// 播放音乐时,路径为 /music/get?path=xxx.mp3@RequestMapping("/get")public ResponseEntity<byte[]> getMusic(String path) {File file = new File(SAVE_PATH + path);byte[] arr = null;try {arr = Files.readAllBytes(file.toPath());if (arr == null) {return ResponseEntity.badRequest().build();}return ResponseEntity.ok(arr);} catch (IOException e) {log.error(e.getMessage());}return ResponseEntity.badRequest().build();}@RequestMapping("/delete")public ResponseBodyMessage<Boolean> delete(Integer id) {if (id == null || id < 1) {return new ResponseBodyMessage<>(-1, "id 不合法", false);}Integer result = musicService.deleteById(id);if (result > 0) {Integer ret = loveMusicService.deleteLovedByMusicId(id);return new ResponseBodyMessage<>(200, null, true);}return new ResponseBodyMessage<>(-1, "删除失败", false);}@RequestMapping("/deleteSel")public ResponseBodyMessage<Boolean> deleteSelMusic(@RequestParam("id[]") List<Integer> id) {if (id == null || id.size() <= 0) {log.error("ids: " + id);return new ResponseBodyMessage<>(-1, "请选中歌曲后再来删除", false);}Integer result = musicService.deleteByIds(id);if (result > 0) {Integer ret = loveMusicService.deleteSelLoved(id);return new ResponseBodyMessage<>(200, null, true);}return new ResponseBodyMessage<>(-1, "批量删除失败", false);}
}

LoveMusicController

@Slf4j
@RestController
@RequestMapping("/lovemusic")
public class LoveMusicController {@Autowiredprivate LoveMusicService loveMusicService;@RequestMapping("/like")public ResponseBodyMessage<Boolean> like(Integer musicId, HttpServletRequest request) {// 检查是否登录HttpSession session = request.getSession(false);if (session == null || session.getAttribute(Constant.USERINFO_SESSION_KEY) == null) {return new ResponseBodyMessage<>(-2, "请登录后再操作", false);}User user = (User) session.getAttribute(Constant.USERINFO_SESSION_KEY);Integer userId = user.getId();log.info("userId: " + userId + ", musicId: " + musicId);if (userId == null || musicId == null || userId < 1 || musicId < 1) {return new ResponseBodyMessage<>(-1, "userId 或 musicId 不合法", false);}// 1. 检查是否已经收藏过该音乐Music ret = loveMusicService.checkLoved(musicId, userId);if (ret != null) {Integer result = loveMusicService.deleteLoved(musicId, userId);return new ResponseBodyMessage<>(-1, "取消收藏成功", false);}// 2. 添加音乐至收藏列表Integer result = loveMusicService.insert(userId, musicId);if (result > 0) {return new ResponseBodyMessage<>(200, null, true);}return new ResponseBodyMessage<>(-1, "收藏失败", false);}// 查找指定用户的收藏列表中的指定歌曲@RequestMapping("/get")public ResponseBodyMessage<List<Music>> getMusic(String musicName, HttpServletRequest request) {// 检查是否登录HttpSession session = request.getSession(false);if (session == null || session.getAttribute(Constant.USERINFO_SESSION_KEY) == null) {return new ResponseBodyMessage<>(-2, "请登录后再操作", null);}User user = (User) session.getAttribute(Constant.USERINFO_SESSION_KEY);Integer userId = user.getId();log.info("userId: " + userId);if (userId == null || userId < 1) {return new ResponseBodyMessage<>(-1, "userId 或 musicId 不合法", null);}// 如果歌名为空,则查询所有列表if (!StringUtils.hasLength(musicName)) {return new ResponseBodyMessage<>(200, null, loveMusicService.getLovedMusics(userId));}List<Music> ret = loveMusicService.getLovedMusicsByName(musicName, userId);if (ret == null || ret.size() <= 0) {return new ResponseBodyMessage<>(-1, "没有你要找的歌曲", ret);}return new ResponseBodyMessage<>(200, null, ret);}// 取消收藏@RequestMapping("/delete")public ResponseBodyMessage<Boolean> deleteLovedMusic(Integer musicId, HttpServletRequest request) {// 检查是否登录HttpSession session = request.getSession(false);if (session == null || session.getAttribute(Constant.USERINFO_SESSION_KEY) == null) {return new ResponseBodyMessage<>(-2, "请登录后再操作", null);}if (musicId == null || musicId < 1) {return new ResponseBodyMessage<>(-1, "userId 或 musicId 不合法", null);}User user = (User) session.getAttribute(Constant.USERINFO_SESSION_KEY);Integer userId = user.getId();log.info("userId: " + userId);if (userId == null || userId < 1) {return new ResponseBodyMessage<>(-1, "userId 或 musicId 不合法", null);}Integer result = loveMusicService.deleteLoved(musicId, userId);if (result > 0) {return new ResponseBodyMessage<>(200, null, true);}return new ResponseBodyMessage<>(-1, "移除音乐失败", false);}
}

service

UserService

@Service
public class UserService {@Autowiredprivate UserMapper userMapper;public User getUserByName(String userName) {return userMapper.getUserByName(userName);}
}

MusicService

@Service
public class MusicService {@Autowiredprivate MusicMapper musicMapper;public Integer addMusic(Music music) {return musicMapper.insert(music);}// 查询没被删除的音乐public Music getSongByName1(String title) {return musicMapper.getSongByName1(title);}// 能查询所有音乐(包括被删除的)public Music getSongByName2(String title) {return musicMapper.getSongByName2(title);}// 增加曲库中已有的歌曲public Integer updateDeleteFlag(Integer musicId, Integer userId) {return musicMapper.updateDeleteFlag(musicId, userId);}public List<Music> getMusics() {return musicMapper.getMusics();}public List<Music> getMusicsByName(String musicName) {return musicMapper.getMusicsByName(musicName);}public Integer deleteById(Integer id) {return musicMapper.deleteById(id);}public Integer deleteByIds(List<Integer> ids) {return musicMapper.deleteByIds(ids);}}

LoveMusicService

@Service
public class LoveMusicService {@Autowiredprivate LoveMusicMapper loveMusicMapper;public Integer insert(Integer userId, Integer musicId) {return loveMusicMapper.insert(userId, musicId);}public Music checkLoved(Integer musicId, Integer userId) {return loveMusicMapper.checkLoved(musicId, userId);}public Integer deleteLoved(Integer musicId, Integer userId) {return loveMusicMapper.deleteLoved(musicId, userId);}public List<Music> getLovedMusics(Integer userId) {return loveMusicMapper.getLovedMusics(userId);}public List<Music> getLovedMusicsByName(String name, Integer userId) {return loveMusicMapper.getLovedMusicsByName(name, userId);}public Integer deleteLovedByMusicId(Integer musicId) {return loveMusicMapper.deleteLovedByMusicId(musicId);}public Integer deleteSelLoved(@RequestParam List<Integer> musicIds) {return loveMusicMapper.deleteSelLoved(musicIds);}}

mapper

UserMapper

@Mapper
public interface UserMapper {@Select("select * from user where user_name = #{userName} and delete_flag = 0")User getUserByName(String userName);}

MusicMapper

@Mapper
public interface MusicMapper {@Insert(" insert into music (title,singer,url,user_id) values(#{title},#{singer},#{url},#{userId})")Integer insert(Music music);// // 查询曲库中指定音乐(未删除的)@Select("select * from music where delete_flag = 0 and title = #{title}")Music getSongByName1(String title);// 查询曲库中指定音乐(包括删除和未删除)@Select("select * from music where title = #{title}")Music getSongByName2(String title);@Update("update music set delete_flag = 0 , user_id = #{userId} where id = #{id}")Integer updateDeleteFlag(Integer id, Integer userId);@Select("select * from music where delete_flag = 0")List<Music> getMusics();@Select("select * from music where title like concat('%', #{title}, '%') and delete_flag = 0")List<Music> getMusicsByName(String title);@Update("update music set delete_flag = 1 where id = #{id}")Integer deleteById(Integer id);Integer deleteByIds(List<Integer> ids);
}

MusicMapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.mika.music.mapper.MusicMapper"><update id="deleteByIds">update musicsetdelete_flag = 1where id in<foreach collection="ids" separator="," item="id" open="(" close=")">#{id}</foreach></update></mapper>

LoveMusicMapper

public interface LoveMusicMapper {@Insert("insert into love_music(user_id,music_id) values(#{userId},#{musicId})")Integer insert(Integer userId, Integer musicId);@Select("select * from love_music where music_id = #{musicId} and user_id = #{userId}")Music checkLoved(Integer musicId, Integer userId);@Delete("delete from love_music where music_id = #{musicId} and user_id = #{userId}")Integer deleteLoved(Integer musicId, Integer userId);@Select("select m.* from music m, love_music ml where ml.music_id = m.id and ml.user_id = #{userId} and m.delete_flag = 0")List<Music> getLovedMusics(Integer userId);@Select("select m.* from music m, love_music ml where ml.music_id = m.id and ml.user_id = #{userId} and m.title like CONCAT('%',#{name},'%') and m.delete_flag = 0")List<Music> getLovedMusicsByName(String name, Integer userId);@Delete("delete from love_music where music_id = #{musicId}")Integer deleteLovedByMusicId(Integer musicId);Integer deleteSelLoved(@RequestParam List<Integer> musicIds);
}

LoveMusicMapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.mika.music.mapper.LoveMusicMapper"><delete id="deleteSelLoved">delete from love_musicwhere music_id in<foreach collection="musicIds" separator="," item="id" open="(" close=")">#{id}</foreach></delete></mapper>

utils

BCryptUtil

@Slf4j
@Component
public class BCryptUtil {// 加密public static String encrypt(String inputPassword) {BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();String newPassword = bCryptPasswordEncoder.encode(inputPassword);log.info("BCrypt加密后密码: " + newPassword);return newPassword;}// 解密public static Boolean verify(String inputPassword, String sqlPassword) {if (!StringUtils.hasLength(sqlPassword)) {log.info("数据库密码为空");return false;}return new BCryptPasswordEncoder().matches(inputPassword, sqlPassword);}
}

Mp3Util

public class Mp3Util {// 判断文件是否是曲子public static Boolean isMp3File(String filePath) throws CannotReadException, TagException, InvalidAudioFrameException, ReadOnlyFileException, IOException {File file = new File(filePath);// 将文件转成MP3格式的MP3File audioFile = (MP3File) AudioFileIO.read(file);// 看看该文件是否有 TAG 标签return audioFile.hasID3v1Tag() || audioFile.hasID3v2Tag();}}

ResponseBodyMessage

@Data
public class ResponseBodyMessage<T> {private Integer code; // 200成功 -1失败 -2未登录private String errMsg;// 错误信息private T data;public ResponseBodyMessage(Integer code, String errMsg, T data) {this.code = code;this.errMsg = errMsg;this.data = data;}
}

config

LoginInterceptor

@Slf4j
@Configuration
public class LoginInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {HttpSession session = request.getSession(false);if (session == null || session.getAttribute(Constant.USERINFO_SESSION_KEY) == null) {log.error("用户未登录");response.sendRedirect("/login.html");return false;}return true;}
}

AppConfig

@Configuration
public class AppConfig implements WebMvcConfigurer {@Autowiredprivate LoginInterceptor loginInterceptor;// 登录拦截器@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(loginInterceptor).addPathPatterns("/**")//排除所有的JS.excludePathPatterns("/js/**.js")//排除images下所有的元素.excludePathPatterns("/images/**").excludePathPatterns("/css/**.css").excludePathPatterns("/fronts/**").excludePathPatterns("/player/**").excludePathPatterns("/login.html").excludePathPatterns("/error.html")//排除登录接口.excludePathPatterns("/user/login");}@Beanpublic BCryptPasswordEncoder getBCryptPasswordEncoder() {return new BCryptPasswordEncoder();}}

完整源码

链接1:Gitee源码

链接2:Github源码

======== 分割线 ==========

因为觉得项目还有能够优化的地方,所以我后面又在原有项目的基础上,添加了一些功能。

分别有:注册功能,评论功能,推荐音乐功能

补充功能

注册音乐模块

这个很简单,还是先约定前后端接口。

后端

按照上面的约定来写就行,更之前写过的登录功能差不多,还是先进行参数的校验,然后再去看看数据库中是否已经存在 userName 用户,如果用户名已经被注册过了,那就注册失败,则返回 false 给前端,如果是没注册过的新用户,那么就写个新增用户的 sql,然后调用,再返回 true 即可。

还是先写 UserMapper,然后让 UserService 调用 UserMapper。

然后再来写 controller,按照刚刚的约定,以及思路来写即可。

    @RequestMapping("/register")public ResponseBodyMessage<Boolean> register(String userName, String password) throws JsonProcessingException {// 1. 进行参数校验// 2. 查询数据库,看看用户名是否被注册// 3. 如果用户名没被注册,则根据用户输入的密码来生成密文// 4. 调用新增用户方法。log.info("register,接收参数 userName: " + userName + ", password: " + password);if (!StringUtils.hasLength(userName) || !StringUtils.hasLength(password)) {return new ResponseBodyMessage<>(-2, "用户或密码不能为空", false);}User user = userService.getUserByName(userName);log.info("register,user: " + user);if (user == null) {// 如果用户名没被注册,则需要新增用户User registerUser = new User(userName, BCryptUtil.encrypt(password));Integer result = userService.addUser(registerUser);if (result <= 0) {return new ResponseBodyMessage<>(-1, "注册失败,请联系管理员", false);}} else {// 说明用户名已经被注册,应该提醒用户换一个用户名注册。return new ResponseBodyMessage<>(-1, "用户名已经被注册", false);}return new ResponseBodyMessage<>(200, null, true);}

最后还要记得在 AppConfig 中的登录拦截器里排除这个接口。

写完之后运行程序,用 postman 来测试一下看看是否正确。

现在表中有这几条数据:

ok,没什么问题的话,就可以去写前端了。

前端

这个也很简单,直接复制一下之前写过的 login.html ,然后再改一下就行。

还需要在登录页面也添加一个注册按钮,在用户点击注册的时候跳转到注册页面。

写完之后来测试一下。

点击确定就能跳到登录页。

点登录后,能成功进来,没什么问题。

OK,注册功能就写完了,接下来写评论功能。

评论功能模块

首先来设计表,一首歌可以有多条评论,而同一时间同一用户同一条评论只能出现在一首各种,所以歌曲和评论是一对多的关系。那我们就可以创建一张消息表,一条评论包含:消息 id,发送用户 id,歌曲 id,评论内容,评论创建时间。

写出来就是这样的,直接粘贴到 db.sql 中,然后再在 mysql 里运行即可。

-- 评论表
DROP TABLE IF EXISTS online_music.comment;
CREATE TABLE online_music.comment (`id` int PRIMARY KEY AUTO_INCREMENT,`user_id` int(11) NOT NULL,`music_id` int(11) NOT NULL,`message` VARCHAR ( 60 ) NOT NULL,`delete_flag` TINYINT ( 4 ) NOT NULL DEFAULT 0,`create_time` DATETIME DEFAULT now(),`update_time` DATETIME DEFAULT now());

而关于评论功能,必定有发布评论以及展示评论,所以至少得写两条 sql。

而因为一首歌评论可能有很多,所以我决定分页展示评论。(ps: 如果一首歌评论达到了 10w+,那就可以单独为这首歌创建一个评论表。)

还是先约定前后端交互接口。

后端

为了实现上述功能,我们需要创建实体类 Comment,CommentController,CommentService,CommentMapper。

Comment 类就照抄表里的字段,然后写一个构造方法就行,然后还需要写一个格式化时间的方法。

@Data
public class Comment {// 评论表private Integer id;private Integer userId;private String userName;private Integer musicId;private String musicName;private String message;private Integer deleteFlag;private Date createTime;private Date updateTime;public Comment() {}public String getCreateTime() {SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");// 使日期按照地区显示,这样才能够得到准确的时间TimeZone zone = TimeZone.getTimeZone("MST");simpleDateFormat.setTimeZone(zone);return simpleDateFormat.format(createTime);}public Comment(Integer userId, Integer musicId, String message) {this.userId = userId;this.musicId = musicId;this.message = message;}
}

先来写 CommentController ,按照之前的约定来写。

首先得写两个方法。

为了方便返回,可以创建个类来存放关于评论列表以及评论总页数的信息,并写个构造方法。

@Data
public class Comments {// 用来记录多条评论列表,以及当前歌曲的总评论页数private List<Comment> commentList;private Integer totalPage;// 每页评论展示数量private Integer commentsPerPage;public Comments(List<Comment> commentList, Integer totalPage, Integer commentsPerPage) {this.commentList = commentList;this.totalPage = totalPage;this.commentsPerPage = commentsPerPage;}public Comments() {}
}
    // 如果某一首歌的评论非常多,达到了 10w 条,那就可以单独为这首歌创建一张表@RequestMapping("/addComment")public ResponseBodyMessage<Boolean> addComment(@RequestParam String commentStr, @RequestParam String songName, HttpServletRequest request) {}@RequestMapping("/getCommentList")public ResponseBodyMessage<Comments> getCommentList(@RequestParam String songName, Integer page, Integer commentsPerPage) {}

 先来写简单的 addComment 方法,首先需要判断用户是否登录,然后进行参数校验,然后需要根据音乐名查找音乐,看看数据库中是否存在这首音乐,如果不存在,就直接返回 false,如果存在,则进行构造评论,然后新增评论,新增成功,最后返回 true 即可。

写出来就是这个样子的:

    // 如果某一首歌的评论非常多,达到了 10w 条,那就可以单独为这首歌创建一张表@RequestMapping("/addComment")public ResponseBodyMessage<Boolean> addComment(@RequestParam String commentStr, @RequestParam String songName, HttpServletRequest request) {HttpSession session = request.getSession(false);if (session == null || session.getAttribute(Constant.USERINFO_SESSION_KEY) == null) {return new ResponseBodyMessage<>(-2, "您尚未登录!", false);}if (!StringUtils.hasLength(commentStr) || !StringUtils.hasLength(songName)) {return new ResponseBodyMessage<>(-1, "评论内容不能为空!", false);}if (commentStr.length() > 50) {return new ResponseBodyMessage<>(-1, "评论内容大于 50 字,评论失败", false);}// 先根据歌名找到歌曲Music music = musicService.getSongByName(songName);if (music == null) {return new ResponseBodyMessage<>(-1, "你想评论的歌曲不存在", false);}User user = (User) session.getAttribute(Constant.USERINFO_SESSION_KEY);Comment comment = new Comment(user.getId(), music.getId(), commentStr);// 新增评论Integer result = commentService.addComment(comment);if (result > 0) {return new ResponseBodyMessage<>(200, null, true);}return new ResponseBodyMessage<>(-1, "评论失败,请联系管理员", false);}

然后再写 getCommentList 方法,还是先进行参数校验,然后根据音乐名获取音乐 id,如果音乐不存在就直接返回 null,如果音乐存在,我们来看看该怎么获取某一页的评论。

评论的展示是按照时间逆序来的。

假设一首歌评论有 4 条,分别如下:

假设每一页展示 1 条评论,当前页数是 1,那么此时总体情况如下:

假设每一页展示 2 条评论,当前页数是 1,那么此时总体情况如下:

假设每一页展示 3 条评论,当前页数是 1,那么此时总体情况如下:

观察如上情况,能够发现当每一页能存放数据不同,而每个第一页的下标范围也不同,0-2。

推算一下就能发现,每一页展示数据的开始下标就是 (curPage - 1) * 每页存放数据的数量。

那么我们只要计算开始出下标,然后再 limit 每页存放数据的数量,就能得到这一页的评论。

那就是计算评论开始下标,然后根据 musicId 和评论开始下标以及每页评论存放数量,来获取这页的评论集合,最后还需要计算评论总页数,评论总页数就等于这首歌评论总数/每页评论存放数量,然后根据其取模来是否要加一。然后构造 comments,最后返回即可。

@RequestMapping("/getCommentList")public ResponseBodyMessage<Comments> getCommentList(@RequestParam String songName, Integer page, Integer commentsPerPage) {if (!StringUtils.hasLength(songName)) {return new ResponseBodyMessage<>(-1, "歌曲名为空", null);}if (page <= 0 || commentsPerPage <= 0) {// 如果当前页数小于等于 0,或者每页展示数量小于等于 0,则提醒参数非法return new ResponseBodyMessage<>(-1, "要获取的当前评论页数或者每页展示评论数不合法", null);}// 根据音乐名获取音乐 IDMusic music = musicService.getSongByName(songName);if (music == null) {return new ResponseBodyMessage<>(-1, "歌曲不存在", null);}Integer startIndex = (page - 1) * commentsPerPage;// 根据 musicId 来获取当前页的评论列表List<Comment> commentList = commentService.getComments(music.getId(), startIndex, commentsPerPage);// 获取这首歌的评论总数Integer totalCommentCount = commentService.getCommentCountsByMusicId(music.getId());// 计算总页数Integer totalPage = (totalCommentCount % commentsPerPage == 0) ? (totalCommentCount / commentsPerPage) : (totalCommentCount / commentsPerPage + 1);Comments comments = new Comments(commentList, totalPage, commentsPerPage);log.info("接收参数" + comments);return new ResponseBodyMessage<>(200, null, comments);}

 然后来写 CommentMapper 和 CommentService,需要写:新增评论和获取某页评论,根据音乐 id 获取所有评论(因为要获得评论总页数,分页功能),根据音乐 id 删除所有评论(删除音乐的时候需要用到) 这几个接口。

@Mapper
public interface CommentMapper {@Insert("insert into comment(user_id, music_id, message) values(#{userId}, #{musicId}, #{message})")Integer addComment(Comment comment);@Select("select c.*, u.user_name , m.title as musicName from `comment` as c, `user` as u , music as m where c.user_id = u.id and c.music_id = #{musicId} and c.music_id = m.id order by c.create_time desc limit #{commentsPerPage} offset #{startIndex}")List<Comment> getComments(Integer musicId, Integer startIndex, Integer commentsPerPage);@Select("select count(*) from comment where music_id = #{musicId}")Integer getCommentCountsByMusicId(Integer musicId);@Delete("delete from comment where music_id = #{musicId}")Integer deleteCommentByMusicId(Integer musicId);
}

 然后在 service 中注入 mapper,并调用对应的方法即可。

@Service
public class CommentService {@Autowiredprivate CommentMapper commentMapper;public Integer addComment(Comment comment) {return commentMapper.addComment(comment);}public List<Comment> getComments(Integer id, Integer startIndex, Integer commentsPerPage) {return commentMapper.getComments(id, startIndex, commentsPerPage);}public Integer getCommentCountsByMusicId(Integer id) {return commentMapper.getCommentCountsByMusicId(id);}public Integer deleteCommentByMusicId(Integer id) {return commentMapper.deleteCommentByMusicId(id);}
}

写完之后,就可以来测试一下。

先测试下新增评论接口。

没登录:

登录后:

多测几条,然后再来测试下展示评论接口。

没问题,按照时间逆序,第一页的话就是 3 和 2。

接下来就可以去写前端啦。

前端

然后要在前端新增一个页面,关于歌曲评论的页面,一进页面,就需要从数据库中根据当前歌曲的 id,查找消息表,然后按照时间逆序来排序,获取消息 list,然后返回给前端,让前端来处理,然后把消息 list 展示到页面上。

首先需要创建个页面来展示每首歌的评论。可以利用 musicId 来区别每首歌的页面。

效果如下:

可以在展示歌曲列表的页面,在每首歌后面多添加一个评论按钮,然后写一个点击事件,如果评论被点击,则会跳转到 comment.html?歌曲 id=n

所以需要修改一下 list.html 的 load 函数。

    function load(musicName) {$.ajax({type: "get",url: "/music/getMusics",data: {"musicName": musicName},success: function (result) {console.log(result);if (result.code == 200 && result.data != null) {var musics = result.data;var finalHtml = '';for (var music of musics) {var url = music.url + ".mp3";finalHtml += '<tr>';finalHtml += '<th> <input id="' + music.id + '" type="checkbox"> </th>';finalHtml += '<td>' + music.title + '</td>';finalHtml += '<td>' + music.singer + '</td>';finalHtml += '<td> <button class="btn btn-primary" onclick="playerSong(\'' + url + '\')"> 播放歌曲 </button> </td>';finalHtml += '<td> <button class="btn btn-primary" onclick="deleteInfo(' + music.id + ')"> 删除 </button>' +'<button class="btn btn-primary" onclick="loveInfo(' + music.id + ')"> 喜欢 </button>'+ '<button class="btn btn-primary" onclick="comment(' + music.id + ')"> 评论 </button>' + '</td>';finalHtml += "</tr>"}$("#info").html(finalHtml);}}});}

然后再按照刚刚的效果图搭建 comment.html 页面:

<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"><meta name="viewport"content="width=device-width,initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no" /><title>歌曲评论页面</title><style>.comment-container {max-width: 800px;margin: 0 auto;padding: 20px;border: 1px solid #ccc;border-radius: 5px;}.song-title {text-align: center;margin-bottom: 20px;}.comment-input-area {display: flex;margin-bottom: 20px;}.comment-input {flex-grow: 1;padding: 10px;border: 1px solid #ccc;border-radius: 5px;font-size: 16px;resize: none;}.comment-button {margin-left: 10px;padding: 10px 20px;background-color: #007bff;color: #fff;border: none;border-radius: 5px;cursor: pointer;}.comment-list {margin-bottom: 20px;}.comment-user {font-weight: bold;margin-right: 10px;}.comment-item {border-bottom: 1px solid #ccc;padding: 10px 0;display: flex;justify-content: space-between;align-items: center;}.comment-text {word-wrap: break-word;flex-grow: 1;}.comment-time {font-size: 0.9em;color: #888;margin-left: 10px;}.pagination {text-align: center;}.pagination button {padding: 5px 15px;border: 1px solid #ccc;border-radius: 5px;background-color: #f8f9fa;cursor: pointer;margin: 0 5px;}.pagination button.active {background-color: #007bff;color: #fff;}</style><script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
</head><body><div class="comment-container"><h1 class="song-title" id="songTitle">歌曲名称</h1><div class="comment-input-area"><textarea class="comment-input" id="commentInput" maxlength="50" placeholder="请输入您的评论 (最多 50 字)"></textarea><button class="comment-button" id="submitComment">发送</button></div><div class="comment-list"><!-- 评论项将通过 JavaScript 插入 --></div><div class="pagination"><!-- 分页按钮将通过 JavaScript 插入 --></div></div><script>// 获取音乐名字, 以及初始化页面function getSongName() {const urlParams = new URLSearchParams(window.location.search);const musicId = urlParams.get("musicId");// 发送 ajax 请求,通过 musicId 来获取音乐名,并赋值到 songTitle 中}getSongName();const commentsPerPage = 5; // 每页显示的评论数let currentPage = 1;const songTitleElement = document.getElementById('songTitle');const commentList = document.querySelector('.comment-list');const paginationContainer = document.querySelector('.pagination');// 获取评论列表的function fetchComments(page) {var songTitle = songTitleElement.textContent;// 对歌名进行处理var lastIndex = songTitle.lastIndexOf("评");songTitle = songTitle.substring(0, lastIndex);// 左闭右开}// 展示评论到页面上function renderComments(comments) {console.log(comments);commentList.innerHTML = '';// 判断当前评论数量是否为空let hasComments = false;comments.forEach(comment => {hasComments = true;const commentItem = document.createElement('div');commentItem.className = 'comment-item';commentItem.innerHTML = `<span class="comment-user">${comment.userName}</span>:<span class="comment-text">${comment.message}</span><span class="comment-time">${comment.createTime}</span>`;commentList.appendChild(commentItem);});if (!hasComments) {alert("还没有人评论,快来当评论第一人吧!");}}// 实现分页功能function renderPagination(totalPages) {paginationContainer.innerHTML = '';for (let i = 1; i <= totalPages; i++) {const pageButton = document.createElement('button');pageButton.textContent = i;pageButton.className = (i === currentPage) ? 'active' : '';pageButton.addEventListener('click', () => {currentPage = i;fetchComments(currentPage);});paginationContainer.appendChild(pageButton);}}// 发送评论功能function sendComment(commentText) {var songTitle = songTitleElement.textContent;// 对歌名进行处理var lastIndex = songTitle.lastIndexOf("评");songTitle = songTitle.substring(0, lastIndex);// 左闭右开console.log(songTitle);console.log(commentText);}document.getElementById('submitComment').addEventListener('click', () => {const input = document.getElementById('commentInput');const newCommentText = input.value.trim();if (newCommentText) {sendComment(newCommentText);input.value = ''; // 清空输入框}});</script>
</body></html>

然后再来完善里面的逻辑,首先是 getSongName 函数,要获取音乐名,就需要发送 ajax 请求,根据 musicId 来获取音乐名即可。

这个很简单,还是在后端直接写一个根据 musicId 获取音乐详情,最后返回音乐即可,当然还是得先参数校验,这个比较简单。

写完后就回到前端,发送 ajax 请求,然后再给标题赋值就行。

    // 获取音乐名字, 以及初始化页面function getSongName() {const urlParams = new URLSearchParams(window.location.search);const musicId = urlParams.get("musicId");// 发送 ajax 请求,通过 musicId 来获取音乐名,并赋值到 songTitle 中$.ajax({type: "post",url: "/music/getMusicById",data: {"musicId": musicId},success: function (result) {if (result.code == 200 && result.data != null) {// 说明成功获取音乐详情,将音乐名设置到标题上 var title = result.data.title;$(document).ready(function () {// 获取 <title> 元素并修改内容document.title = title + "评论页面";});$(document).ready(function () {$('#songTitle').text(title + "评论区");fetchComments(currentPage);});} else {alert(result.errMsg);}}});}

然后再来写获取评论列表的 fetchComments 函数,按照之前的约定来写,直接调用 ajax 请求,然后构造页面。

    // 获取评论列表的function fetchComments(page) {var songTitle = songTitleElement.textContent;// 对歌名进行处理var lastIndex = songTitle.lastIndexOf("评");songTitle = songTitle.substring(0, lastIndex);// 左闭右开$.ajax({url: `/comment/getCommentList`,method: 'GET',data: {songName: songTitle,page: page,commentsPerPage: commentsPerPage},success: function (result) {if (result != null && result.code == 200 && result.data != null) {console.log(result);var comments = result.data.commentList;var totalPage = result.data.totalPage;renderComments(comments);renderPagination(totalPage);} else {console.error('获取评论失败:', result.errMsg);alert(result.errMsg);}},error: function (error) {console.error('请求失败:', error);}});}

最后再来写发送评论 sendComment 函数,还是按照之前的约定来发送 ajax 请求,最后参数校验,成功就弹个框即可。

    // 发送评论功能function sendComment(commentText) {var songTitle = songTitleElement.textContent;// 对歌名进行处理var lastIndex = songTitle.lastIndexOf("评");songTitle = songTitle.substring(0, lastIndex);// 左闭右开console.log(songTitle);console.log(commentText);$.ajax({url: '/comment/addComment',method: 'POST',// contentType: 'application/json',data: {songName: songTitle,commentStr: commentText},success: function (result) {if (result.code == 200 && result.data == true) {alert('评论发送成功');fetchComments(currentPage);} else {alert('评论发送失败');}},error: function (error) {console.error('发送失败:', error);}});}

然后再运行程序,测试一下看看。

 获取歌曲名没什么问题。

发送功能也没问题。

再看看分页。

没啥问题,太好了,最后来写推荐音乐功能。

推荐音乐模块

那么推荐音乐功能具体该怎么实现呢?

可以根据标签来推荐音乐,就比如说,某个人最近一直在听二次元的歌曲,那就可以给这个用户推荐带有二次元标签的歌曲。

假设这个用户这三十天内听了 200 首歌,那么我们可以拉取该用户近三十天收听的 100 首歌,

然后统计这 100 首歌的所有的标签出现的次数,并按照逆序排序,然后再根据排名推荐最近点击量高的歌曲,推荐 20 首左右,如果不够 20 首,则能推荐多少就推荐多少,到了最后展示音乐的时候,就将推荐的音乐放在前面,其他音乐放在后面。

总体思路就是上面那样,所以根据思路,我们需要听歌记录表,记录用户听了什么歌,以及音乐标签表。

听歌记录表:id,用户 id,音乐 id,听歌时间。

音乐标签表:id,音乐 id,标签内容,创建时间。

具体实现:需要在用户点击播放音乐时,将听歌记录存入表中。

到时候一进入音乐列表页,就需要调用推荐音乐方法,然后后端通过 session 获取用户 id,根据用户 id 去查询听歌记录表的最后的100首音乐。得到音乐后根据音乐 id,去音乐标签表查询音乐标签,并统计标签出现次数(用 HashMap),然后再将标签以及出现次数 按照出现次数逆序排名(优先级队列,大根堆),然后再遍历大根堆,根据音乐标签去联合查询听歌记录表以及音乐标签表,按照播放量逆序排名得到的歌曲,如果推荐的歌曲够了 20 首,就直接跳出循环,然后再去数据库中查询剩下的音乐(根据不在推荐音乐集合中的音乐 id 查询音乐表),将剩下的音乐一个个放在推荐音乐集合的后面,最后再返回即可。

准备工作

要想实现推荐音乐,首先得先实现上传音乐时上传音乐标签。

也就是说,需要新增前面提到过的表。

-- 收听表   表示用户,在某时,收听了某首曲子
DROP TABLE IF EXISTS online_music.listen;
CREATE TABLE online_music.listen (`id` int PRIMARY KEY AUTO_INCREMENT,`user_id` int(11) NOT NULL,`music_id` int(11) NOT NULL,`delete_flag` TINYINT ( 4 ) NOT NULL DEFAULT 0,`create_time` DATETIME DEFAULT now(),`update_time` DATETIME DEFAULT now());-- 音乐标签表
DROP TABLE IF EXISTS online_music.music_label;
CREATE TABLE online_music.music_label (`id` int PRIMARY KEY AUTO_INCREMENT,`music_id` int(11) NOT NULL,`label_name` varchar(10) NOT NULL,`delete_flag` TINYINT ( 4 ) NOT NULL DEFAULT 0,`create_time` DATETIME DEFAULT now(),`update_time` DATETIME DEFAULT now());

将如上代码复制到 mysql 中运行。

然后首先来修改 upload.html。

修改成大概是这样子:

也可以自行实现一个自定义音乐标签,加个文本框,然后输入对应标签内容,点击添加按钮后将该标签添加到数组里什么的。这里我就直接用写死的音乐标签了。

然后就是按照如上效果图,修改之前的 html。

<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><link href="css/bootstrap.min.css" rel="stylesheet"><style>body {background: linear-gradient(to right, #0033cc, #66ccff);font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;margin: 0;padding: 0;}.container {max-width: 800px;margin: 50px auto;padding: 30px;background: rgba(255, 255, 255, 0.9);border-radius: 12px;box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);}.page-title {text-align: center;margin-bottom: 30px;font-size: 2.5em;color: #333;font-weight: bold;text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);}.form-group {margin-bottom: 20px;}.form-group label {font-weight: bold;margin-bottom: 5px;display: block;font-size: 1.2em;color: #333;}.form-control {border-radius: 8px;border: 1px solid #ddd;box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);padding: 10px;font-size: 1em;}.form-check {margin-bottom: 10px;display: flex;align-items: center;}.form-check input {margin-right: 10px;}.tag-container {display: flex;flex-wrap: wrap;gap: 10px;}.form-check-label {font-size: 0.9em;color: #333;}.btn-submit {display: block;width: 100%;padding: 15px;background-color: #007bff;border: none;color: white;font-size: 1.2em;cursor: pointer;border-radius: 8px;text-align: center;transition: background-color 0.3s ease;}.btn-submit:hover {background-color: #0056b3;}</style><script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
</head><body><div class="container"><div class="page-title">上传歌曲</div><form id="uploadForm" enctype="multipart/form-data"><div class="form-group"><label for="fileUpload">文件上传:</label><input type="file" id="fileUpload" name="filename" class="form-control" /></div><div class="form-group"><label for="singer">歌手名:</label><input type="text" id="singer" name="singer" placeholder="请输入歌手名" class="form-control" /></div><div class="form-group"><label>音乐标签:</label><div class="tag-container"><!-- 标签选项 --><div class="form-check"><input type="checkbox" class="form-check-input" id="ancientStyle" name="tags" value="古风" /><label class="form-check-label" for="ancientStyle">古风</label></div><div class="form-check"><input type="checkbox" class="form-check-input" id="nationalStyle" name="tags" value="国风" /><label class="form-check-label" for="nationalStyle">国风</label></div><div class="form-check"><input type="checkbox" class="form-check-input" id="animeStyle" name="tags" value="二次元" /><label class="form-check-label" for="animeStyle">二次元</label></div><div class="form-check"><input type="checkbox" class="form-check-input" id="pop" name="tags" value="流行" /><label class="form-check-label" for="pop">流行</label></div><div class="form-check"><input type="checkbox" class="form-check-input" id="rock" name="tags" value="摇滚" /><label class="form-check-label" for="rock">摇滚</label></div><div class="form-check"><input type="checkbox" class="form-check-input" id="jazz" name="tags" value="爵士" /><label class="form-check-label" for="jazz">爵士</label></div><div class="form-check"><input type="checkbox" class="form-check-input" id="classical" name="tags" value="古典" /><label class="form-check-label" for="classical">古典</label></div><div class="form-check"><input type="checkbox" class="form-check-input" id="hipHop" name="tags" value="嘻哈" /><label class="form-check-label" for="hipHop">嘻哈</label></div><div class="form-check"><input type="checkbox" class="form-check-input" id="blues" name="tags" value="蓝调" /><label class="form-check-label" for="blues">蓝调</label></div><div class="form-check"><input type="checkbox" class="form-check-input" id="folk" name="tags" value="民谣" /><label class="form-check-label" for="folk">民谣</label></div><div class="form-check"><input type="checkbox" class="form-check-input" id="electronic" name="tags" value="电子" /><label class="form-check-label" for="electronic">电子</label></div><div class="form-check"><input type="checkbox" class="form-check-input" id="dance" name="tags" value="舞曲" /><label class="form-check-label" for="dance">舞曲</label></div><div class="form-check"><input type="checkbox" class="form-check-input" id="opera" name="tags" value="歌剧" /><label class="form-check-label" for="opera">歌剧</label></div><div class="form-check"><input type="checkbox" class="form-check-input" id="instrumental" name="tags" value="器乐" /><label class="form-check-label" for="instrumental">器乐</label></div><div class="form-check"><input type="checkbox" class="form-check-input" id="reggae" name="tags" value="雷鬼" /><label class="form-check-label" for="reggae">雷鬼</label></div><div class="form-check"><input type="checkbox" class="form-check-input" id="metal" name="tags" value="金属" /><label class="form-check-label" for="metal">金属</label></div><div class="form-check"><input type="checkbox" class="form-check-input" id="soul" name="tags" value="灵魂" /><label class="form-check-label" for="soul">灵魂</label></div><div class="form-check"><input type="checkbox" class="form-check-input" id="country" name="tags" value="乡村" /><label class="form-check-label" for="country">乡村</label></div><div class="form-check"><input type="checkbox" class="form-check-input" id="rnb" name="tags" value="R&B" /><label class="form-check-label" for="rnb">R&B</label></div><div class="form-check"><input type="checkbox" class="form-check-input" id="alternative" name="tags" value="另类" /><label class="form-check-label" for="alternative">另类</label></div><div class="form-check"><input type="checkbox" class="form-check-input" id="ambient" name="tags" value="氛围" /><label class="form-check-label" for="ambient">氛围</label></div><div class="form-check"><input type="checkbox" class="form-check-input" id="japanese" name="tags" value="日语" /><label class="form-check-label" for="japanese">日语</label></div><div class="form-check"><input type="checkbox" class="form-check-input" id="folkRock" name="tags" value="民谣摇滚" /><label class="form-check-label" for="folkRock">民谣摇滚</label></div><div class="form-check"><input type="checkbox" class="form-check-input" id="gospel" name="tags" value="福音" /><label class="form-check-label" for="gospel">福音</label></div><div class="form-check"><input type="checkbox" class="form-check-input" id="trance" name="tags" value="迷幻" /><label class="form-check-label" for="trance">迷幻</label></div><div class="form-check"><input type="checkbox" class="form-check-input" id="newAge" name="tags" value="新世纪" /><label class="form-check-label" for="newAge">新世纪</label></div><div class="form-check"><input type="checkbox" class="form-check-input" id="latin" name="tags" value="拉丁" /><label class="form-check-label" for="latin">拉丁</label></div><div class="form-check"><input type="checkbox" class="form-check-input" id="synthwave" name="tags" value="合成波" /><label class="form-check-label" for="synthwave">合成波</label></div><div class="form-check"><input type="checkbox" class="form-check-input" id="industrial" name="tags" value="工业" /><label class="form-check-label" for="industrial">工业</label></div><div class="form-check"><input type="checkbox" class="form-check-input" id="love" name="tags" value="爱情" /><label class="form-check-label" for="love">爱情</label></div><div class="form-check"><input type="checkbox" class="form-check-input" id="english" name="tags" value="英语" /><label class="form-check-label" for="english">英语</label></div><div class="form-check"><input type="checkbox" class="form-check-input" id="korean" name="tags" value="韩语" /><label class="form-check-label" for="korean">韩语</label></div></div></div><button type="button" id="submitBtn" class="btn-submit">上传</button></form></div><script>$(document).ready(function () {$('#submitBtn').click(function () {// 获取表单数据var formData = new FormData($('#uploadForm')[0]);// 提取文件var filename = $('#fileUpload')[0].files[0];// 提取歌手名var singer = $('#singer').val();// 提取选中的标签var tags = [];$('input[name="tags"]:checked').each(function () {tags.push($(this).val());});// 创建一个新的对象来存放提取的数据var data = {filename: filename,singer: singer,tags: tags};// 将数据转换为 FormData 对象var uploadData = new FormData();uploadData.append('filename', filename);uploadData.append('singer', singer);tags.forEach(function (tag) {uploadData.append('tags[]', tag); // 注意这里使用了数组语法});console.log(filename);console.log(singer);console.log(tags);// 发送 AJAX 请求});});</script>
</body></html>

 然后写一下发送 ajax 请求的逻辑,如果后端返回 true 说明上传成功,那就弹个框,然后跳转到音乐列表页,如果没成功,那就弹框显示失败原因。

                // 发送 AJAX 请求$.ajax({url: '/music/upload',type: 'POST',data: uploadData,processData: false,contentType: false,success: function (result) {if (result != null && result.code == 200 && result.data == true) {alert('音乐上传成功!');location.href = "list.html";} else {alert(result.errMsg);}}});

写完了前端,我们可以先运行一下看看效果怎么样。

没什么问题,接下来就是修改一下后端代码,在上传音乐时,不仅要上传到本体,还要上传到数据库,在音乐上传到数据库中时,还需要将对应的音乐标签数组遍历,并根据音乐 id 上传到歌曲标签表中。直接写一个新增方法就行,但是考虑到后面删除音乐,所以也需要写一个根据音乐 id 删除对应音乐标签记录的方法。并且还需要写一个根据音乐 id 获取音乐标签集合的方法(推荐音乐功能需要)。

@Mapper
public interface MusicLabelMapper {@Insert("insert into music_label(music_id, label_name) values(#{musicId}, #{labelName});")Integer addMusicLabel(Integer musicId, String labelName);@Delete("delete from music_label where music_id = #{musicId}")Integer deleteMusicLabelByMusicId(Integer musicId);@Select("select label_name from music_label where music_id = #{musicId}")List<String> getLabelByMusicId(Integer musicId);
}

然后再让 service 调用一下 mapper,然后让 controller 调用 service,补充一下上传标签的过程即可。

在原有代码基础上改下,遍历音乐标签数组,新增每个音乐标签到数据库中即可。

上传重复音乐那块,为了贴合思路,我也修改了一下下。

    // 音乐 上传到服务器+上传到数据库, 还需要将对应的音乐标签也上传到数据库中@RequestMapping("/upload")public ResponseBodyMessage<Boolean> addMusic(@RequestParam String singer, @RequestParam("filename") MultipartFile file,@RequestParam("tags[]") List<String> tags, HttpServletRequest request, HttpServletResponse resp) throws IOException {// 检查是否登录HttpSession session = request.getSession(false);if (session == null || session.getAttribute(Constant.USERINFO_SESSION_KEY) == null) {return new ResponseBodyMessage<>(-2, "请登录后再操作", false);}log.info(singer);if (!StringUtils.hasLength(singer) || file == null) {return new ResponseBodyMessage<>(-1, "请输入歌手后再来操作!", false);}log.info("接收参数 tags: " + tags);// 上传到服务器// 获取文件的绝对路径String fileNameAndType = file.getOriginalFilename();log.info("获取路径,fileNameAndType: " + fileNameAndType);if (!StringUtils.hasLength(fileNameAndType)) {return new ResponseBodyMessage<>(-1, "请输入歌手后再来操作!", false);}String path = SAVE_PATH + fileNameAndType;// 获取曲名int point = fileNameAndType.lastIndexOf(".");String title = fileNameAndType.substring(0, point);// 查询数据库里是否存在这首曲子,如果存在,则看看该曲子的歌手,与本次上传的曲子歌手是否相同// 不同就可以上传,相同就提示用户,这首曲子已经存在,不能进行上传// 如果库里不存在这首曲子,那就可以上传Music music = musicService.getSongByName(title);// 但是这里有一个问题,那就是当上传同一首音乐,并且歌名相同时,就需要提醒用户,歌曲重名了,不能上传// 否则当删除该文件时,另一个同名歌曲也会被删除// 也就是说,可以上传重复歌曲,但是歌曲的名字不能相同,得与之前同名歌曲名字做一点区分if (music != null && music.getSinger().equals(singer)) {return new ResponseBodyMessage<>(-1, "很抱歉,曲库中已经存在这首同名同歌手的歌曲,不能进行上传", false);} else if (music != null && music.getTitle().equals(title)) {return new ResponseBodyMessage<>(-1, "很抱歉,请您上传的曲子名字与其他曲子同名,请对曲名做出一些区分后再进行上传", false);}// 可以上传,新增歌曲File dest = new File(path);if (!dest.exists()) {// 如果目录不存在,就创建目录dest.mkdir();}try {// 上传文件file.transferTo(dest);} catch (IOException e) {log.error(e.getMessage());return new ResponseBodyMessage<>(-1, "服务器上传失败,请联系管理员", false);}// 判断该文件是否是歌曲try {// TODO 如果该歌曲后缀名不是 MP3,但也是歌曲,那该怎么判断呢?// TODO 比如后缀为 .mp3  .m4a   .wav  .flac   .ape 那该怎么判断呢?if (!Mp3Util.isMp3File(path)) {dest.delete();return new ResponseBodyMessage<>(-1, "该文件不是mp3格式", false);}} catch (Exception e) {dest.delete();log.error(e.getMessage());return new ResponseBodyMessage<>(-1, "该文件不是mp3格式", false);}// 数据库上传存路径时,title(歌名) 没有加后缀.mp3// 注意将音乐标签一并上传,就需要音乐 id 以及标签,所以在上传音乐时// 需要获取新增音乐的 id。String url = "/music/get?path=" + title;Integer result = -1;User user = (User) request.getSession().getAttribute(Constant.USERINFO_SESSION_KEY);try {Music uploadMusic = new Music(title, singer, url, user.getId());result = musicService.addMusic(uploadMusic);if (result > 0) {// 遍历 tags,将音乐标签一个个添加到数据库中for (String label : tags) {Integer tmp = musicLabelService.addMusicLabel(uploadMusic.getId(), label);}return new ResponseBodyMessage<>(200, null, true);} else {dest.delete();return new ResponseBodyMessage<>(-1, "数据库上传失败,请联系管理员", false);}} catch (Exception e) {dest.delete();log.error(e.getMessage());return new ResponseBodyMessage<>(-1, "数据库上传失败,请联系管理员", false);}}

上传音乐的前后端都写完了,就可以来测试一下看看。

没啥问题。

但是还需要修改收听歌曲时的代码,在获取音乐二进制文件之前,还需要将用户的听歌记录新增到听歌记录表中。

    // 播放音乐时,路径为 /music/get?path=xxx.mp3@RequestMapping("/get")public ResponseEntity<byte[]> getMusic(String path, HttpServletRequest request) {Integer index = path.lastIndexOf(".");String musicName = path.substring(0, index);HttpSession session = request.getSession(false);log.info("接收参数 musicName: " + musicName);if (!StringUtils.hasLength(musicName) || session == null || session.getAttribute(Constant.USERINFO_SESSION_KEY) == null) {return ResponseEntity.badRequest().build();}// 根据音乐名来查询音乐详情Music music = musicService.getSongByName(musicName);if (music == null) {return ResponseEntity.badRequest().build();}// 新增用户此次的听歌记录User user = (User) session.getAttribute(Constant.USERINFO_SESSION_KEY);Integer result = listenService.addListenRecord(user.getId(), music.getId());log.info("getMusic, 新增音乐记录: " + result);File file = new File(SAVE_PATH + path);byte[] arr = null;try {arr = Files.readAllBytes(file.toPath());if (arr == null) {return ResponseEntity.badRequest().build();}return ResponseEntity.ok(arr);} catch (IOException e) {log.error(e.getMessage());}return ResponseEntity.badRequest().build();}

改完之后来测试一下看看。

没问题。好耶,终于只剩最后一个功能了,接下来就是实现推荐音乐功能了。

后端

还是先约定前后端接口。

先从 controller 开始写好了。

根据之前的思路来写就好。

首先进行参数校验,判断用户是否登录,如果用户未登录,那就直接返回。

如果用户登录了,就需要去根据 userId,联合查询,收听音乐记录表和音乐表中,最近听过的 100 首音乐,并按照时间逆序排序。

如果没有对应的音乐,说明用户没听过歌,那就直接返回数据库中所有的音乐即可。

如果有音乐,则遍历音乐集合,根据音乐 id 查询每一首歌的音乐标签,并用 HashMap 来统计其出现次数。

然后在创建大根堆,根据哈希表中的每个节点的出现次数来排序。

做完上述工作后,此时大根堆里面就已经按照音乐标签出现次数的大小排好序了。

然后遍历大根堆,根据音乐标签,联合查询音乐表和音乐标签表和收听音乐表,查询近期播放量高的音乐集合,并将得到的音乐集合放入推荐音乐列表中。

如果推荐音乐列表里的音乐已经大于等于 20 首,那就退出循环。

如果不够 20 首音乐,那能推荐多少首就推荐多少首。

接下来,就是查询除了推荐的音乐以外的音乐,可以将所有推荐音乐的 id 放入一个集合中,然后在查询 musicId 不在该 id 集合中的音乐集合。

然后遍历集合,将里面的音乐按照顺序添加到推荐音乐列表的后面,最后再返回推荐音乐列表即可。

    // 推荐歌曲@RequestMapping("/recommendMusics")public ResponseBodyMessage<List<Music>> recommendMusics(HttpServletRequest request) {// TODO 拉取用户最近听的 100 首歌,根据用户 ID 联合查询 listen 表 以及 music 表,并按照时间逆序排序// TODO 然后再遍历音乐集合,统计各个音乐标签的数量,并按照出现次数排序// TODO 然后再根据音乐标签去数据库中查找音乐,根据排名来推荐 20 首音乐 recommends// TODO 然后再来查询音乐 id 不在 recommends 中的音乐,musicId not in ()// TODO 最后将两个音乐合集一合并,最后返回即可// 检查是否登录HttpSession session = request.getSession(false);if (session == null || session.getAttribute(Constant.USERINFO_SESSION_KEY) == null) {return new ResponseBodyMessage<>(-2, "请登录后再操作", null);}User user = (User) session.getAttribute(Constant.USERINFO_SESSION_KEY);// 拉取用户最近收听的 100 首歌List<Music> music1 = musicService.getLast100ListenedMusic(user.getId());log.info("最近 100 首音乐" + music1.toString());if (music1 == null || music1.size() == 0) {// 说明用户没有听过音乐// 就可以直接推送库里的全部音乐List<Music> result = musicService.getMusics();return new ResponseBodyMessage<>(200, null, result);}// 根据 musicId 来去标签表获取标签,并统计出标签的出现次数,并按照出现次数排序,优先级队列Map<String, Integer> labelMap = new HashMap<>();for (Music music : music1) {List<String> label = musicLabelService.getLabelByMusicId(music.getId());for (String labelName : label) {int count = labelMap.getOrDefault(labelName, 0);labelMap.put(labelName, count + 1);}}log.info("音乐标签" + labelMap.toString());// 建立大根堆, 并把元素放入堆中PriorityQueue<Map.Entry<String, Integer>> labelQueue = new PriorityQueue<>(new Comparator<Map.Entry<String, Integer>>() {@Overridepublic int compare(Map.Entry<String, Integer> o1, Map.Entry<String, Integer> o2) {return o2.getValue() - o1.getValue();}});labelQueue.addAll(labelMap.entrySet());log.info("排序后的音乐标签" + labelQueue.toString());// 然后再根据音乐标签,来去查找播放量较高的, 推荐够 20 首就行,// 如果队列为空了还不够 20 首,那就能推荐多少就推荐多少List<Music> musicRecommend = new ArrayList<>();// 为了保证在前面的一定是点击量高的,并且还需要去重,所以将 musicTmp 作为这首歌是否出现过的标志// 如果 musicTmp 出现过这首歌,则不能添加进最终的推荐结果集合里,反之则能加入Set<Music> musicTmp = new HashSet<>();while (!labelQueue.isEmpty()) {if (!musicRecommend.isEmpty() && musicRecommend.size() >= 20) {break;}Map.Entry<String, Integer> label = labelQueue.poll();// 根据音乐标签,获取播放量高的音乐List<Music> music = musicService.getHighViewCountMusicByLabelName(label.getKey());if (music != null && music.size() != 0) {for (Music m : music) {if (!musicTmp.contains(m)) {musicRecommend.add(m);musicTmp.add(m);}}}}log.info("推荐的音乐" + musicRecommend.toString());// 然后去数据库里查询剩下的音乐, 首先获取以及推荐了的歌曲的 idList<Integer> musicIds = new ArrayList<>();for (Music music : musicRecommend) {musicIds.add(music.getId());}List<Music> musicsNotInMusicIds = musicService.getMusicsNotInMusicIds(musicIds);log.info("剩下的音乐合集: " + musicsNotInMusicIds);// 再把剩下的音乐全部添加到推荐音乐合集的后面,最后返回即可musicRecommend.addAll(musicsNotInMusicIds);log.info("返回的推荐音乐 + 剩下的音乐合集: " + musicRecommend);return new ResponseBodyMessage<>(200, null, musicRecommend);}

 然后再来写 mapper,就写我刚刚说的那几条 sql 就行。

    // 获取用户最近收听的 100 首音乐详情@Select("select m.* from listen as l, music as m where l.user_id = #{userId} and l.music_id = m.id group by m.id order by l.create_time desc limit 100")List<Music> getLast100ListenedMusic(Integer userId);// 根据特定音乐获取高播放量的音乐@Select("select count(l.music_id) as times, m.* from music_label as ml, listen as l, music as m where ml.label_name = #{labelName} and l.music_id = ml.music_id and ml.music_id = m.id group by l.music_id order by times desc")List<Music> getHighViewCountMusicByLabelName(String labelName);// 这个去 xml 里写List<Music> getMusicsNotInMusicIds(List<Integer> ids);

然后再来写 service,调用一下对应的方法即可。

    public List<Music> getLast100ListenedMusic(Integer userId) {return musicMapper.getLast100ListenedMusic(userId);}public List<Music> getHighViewCountMusicByLabelName(String labelName) {return musicMapper.getHighViewCountMusicByLabelName(labelName);}public List<Music> getMusicsNotInMusicIds(List<Integer> ids) {return musicMapper.getMusicsNotInMusicIds(ids);}

 写完之后,就可以运行程序,去 postman 里测试一下。

首先来测试没有听过音乐的用户 tianqi 的情况:

再来看看只收听过一首音乐的 mika:

后台打印:

返回的顺序也没问题。因为数据不是很够,所以得多添加几首音乐,多听些歌。

删除了些没有带标签的歌后,又添加了些我不听的歌,以及听了一些歌后。

测试后,查看后台日记。

没啥问题,能正确推荐,接下来就可以去写前端了。

前端

还是按照之前写的约定来写。

直接去音乐列表页定义一个推荐音乐的函数,然后再调用这个函数。

函数里面发送 ajax 请求,就是照抄 load 函数,再改改就行。

        // 推荐音乐function recommendMusics() {$.ajax({type: "get",url: "/music/recommendMusics",success: function (result) {console.log(result);if (result.code == 200 && result.data != null) {var musics = result.data;var finalHtml = '';for (var music of musics) {var url = music.url + ".mp3";finalHtml += '<tr>';finalHtml += '<th> <input id="' + music.id + '" type="checkbox"> </th>';finalHtml += '<td>' + music.title + '</td>';finalHtml += '<td>' + music.singer + '</td>';finalHtml += '<td> <button class="btn btn-primary" onclick="playerSong(\'' + url + '\')"> 播放歌曲 </button> </td>';finalHtml += '<td> <button class="btn btn-primary" onclick="deleteInfo(' + music.id + ')"> 删除 </button>' +'<button class="btn btn-primary" onclick="loveInfo(' + music.id + ')"> 喜欢 </button>'+ '<button class="btn btn-primary" onclick="comment(' + music.id + ')"> 评论 </button>' + '</td>';finalHtml += "</tr>"}$("#info").html(finalHtml);}}});}recommendMusics();

写完之后就可以运行程序,测试一下看看。

没啥问题,这个功能写完了。

添加缓存

考虑到未来可能会有大量用户使用我的音乐播放器(高并发情况),所以我打算用 redis 作为缓存

首先还是先来说一下,为何用 redis 就能解决高并发问题?以及如何用 redis 来作为缓存?

首先,redis 是一个将数据存储在内存中的结构,跟 mysql 比起来,mysql 将数据存储在硬盘中,而在内存中读数据比在在硬盘中读数据能快好几个数量级,而且 redis 的实现的功能比较简单,要干的活比较少,而 mysql 实现了很多功能(比如索引、联合查询之类的),要干的活就会有很多,所以 mysql 跟 redis 比起来,要干的活又多,读数据速度又慢,所以自然而然,mysql 就很慢。

那为什么不用 redis 替代 mysql 呢?因为内存容量比硬盘要小很多,所以能够存储的数据就少,而且 redis 实现的功能没有 mysql 多,能干的事情也没有 mysql 多,所以自然还是以 mysql 为主,但是当大量用户在同一时刻访问同一服务器时,所有的请求都会交给 mysql 来处理,而 mysql 又慢,所以此时,mysql 就会顶不住,自然而然就会挂掉,而 mysql 一挂,导致整个服务器就不能正常运行,一些业务功能也就无法正常使用。为了解决这个问题,就可以用 redis 来作为缓存。

此时,接收到的大量请求会先去 redis 中查询数据,如果在 redis 中没有找到对应的数据,才会去 mysql 中查找,此时 redis 就相当于一层滤网,通过滤网就能筛掉大量的请求,大量减少 mysql 查询的负载,而剩下的一点请求,mysql 就能处理的过来了。(可以简单理解为 mysql 就相当于脆皮法师,redis 就相当于肉盾)

也就是说,可以用 redis 来存放热点数据,mysql 还是存储全量数据,如果是读数据的话,先去 redis 中查找,找不到再去 mysql 中查。但是还需要注意,redis 中存储的数据得和 mysql 中存储的数据保存一致(保证数据准确性),所以在修改数据的时候,不仅要修改 redis 的数据,还需要修改 mysql 中的数据。

那此时,又有一个问题,什么样的数据是 "热点数据" 呢?

热点数据,就是会被频繁访问到的数据。有两种策略来获得热点数据,第一种是定时生成,第二种是实时生成。

定时生成:每隔一段时期(几天/几周/几月),对于访问的数据频次进行统计,挑选出访问频次最高的前 N %。这种的话实时性比较低,不能应对突发情况。

实时生成:先给缓存设置容量上限(通过 redis 配置文件 maxmemory 参数设置),然后把每次用户的查询数据去 redis 中找,如果没有在 redis 中找到,那就去 mysql 中查询,并把结果存入 redis 中,redis 慢慢积累数据,那么缓存就会满,满了之后就会触发 redis 淘汰策略,把不太热门数据删除,持续一段时间之后,redis 里面的数据自然而然就是热门数据了。

这里我们采用实时生成的策略来获取热点数据。

要想在 springboot 中使用 redis,就需要配置 redis,我们可以使用管道映射,通过设置下面这个就能监听 redis 请求。

然后再去之前的 application-dev.properties 配置文件中配置 redis 信息。

并且在 pom.xml 里添加 redis 依赖,如下:

        <!-- redis 依赖--><!-- https://mvnrepository.com/artifact/redis.clients/jedis --><dependency><groupId>redis.clients</groupId><artifactId>jedis</artifactId><version>4.3.2</version></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency>

这样就可以在 springboot 项目中使用 redis 了。

因为使用了 redis ,所以前面写过的代码都需要,按照前面 说过的 redis 规则修改一下。

也就是说,请求来的时候,得先去根据自己定义的 key 的格式,然后构造对应的 key,然后根据 key,去 redis 中查找对应的值,如果找到了,那么就直接返回,如果没找到,则需要去 MySQL 中查找,如果找到了,就需要将 key 和对应的值存入 redis,并设置过期时间(这里我设置为 30 天 ,暂时不考虑缓存雪崩问题)。然后再返回结果,如果没找到,那就直接返回。

用户模块

@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {@Autowiredprivate UserService userService;@Autowiredprivate StringRedisTemplate stringRedisTemplate;@Autowiredprivate ObjectMapper objectMapper;@RequestMapping("/login")public ResponseBodyMessage<Boolean> login(String userName, String password, HttpServletRequest request) {// 1. 进行参数校验// 2. 查询数据库, 对密码进行校验// 3. 密码校验成功,设置 session, 返回结果log.info("login,接收参数 userName: " + userName + ", password: " + password);if (!StringUtils.hasLength(userName) || !StringUtils.hasLength(password)) {return new ResponseBodyMessage<>(-2, "用户或密码不能为空", false);}// 先根据 userName,去 redis 中找到对应的 userId// 规定 key: userName:用户名   value: userIdString username = "userName:" + userName;String id = stringRedisTemplate.opsForValue().get(username);log.info("从 redis 找到 id: " + id);if (StringUtils.hasLength(id)) {// 查询 redis,构造 key:  user:info:id    value: {id:"1", userName:"zhangsan", password: "123"}String key = "user:info:" + id;String value = stringRedisTemplate.opsForValue().get(key);// 能查找到用户if (StringUtils.hasLength(value)) {try {User userRedis = objectMapper.readValue(value, User.class);// 说明已经在 redis 中查找到了对应数据,则进行校验log.info("从 redis 查找到 user: " + userRedis);if (BCryptUtil.verify(password, userRedis.getPassword())) {// 校验成功,存入 sessionuserRedis.setPassword("");request.getSession().setAttribute(Constant.USERINFO_SESSION_KEY, userRedis);return new ResponseBodyMessage<>(200, null, true);} else {return new ResponseBodyMessage<>(-1, "用户名或者密码错误", false);}} catch (JsonProcessingException e) {e.printStackTrace();}}}// 到了这里,说明 redis 中一定没有存储对应用户的数据User user = userService.getUserByName(userName);log.info("login,user: " + user);if (user == null || !BCryptUtil.verify(password, user.getPassword())) {return new ResponseBodyMessage<>(-1, "用户名或者密码错误", false);}String key = "user:info:" + user.getId();// 说明此时 redis 还没有对应的数据,那就存入 redis 中。(设置 30 天的过期时间)String valueSave = null;try {valueSave = objectMapper.writeValueAsString(user);stringRedisTemplate.opsForValue().set(username, String.valueOf(user.getId()), Duration.ofDays(30));stringRedisTemplate.opsForValue().set(key, valueSave, Duration.ofDays(30));} catch (JsonProcessingException e) {e.printStackTrace();}user.setPassword("");request.getSession().setAttribute(Constant.USERINFO_SESSION_KEY, user);return new ResponseBodyMessage<>(200, null, true);}@RequestMapping("/register")public ResponseBodyMessage<Boolean> register(String userName, String password) throws JsonProcessingException {// 1. 进行参数校验// 2. 查询数据库,看看用户名是否被注册// 3. 如果用户名没被注册,则根据用户输入的密码来生成密文// 4. 调用新增用户方法。log.info("register,接收参数 userName: " + userName + ", password: " + password);if (!StringUtils.hasLength(userName) || !StringUtils.hasLength(password)) {return new ResponseBodyMessage<>(-2, "用户或密码不能为空", false);}// 去 redis 中查找 userName 是否存在// 查询 redis,构造 key:  user:info:userName    value: {id:"1", userName:"zhangsan", password: "123"}String username = "userName:" + userName;String id = stringRedisTemplate.opsForValue().get(username);String key = "user:info:" + id;String value = stringRedisTemplate.opsForValue().get(key);// 判断结果是否合法if (StringUtils.hasLength(value)) {User userRedis = objectMapper.readValue(value, User.class);// 说明已经在 redis 中查找到了对应数据log.info("从 redis 查找到 user: " + userRedis);// 说明用户已经存在,不能注册return new ResponseBodyMessage<>(-1, "用户名已经被注册", false);}// 到了这里说明 redis 中没有该用户,则还需要去 mysql 中查找User user = userService.getUserByName(userName);log.info("register,user: " + user);if (user == null) {// 如果用户名没被注册,则需要新增用户User registerUser = new User(userName, BCryptUtil.encrypt(password));Integer result = userService.addUser(registerUser);if (result <= 0) {return new ResponseBodyMessage<>(-1, "注册失败,请联系管理员", false);}} else {// 说明用户名已经被注册,应该提醒用户换一个用户名注册。return new ResponseBodyMessage<>(-1, "用户名已经被注册", false);}return new ResponseBodyMessage<>(200, null, true);}
}

改完之后,就可以运行下程序,看看有没有问题...在运行程序时,记得也要打开 xshell 里的 redis,不然会连接失败。(用 redis-cli --raw 打开 redis)

首先来测试下登录接口。

如果用户不在 redis 中存在,就会将从 mysql 中查询到的用户信息存入 redis 中。

这个没问题,接下来看看注册接口。

输入已经存在的用户名进行注册后,会去 redis 中查询。

这个也没问题。

然后接下来修改音乐模块的代码。

音乐模块

按照前面的约定以及思路来写,先去 redis 中找,redis 里找不到就去 mysql 中找,mysql 中找到了就添加到 redis 里,最后返回结果。

播放音乐功能:

    // 播放音乐时,路径为 /music/get?path=xxx.mp3@RequestMapping("/get")public ResponseEntity<byte[]> getMusic(String path, HttpServletRequest request) {Integer index = path.lastIndexOf(".");String musicName = path.substring(0, index);HttpSession session = request.getSession(false);log.info("接收参数 musicName: " + musicName);if (!StringUtils.hasLength(musicName) || session == null || session.getAttribute(Constant.USERINFO_SESSION_KEY) == null) {return ResponseEntity.badRequest().build();}// 根据音乐名来查询音乐详情String musicname = "musicName:" + musicName;// 查找 musicIdString id = stringRedisTemplate.opsForValue().get(musicname);log.info("getMusic, 从 redis 中查找到 musicId:" + id);if (StringUtils.hasLength(id)) {// 如果找到了,那就继续找音乐本体String musicKey = "music:info:" + id;try {Music music = objectMapper.readValue(stringRedisTemplate.opsForValue().get(musicKey), Music.class);log.info("getMusic, 从 redis 中查找到 music: " + music);// 新增用户此次的听歌记录User user = (User) session.getAttribute(Constant.USERINFO_SESSION_KEY);Integer result = listenService.addListenRecord(user.getId(), music.getId());log.info("新增音乐记录: " + result);// TODO 往 redis 添加:"user:1:listenmusic:27"  "27"   过期时间:30 天String redisKey = "user:" + user.getId() + ":listenmusic:" + id;stringRedisTemplate.opsForValue().set(redisKey, id);stringRedisTemplate.expire(redisKey, Duration.ofDays(30));log.info("往 redis 中添加听歌记录: " + redisKey);File file = new File(SAVE_PATH + path);byte[] arr = null;arr = Files.readAllBytes(file.toPath());if (arr == null) {return ResponseEntity.badRequest().build();}return ResponseEntity.ok(arr);} catch (JsonProcessingException e) {e.printStackTrace();} catch (IOException e) {log.error(e.getMessage());}}// 到这里说明在 redis 里没找到音乐Music music = musicService.getSongByName(musicName);if (music == null) {return ResponseEntity.badRequest().build();}// 新增用户此次的听歌记录User user = (User) session.getAttribute(Constant.USERINFO_SESSION_KEY);Integer result = listenService.addListenRecord(user.getId(), music.getId());log.info("getMusic, 新增音乐记录: " + result);// TODO 往 redis 添加:"user:1:listenmusic:27"  "27"   过期时间:30 天String redisKey = "user:" + user.getId() + ":listenmusic:" + music.getId();stringRedisTemplate.opsForValue().set(redisKey, String.valueOf(music.getId()));stringRedisTemplate.expire(redisKey, Duration.ofDays(30));log.info("往 redis 中添加听歌记录: " + redisKey);try {// 将对应音乐存入 redis 中String musicKey = "music:info:" + music.getId();stringRedisTemplate.opsForValue().set(musicKey, objectMapper.writeValueAsString(music), Duration.ofDays(60));stringRedisTemplate.opsForValue().set(musicname, String.valueOf(music.getId()), Duration.ofDays(60));log.info("getMusic, 音乐不在 redis 中存在,需要新增音乐到 redis 中:" + music);} catch (JsonProcessingException e) {log.error(e.getMessage());}File file = new File(SAVE_PATH + path);byte[] arr = null;try {arr = Files.readAllBytes(file.toPath());if (arr == null) {return ResponseEntity.badRequest().build();}return ResponseEntity.ok(arr);} catch (IOException e) {log.error(e.getMessage());}return ResponseEntity.badRequest().build();}

 写完之后来测试下看看效果。

再听一遍看看

能从 redis 里找到对应的音乐,没问题。

然后再来修改查询音乐功能的代码。

    @RequestMapping("/getMusics")public ResponseBodyMessage<List<Music>> getMusics(String musicName) {if (!StringUtils.hasLength(musicName)) {// 音乐名为空,查询所有音乐List<Music> result = musicService.getMusics();return new ResponseBodyMessage<>(200, null, result);}// 根据音乐名,去 redis 中查找多个音乐 ID// 然后再去 redis 中查找音乐本体,找不到再去 mysql 查询// 根据音乐名来查询音乐详情List<String> keys = new ArrayList<>();String patterns = "musicName:" + "*" + musicName + "*";log.info("getMusics, 根据样式: " + patterns + "去 redis 中扫描符合要求的 keys");// 指定样式以及每次扫描的数量ScanOptions scanOptions = ScanOptions.scanOptions().match(patterns).count(1000).build();Set<String> execute = stringRedisTemplate.execute(new RedisCallback<Set<String>>() {@Overridepublic Set<String> doInRedis(RedisConnection connection) {Set<String> binaryKeys = new HashSet<>();Cursor<byte[]> cursor = connection.scan(scanOptions);while (cursor.hasNext()) {binaryKeys.add(new String(cursor.next()));}return binaryKeys;}});log.info("getMusics, 根据音乐名查找的 keys :" + execute);// 再根据音乐名字获取到音乐 ID,从而获取到音乐详情List<String> list = stringRedisTemplate.opsForValue().multiGet(execute);// 得到音乐 ID 后,就是根据音乐 ID 获取音乐本体log.info("getMusics, 从 redis 中获取的音乐 ID 集合 musicIds: " + list);Set<Music> musicSet = new HashSet<>();List<Integer> musicIds = new ArrayList<>();if (list != null && list.size() > 0) {for (String musicId : list) {musicIds.add(Integer.valueOf(musicId));try {String musicKey = "music:info:" + musicId;Music musicRedis = objectMapper.readValue(stringRedisTemplate.opsForValue().get(musicKey), Music.class);musicSet.add(musicRedis);} catch (JsonProcessingException e) {throw new RuntimeException(e);}}log.info("getMusics, 从 redis 中获取的音乐集合 musics: " + musicSet);// TODO 然后再去 mysql 中查找剩下的数据,如果有歌曲名的话List<Music> result = musicService.getMusicsNotInMusicIdsAndTitleLike(musicIds, musicName);// TODO 把对应歌曲信息添加到 redis 中if (result != null && result.size() != 0) {log.info("getMusics, 音乐不在 redis 中存在,需要新增音乐到 redis 中:" + result);for (Music music : result) {musicSet.add(music);// 将对应音乐存入 redis 中String musicKey = "music:info:" + music.getId();String musicname = "musicName:" + music.getTitle();try {stringRedisTemplate.opsForValue().set(musicKey, objectMapper.writeValueAsString(music), Duration.ofDays(60));stringRedisTemplate.opsForValue().set(musicname, String.valueOf(music.getId()), Duration.ofDays(60));} catch (JsonProcessingException e) {log.error(e.getMessage());}}}return new ResponseBodyMessage<>(200, null, new ArrayList<>(musicSet));}// 此时说明 redis 中没有歌曲,则需要添加List<Music> result = musicService.getMusicsByName(musicName);if (result != null && result.size() != 0) {log.info("getMusics, 音乐不在 redis 中存在,需要新增音乐到 redis 中:" + result);for (Music music : result) {// 将对应音乐存入 redis 中String musicKey = "music:info:" + music.getId();String musicname = "musicName:" + music.getTitle();try {stringRedisTemplate.opsForValue().set(musicKey, objectMapper.writeValueAsString(music), Duration.ofDays(60));stringRedisTemplate.opsForValue().set(musicname, String.valueOf(music.getId()), Duration.ofDays(60));} catch (JsonProcessingException e) {log.error(e.getMessage());}}}return new ResponseBodyMessage<>(200, null, result);}

 修改完后测试一下看看是否有效果。

再查一次。

没问题。

然后再来修改根据音乐 ID 来查询音乐的代码。这个是辅助评论功能的代码。

就跟刚刚上面写的那个代码差不多,抄抄改改就行。

    // 根据音乐ID 来查询音乐@RequestMapping("/getMusicById")public ResponseBodyMessage<Music> getMusics(@RequestParam Integer musicId) {if (musicId == null || musicId <= 0) {// musicId 不合法,返回return new ResponseBodyMessage<>(-1, "musicId 不合法", null);}// 先去 redis 中查询音乐,查不到再去 mysql 中查找String musicKey = "music:info:" + musicId;String musicValue = stringRedisTemplate.opsForValue().get(musicKey);log.info("getMusics, 从 redis 中查找音乐本体: " + musicValue);if (StringUtils.hasLength(musicValue)) {// 说明在 redis 中找到了音乐,直接返回该音乐即可try {Music musicRedis = objectMapper.readValue(musicValue, Music.class);return new ResponseBodyMessage<>(200, null, musicRedis);} catch (JsonProcessingException e) {throw new RuntimeException(e);}}Music music = musicService.getMusicById(musicId);// 将信息存入 redis 中if (music != null) {try {// 将对应音乐存入 redis 中musicKey = "music:info:" + music.getId();String musicname = "musicName:" + music.getTitle();stringRedisTemplate.opsForValue().set(musicKey, objectMapper.writeValueAsString(music), Duration.ofDays(60));stringRedisTemplate.opsForValue().set(musicname, String.valueOf(music.getId()), Duration.ofDays(60));log.info("getMusics, 音乐不在 redis 中存在,需要新增音乐到 redis 中:" + music);} catch (JsonProcessingException e) {log.error(e.getMessage());}return new ResponseBodyMessage<>(200, null, music);}return new ResponseBodyMessage<>(-1, "获取歌失败", null);}

 写完之后来测试一下看看,直接点开音乐评论区,然后再看后台打印的日记即可。

新增了 INTERNET YAMERO 的歌曲记录。

刷新下评论区,看看是否能从 redis 里获取到对应音乐。

没啥问题,然后再来修改推荐音乐功能的代码。

因为推荐音乐标签排行榜需要每首歌的标签,所以在上传音乐时,还需要将对应的音乐标签存入 redis 里。

    // 音乐 上传到服务器+上传到数据库, 还需要将对应的音乐标签也上传到数据库中@RequestMapping("/upload")public ResponseBodyMessage<Boolean> addMusic(@RequestParam String singer, @RequestParam("filename") MultipartFile file,@RequestParam("tags[]") List<String> tags, HttpServletRequest request, HttpServletResponse resp) throws IOException {// 检查是否登录HttpSession session = request.getSession(false);if (session == null || session.getAttribute(Constant.USERINFO_SESSION_KEY) == null) {return new ResponseBodyMessage<>(-2, "请登录后再操作", false);}log.info(singer);if (!StringUtils.hasLength(singer) || file == null) {return new ResponseBodyMessage<>(-1, "请输入歌手后再来操作!", false);}log.info("接收参数 tags: " + tags);// 上传到服务器// 获取文件的绝对路径String fileNameAndType = file.getOriginalFilename();log.info("获取路径,fileNameAndType: " + fileNameAndType);if (!StringUtils.hasLength(fileNameAndType)) {return new ResponseBodyMessage<>(-1, "请输入歌手后再来操作!", false);}String path = SAVE_PATH + fileNameAndType;// 获取曲名int point = fileNameAndType.lastIndexOf(".");String title = fileNameAndType.substring(0, point);// 查询数据库里是否存在这首曲子,如果存在,则看看该曲子的歌手,与本次上传的曲子歌手是否相同// 不同就可以上传,相同就提示用户,这首曲子已经存在,不能进行上传// 如果库里不存在这首曲子,那就可以上传Music music = musicService.getSongByName(title);// 但是这里有一个问题,那就是当上传同一首音乐,并且歌名相同时,就需要提醒用户,歌曲重名了,不能上传// 否则当删除该文件时,另一个同名歌曲也会被删除// 也就是说,可以上传重复歌曲,但是歌曲的名字不能相同,得与之前同名歌曲名字做一点区分if (music != null && music.getSinger().equals(singer)) {return new ResponseBodyMessage<>(-1, "很抱歉,曲库中已经存在这首同名同歌手的歌曲,不能进行上传", false);} else if (music != null && music.getTitle().equals(title)) {return new ResponseBodyMessage<>(-1, "很抱歉,请您上传的曲子名字与其他曲子同名,请对曲名做出一些区分后再进行上传", false);}// 可以上传,新增歌曲File dest = new File(path);if (!dest.exists()) {// 如果目录不存在,就创建目录dest.mkdir();}try {// 上传文件file.transferTo(dest);} catch (IOException e) {log.error(e.getMessage());return new ResponseBodyMessage<>(-1, "服务器上传失败,请联系管理员", false);}// 判断该文件是否是歌曲try {// TODO 如果该歌曲后缀名不是 MP3,但也是歌曲,那该怎么判断呢?// TODO 比如后缀为 .mp3  .m4a   .wav  .flac   .ape 那该怎么判断呢?if (!Mp3Util.isMp3File(path)) {dest.delete();return new ResponseBodyMessage<>(-1, "该文件不是mp3格式", false);}} catch (Exception e) {dest.delete();log.error(e.getMessage());return new ResponseBodyMessage<>(-1, "该文件不是mp3格式", false);}// 数据库上传存路径时,title(歌名) 没有加后缀.mp3// 注意将音乐标签一并上传,就需要音乐 id 以及标签,所以在上传音乐时// 需要获取新增音乐的 id。String url = "/music/get?path=" + title;Integer result = -1;User user = (User) request.getSession().getAttribute(Constant.USERINFO_SESSION_KEY);try {Music uploadMusic = new Music(title, singer, url, user.getId());result = musicService.addMusic(uploadMusic);if (result > 0) {// 遍历 tags,将音乐标签一个个添加到数据库中for (String label : tags) {// 将音乐标签也添加到 redis 中String redisMusicLabel = "musicLabel:music:" + uploadMusic.getId();stringRedisTemplate.opsForSet().add(redisMusicLabel, label);stringRedisTemplate.expire(redisMusicLabel, Duration.ofDays(60));Integer tmp = musicLabelService.addMusicLabel(uploadMusic.getId(), label);stringRedisTemplate.opsForSet().add(redisMusicLabel, label);stringRedisTemplate.expire(redisMusicLabel, Duration.ofDays(60));}return new ResponseBodyMessage<>(200, null, true);} else {dest.delete();return new ResponseBodyMessage<>(-1, "数据库上传失败,请联系管理员", false);}} catch (Exception e) {dest.delete();log.error(e.getMessage());return new ResponseBodyMessage<>(-1, "数据库上传失败,请联系管理员", false);}}

继续来修改推荐音乐功能的代码,核心还是那样,涉及到查询数据操作时,先去 redis 中找,如果没找到,再去 mysql 中找,如果找到了,则构造键值,添加到 redis 里,再返回结果。

    // 推荐歌曲@RequestMapping("/recommendMusics")public ResponseBodyMessage<List<Music>> recommendMusics(HttpServletRequest request) {// TODO 拉取用户最近听的 100 首歌,根据用户 ID 联合查询 listen 表 以及 music 表,并按照时间逆序排序// TODO 然后再遍历音乐集合,统计各个音乐标签的数量,并按照出现次数排序// TODO 然后再根据音乐标签去数据库中查找音乐,根据排名来推荐 20 首音乐recommends// TODO 然后再来查询音乐 id 不在 recommends 中的音乐,musicId not in ()// TODO 最后将两个音乐合集一合并,最后返回即可// 检查是否登录HttpSession session = request.getSession(false);if (session == null || session.getAttribute(Constant.USERINFO_SESSION_KEY) == null) {return new ResponseBodyMessage<>(-2, "请登录后再操作", null);}User user = (User) session.getAttribute(Constant.USERINFO_SESSION_KEY);// 拉取用户最近收听的 100 首歌// TODO 可以从 redis 中,找:"user:1:listenmusic:27"  "27"   过期时间:30 天// TODO 获取到音乐 ID 后,就能去 redis 中查找对应音乐的标签 "musicLabel:music:27"  "二次元", "摇滚", "动漫"// TODO 先从 redis 中获取该用户的听歌标签排行榜,如果没有,才执行后面的步骤String userLabelRank = "user:" + user.getId() + ":recommendlabel:rank";Set<String> set = stringRedisTemplate.opsForZSet().reverseRange(userLabelRank, 0, -1);if (set != null && set.size() != 0) {// 说明得到了标签排行榜,则根据标签来查找推荐音乐即可log.info("得到指定用户标签排行榜: " + set);Set<Music> musicRecommend = new HashSet<>();for (String label : set) {// 根据音乐标签,获取播放量高的音乐if (!musicRecommend.isEmpty() && musicRecommend.size() >= 20) {break;}List<Music> music = musicService.getHighViewCountMusicByLabelName(label);if (music != null && music.size() != 0) {musicRecommend.addAll(music);}}log.info("redis 里的推荐音乐合集: " + musicRecommend);// 然后取数据库里查询剩下的音乐, 首先获取以及推荐了的歌曲的 idList<Integer> musicIds = new ArrayList<>();for (Music music : musicRecommend) {musicIds.add(music.getId());}// 如果 redis 里没有歌曲,则直接到 mysql 中获取所有歌曲List<Music> musicsNotInMusicIds = null;if (musicIds.size() > 0) {musicsNotInMusicIds = musicService.getMusicsNotInMusicIds(musicIds);} else {// 说明此时 redis 中什么音乐都没有,则需要去 mysql 中查找所有音乐musicsNotInMusicIds = musicService.getMusics();}log.info("剩下的音乐合集: " + musicsNotInMusicIds);// 再把剩下的音乐全部添加到推荐音乐合集的后面,最后返回即可List<Music> retMusic = new ArrayList<>();for (Music music : musicRecommend) {retMusic.add(music);}for (Music music : musicsNotInMusicIds) {retMusic.add(music);}log.info("返回的推荐音乐 + 剩下的音乐合集: " + retMusic);return new ResponseBodyMessage<>(200, null, retMusic);}// 到这里说明 redis 还没有这个用户的标签排行榜,所以从 redis 拉取 100 歌统计标签,再添加排行榜String listenmusicPattern = "user:" + user.getId() + ":listenmusic:*";List<String> keys = new ArrayList<>();log.info("recommendMusics, 根据样式: " + listenmusicPattern + "去 redis 中扫描符合要求的 keys");// 指定样式以及每次扫描的数量ScanOptions scanOptions = ScanOptions.scanOptions().match(listenmusicPattern).count(1000).build();Set<String> execute = stringRedisTemplate.execute(new RedisCallback<Set<String>>() {@Overridepublic Set<String> doInRedis(RedisConnection connection) {Set<String> binaryKeys = new HashSet<>();Cursor<byte[]> cursor = connection.scan(scanOptions);while (cursor.hasNext()) {binaryKeys.add(new String(cursor.next()));}return binaryKeys;}});log.info("recommendMusics, redis 根据指定音乐听歌记录查找的 keys :" + execute);// 然后根据 keys 获取到音乐 ID,再根据音乐 ID 获取音乐详情List<String> musicId = stringRedisTemplate.opsForValue().multiGet(execute);log.info("recommendMusics, redis 根据指定音乐听歌记录查找的 musicId :" + musicId);List<Music> musicRedis = new ArrayList<>();if (musicId != null && musicId.size() > 0) {for (String id : musicId) {String key = "music:info:" + id;try {Music music = objectMapper.readValue(stringRedisTemplate.opsForValue().get(key), Music.class);musicRedis.add(music);if (musicRedis.size() >= 100) {break;}} catch (JsonProcessingException e) {log.error(e.getMessage());}}log.info("recommendMusics, redis 根据指定音乐听歌记录查找的 musics :" + musicRedis);// 此时就获取到了近 30 天用户收听的 100 首歌曲// 然后统计每首歌曲的音乐标签,并且排序 "musicLabel:music:27"  "二次元", "摇滚", "动漫"Map<String, Double> labelMap = new HashMap<>();// 根据 musicId,去 redis 中查找对应的音乐标签for (Music music : musicRedis) {String labelMusicKey = "musicLabel:music:" + music.getId();Set<String> labelList = stringRedisTemplate.opsForSet().members(labelMusicKey);for (String label : labelList) {Double value = labelMap.getOrDefault(label, 0.0);labelMap.put(label, value + 1.0);}// TODO 如果 redis 中的对应音乐标签是空,则需要从数据库中得到音乐标签,并存入 redis 中if (labelList == null || labelList.isEmpty() || labelList.size() <= 0) {List<String> labels = musicLabelService.getLabelByMusicId(music.getId());for (String label : labels) {// 将音乐标签也添加到 redis 中String redisMusicLabel = "musicLabel:music:" + music.getId();stringRedisTemplate.opsForSet().add(redisMusicLabel, label);stringRedisTemplate.expire(redisMusicLabel, Duration.ofDays(60));Double value = labelMap.getOrDefault(label, 0.0);labelMap.put(label, value + 1.0);}}}// 然后将其排序,可以用 redis 的 zset 来完成// 还是先约定 key: "user:1:recommendlabel:rank"  value: "50","二次元" "30","动漫"  "20","摇滚"String userRecommendLabelRank = "user:" + user.getId() + ":recommendlabel:rank";Set<ZSetOperations.TypedTuple<String>> userRecommendLabelRankValue = new HashSet<>();for (Map.Entry<String, Double> entry : labelMap.entrySet()) {ZSetOperations.TypedTuple<String> typedTuple = ZSetOperations.TypedTuple.of(entry.getKey(), entry.getValue());userRecommendLabelRankValue.add(typedTuple);}if (!userRecommendLabelRankValue.isEmpty()) {stringRedisTemplate.opsForZSet().add(userRecommendLabelRank, userRecommendLabelRankValue);stringRedisTemplate.expire(userRecommendLabelRank, Duration.ofDays(15));}// 此时再从 redis 中获取所有的标签,然后再根据标签推荐音乐set = stringRedisTemplate.opsForZSet().reverseRange(userRecommendLabelRank, 0, -1);// TODO 遍历标签,从 mysql 中获取推荐音乐log.info("得到指定用户标签排行榜: " + set);Set<Music> musicRecommend = new HashSet<>();for (String label : set) {// 根据音乐标签,获取播放量高的音乐if (!musicRecommend.isEmpty() && musicRecommend.size() >= 20) {break;}List<Music> music = musicService.getHighViewCountMusicByLabelName(label);if (music != null && music.size() != 0) {musicRecommend.addAll(music);}}// 然后取数据库里查询剩下的音乐, 首先获取以及推荐了的歌曲的 idList<Integer> musicIds = new ArrayList<>();for (Music music : musicRecommend) {musicIds.add(music.getId());}List<Music> musicsNotInMusicIds = musicService.getMusicsNotInMusicIds(musicIds);log.info("剩下的音乐合集: " + musicsNotInMusicIds);// 再把剩下的音乐全部添加到推荐音乐合集的后面,最后返回即可List<Music> retMusic = new ArrayList<>();for (Music music : musicRecommend) {retMusic.add(music);}for (Music music : musicsNotInMusicIds) {retMusic.add(music);}log.info("返回的推荐音乐 + 剩下的音乐合集: " + retMusic);return new ResponseBodyMessage<>(200, null, retMusic);}// 代码走到这里,说明 redis 里没有关于该用户听歌的记录以及标签排行榜,则需要添加List<Music> music1 = musicService.getLast100ListenedMusic(user.getId());log.info("最近 100 首音乐" + music1.toString());if (music1 == null || music1.size() == 0) {// 说明用户没有听过音乐// 就可以直接推送库里的全部音乐List<Music> result = musicService.getMusics();return new ResponseBodyMessage<>(200, null, result);}// 根据 musicId 来去标签表获取标签,并统计出标签的出现次数,并按照出现次数排序,优先级队列Map<String, Integer> labelMap = new HashMap<>();for (Music music : music1) {List<String> label = musicLabelService.getLabelByMusicId(music.getId());for (String labelName : label) {int count = labelMap.getOrDefault(labelName, 0);labelMap.put(labelName, count + 1);}}log.info("音乐标签" + labelMap.toString());// 建立大根堆, 并把元素放入堆中PriorityQueue<Map.Entry<String, Integer>> labelQueue = new PriorityQueue<>(new Comparator<Map.Entry<String, Integer>>() {@Overridepublic int compare(Map.Entry<String, Integer> o1, Map.Entry<String, Integer> o2) {return o2.getValue() - o1.getValue();}});labelQueue.addAll(labelMap.entrySet());log.info("排序后的音乐标签" + labelQueue.toString());// TODO 然后将标签排行榜添加到 redis 中String userRecommendLabelRank = "user:" + user.getId() + ":recommendlabel:rank";Set<ZSetOperations.TypedTuple<String>> userRecommendLabelRankValue = new HashSet<>();for (Map.Entry<String, Integer> entry : labelMap.entrySet()) {ZSetOperations.TypedTuple<String> typedTuple = ZSetOperations.TypedTuple.of(entry.getKey(), (entry.getValue()) * 1.0);userRecommendLabelRankValue.add(typedTuple);}log.info("将指定用户标签排行榜添加到 redis 中: " + userRecommendLabelRankValue);stringRedisTemplate.opsForZSet().add(userRecommendLabelRank, userRecommendLabelRankValue);stringRedisTemplate.expire(userRecommendLabelRank, Duration.ofDays(15));// 然后再根据音乐标签,来去查找播放量较高的, 推荐够 20 首就行,// 如果队列为空了还不够 20 首,那就能推荐多少就推荐多少Set<Music> musicRecommend = new HashSet<>();while (!labelQueue.isEmpty()) {if (!musicRecommend.isEmpty() && musicRecommend.size() >= 20) {break;}Map.Entry<String, Integer> label = labelQueue.poll();// 根据音乐标签,获取播放量高的音乐List<Music> music = musicService.getHighViewCountMusicByLabelName(label.getKey());if (music != null && music.size() != 0) {musicRecommend.addAll(music);}}log.info("推荐的音乐" + musicRecommend.toString());// 然后取数据库里查询剩下的音乐, 首先获取以及推荐了的歌曲的 idList<Integer> musicIds = new ArrayList<>();for (Music music : musicRecommend) {musicIds.add(music.getId());}List<Music> musicsNotInMusicIds = musicService.getMusicsNotInMusicIds(musicIds);log.info("剩下的音乐合集: " + musicsNotInMusicIds);// 再把剩下的音乐全部添加到推荐音乐合集的后面,最后返回即可List<Music> retMusic = new ArrayList<>();for (Music music : musicRecommend) {retMusic.add(music);}for (Music music : musicsNotInMusicIds) {retMusic.add(music);}log.info("返回的推荐音乐 + 剩下的音乐合集: " + retMusic);return new ResponseBodyMessage<>(200, null, retMusic);}

写完了之后,运行下程序,测试下看看效果。

没啥问题,能正确推荐,然后我们再来修改收藏音乐模块的代码。

收藏音乐模块,去 redis 中找,找不到再去 mysql 里找,mysql 里找到后存入 redis 里。

收藏音乐模块代码

@Slf4j
@RestController
@RequestMapping("/lovemusic")
public class LoveMusicController {@Autowiredprivate LoveMusicService loveMusicService;@Autowiredprivate MusicService musicService;@Autowiredprivate StringRedisTemplate stringRedisTemplate;@Autowiredprivate ObjectMapper objectMapper;@RequestMapping("/like")public ResponseBodyMessage<Boolean> like(Integer musicId, HttpServletRequest request) {// 检查是否登录HttpSession session = request.getSession(false);if (session == null || session.getAttribute(Constant.USERINFO_SESSION_KEY) == null) {return new ResponseBodyMessage<>(-2, "请登录后再操作", false);}User user = (User) session.getAttribute(Constant.USERINFO_SESSION_KEY);Integer userId = user.getId();log.info("userId: " + userId + ", musicId: " + musicId);if (userId == null || musicId == null || userId < 1 || musicId < 1) {return new ResponseBodyMessage<>(-1, "userId 或 musicId 不合法", false);}// 校验音乐是否存在// 可以先去 redis 中找,找不到再去 mysql 中找String redisMusicKey = "music:info:" + musicId;String redisMusicStr = stringRedisTemplate.opsForValue().get(redisMusicKey);Music music = null;if (StringUtils.hasLength(redisMusicStr)) {try {music = objectMapper.readValue(redisMusicStr, Music.class);} catch (JsonProcessingException e) {log.error(e.getMessage());}} else {// 添加到 redis 中,将音乐music = musicService.getMusicById(musicId);if (music != null) {try {// 将对应音乐存入 redis 中String musicKey = "music:info:" + music.getId();String musicname = "musicName:" + music.getTitle();stringRedisTemplate.opsForValue().set(musicKey, objectMapper.writeValueAsString(music), Duration.ofDays(60));stringRedisTemplate.opsForValue().set(musicname, String.valueOf(music.getId()), Duration.ofDays(60));log.info("getMusics, 音乐不在 redis 中存在,需要新增音乐到 redis 中:" + music);} catch (JsonProcessingException e) {log.error(e.getMessage());}}}if (music == null) {return new ResponseBodyMessage<>(-1, "曲库中没有这首歌", false);}// 1. 检查是否已经收藏过该音乐String redisLovedMusicKey = "lovedMusic:user:" + user.getId() + ":music:" + music.getTitle();String redisLovedMusicStr = stringRedisTemplate.opsForValue().get(redisLovedMusicKey);if (StringUtils.hasLength(redisLovedMusicStr)) {// 已经收藏过,就需要取消收藏stringRedisTemplate.delete(redisLovedMusicKey);Integer result = loveMusicService.deleteLoved(musicId, userId);stringRedisTemplate.delete(redisLovedMusicKey);return new ResponseBodyMessage<>(-1, "取消收藏成功", false);}Music ret = loveMusicService.checkLoved(musicId, userId);if (ret != null) {// 从 redis 中删除收藏记录redisLovedMusicKey = "lovedMusic:user:" + user.getId() + ":music:" + ret.getTitle();stringRedisTemplate.delete(redisLovedMusicKey);Integer result = loveMusicService.deleteLoved(musicId, userId);stringRedisTemplate.delete(redisLovedMusicKey);return new ResponseBodyMessage<>(-1, "取消收藏成功", false);}// 2. 添加音乐至收藏列表Integer result = loveMusicService.insert(userId, musicId);if (result > 0) {return new ResponseBodyMessage<>(200, null, true);}return new ResponseBodyMessage<>(-1, "收藏失败", false);}// 查找指定用户的收藏列表中的指定歌曲@RequestMapping("/get")public ResponseBodyMessage<List<Music>> getMusic(String musicName, HttpServletRequest request) {// 检查是否登录HttpSession session = request.getSession(false);if (session == null || session.getAttribute(Constant.USERINFO_SESSION_KEY) == null) {return new ResponseBodyMessage<>(-2, "请登录后再操作", null);}User user = (User) session.getAttribute(Constant.USERINFO_SESSION_KEY);Integer userId = user.getId();log.info("userId: " + userId);if (userId == null || userId < 1) {return new ResponseBodyMessage<>(-1, "userId 或 musicId 不合法", null);}// 如果歌名为空,则查询所有列表if (!StringUtils.hasLength(musicName)) {return new ResponseBodyMessage<>(200, null, loveMusicService.getLovedMusics(userId));}// TODO 如果歌名为不空,就可以根据 key:"lovedMusic:user:1:music:*蒼月的懺悔詩*"  value:"37"String redisLovedPatterns = "lovedMusic:user:" + user.getId() + ":music:*" + musicName + "*";log.info("getMusic, 根据样式: " + redisLovedPatterns + "去 redis 中扫描符合要求的 keys");// 指定样式以及每次扫描的数量ScanOptions scanOptions = ScanOptions.scanOptions().match(redisLovedPatterns).count(1000).build();Set<String> execute = stringRedisTemplate.execute(new RedisCallback<Set<String>>() {@Overridepublic Set<String> doInRedis(RedisConnection connection) {Set<String> binaryKeys = new HashSet<>();Cursor<byte[]> cursor = connection.scan(scanOptions);while (cursor.hasNext()) {binaryKeys.add(new String(cursor.next()));}return binaryKeys;}});log.info("getMusic, 根据音乐名查找的 keys :" + execute);// 再根据音乐名字获取到音乐 ID,从而获取到音乐详情List<String> list = stringRedisTemplate.opsForValue().multiGet(execute);// 得到音乐 ID 后,就是根据音乐 ID 获取音乐本体log.info("getMusic, 从 redis 中获取的音乐 ID 集合 musicIds: " + list);Set<Music> musicSet = new HashSet<>();List<Integer> musicIds = new ArrayList<>();if (list != null && list.size() > 0) {for (String musicId : list) {musicIds.add(Integer.valueOf(musicId));try {String musicKey = "music:info:" + musicId;String redisMusicStr = stringRedisTemplate.opsForValue().get(musicKey);if (!StringUtils.hasLength(redisMusicStr)) {Music musicTmp = musicService.getMusicById(Integer.valueOf(musicId));String s = objectMapper.writeValueAsString(musicTmp);stringRedisTemplate.opsForValue().set(musicKey, s);String tmpName = "musicName:" + musicTmp.getTitle();stringRedisTemplate.opsForValue().set(tmpName, musicId);}Music musicRedis = objectMapper.readValue(stringRedisTemplate.opsForValue().get(musicKey), Music.class);musicSet.add(musicRedis);} catch (JsonProcessingException e) {throw new RuntimeException(e);}}log.info("getMusic, 从 redis 中获取的收藏音乐集合 musics: " + musicSet);// TODO 然后再去 mysql 中查找剩下的收藏数据,如果有歌曲名的话List<Music> result = null;result = loveMusicService.getLovedMusicsByNameNotInIds(musicName, musicIds);// TODO 把对应歌曲信息添加到 redis 中if (result != null && result.size() != 0) {log.info("getMusic, 收藏音乐不在 redis 中存在,需要新增音乐到 redis 中:" + result);for (Music music : result) {musicSet.add(music);// 将对应音乐存入 redis 中String redisLovedPatternsKey = "lovedMusic:user:" + user.getId() + ":music:" + music.getTitle();stringRedisTemplate.opsForValue().set(redisLovedPatternsKey, String.valueOf(music.getId()));stringRedisTemplate.expire(redisLovedPatternsKey, Duration.ofDays(60));}}return new ResponseBodyMessage<>(200, null, new ArrayList<>(musicSet));}List<Music> ret = loveMusicService.getLovedMusicsByName(musicName, userId);if (ret == null || ret.size() <= 0) {return new ResponseBodyMessage<>(-1, "没有你要找的歌曲", ret);}log.info("getMusics, 音乐不在 redis 中存在,需要新增音乐到 redis 中:" + ret);for (Music music : ret) {// 将对应音乐存入 redis 中String redisLovedPatternsKey = "lovedMusic:user:" + user.getId() + ":music:" + music.getTitle();stringRedisTemplate.opsForValue().set(redisLovedPatternsKey, String.valueOf(music.getId()));stringRedisTemplate.expire(redisLovedPatternsKey, Duration.ofDays(60));}return new ResponseBodyMessage<>(200, null, ret);}// 取消收藏@RequestMapping("/delete")public ResponseBodyMessage<Boolean> deleteLovedMusic(Integer musicId, HttpServletRequest request) {// 检查是否登录HttpSession session = request.getSession(false);if (session == null || session.getAttribute(Constant.USERINFO_SESSION_KEY) == null) {return new ResponseBodyMessage<>(-2, "请登录后再操作", null);}if (musicId == null || musicId < 1) {return new ResponseBodyMessage<>(-1, "userId 或 musicId 不合法", null);}User user = (User) session.getAttribute(Constant.USERINFO_SESSION_KEY);Integer userId = user.getId();log.info("userId: " + userId);if (userId == null || userId < 1) {return new ResponseBodyMessage<>(-1, "userId 或 musicId 不合法", null);}// 将收藏记录从 redis 中删除// 先根据 musicId 得到 音乐本体,然后通过本体拿到歌名,再删除String redisMusicKey = "music:info:" + musicId;String redisMusicStr = stringRedisTemplate.opsForValue().get(redisMusicKey);String redisLovedMusicKey = "";try {if (StringUtils.hasLength(redisMusicStr)) {Music redisMusic = objectMapper.readValue(redisMusicStr, Music.class);redisLovedMusicKey = "lovedMusic:user:" + user.getId() + ":music:" + redisMusic.getTitle();// 然后删除stringRedisTemplate.delete(redisLovedMusicKey);}} catch (JsonProcessingException e) {log.error(e.getMessage());}Integer result = loveMusicService.deleteLoved(musicId, userId);if (result > 0) {if (StringUtils.hasLength(redisLovedMusicKey)) {stringRedisTemplate.delete(redisLovedMusicKey);}return new ResponseBodyMessage<>(200, null, true);}return new ResponseBodyMessage<>(-1, "移除音乐失败", false);}}

写完后可以测试一下看看。

点击一首未收藏过的歌曲收藏后,进入对应收藏页面:

再点一下就取消收藏,没问题。

测试下在收藏音乐中查询:

再取消一下收藏:

没问题,然后再来修改评论模块的代码。

涉及到 mysql 查数据的操作,还是先去 redis 中查找,然后没找到的话再去 mysql 中找,最后将找到的数据添加到 redis 中即可。

评论模块代码

@Slf4j
@RestController
@RequestMapping("/comment")
public class CommentController {@Autowiredprivate CommentService commentService;@Autowiredprivate MusicService musicService;@Autowiredprivate StringRedisTemplate stringRedisTemplate;@Autowiredprivate ObjectMapper objectMapper;// 如果某一首歌的评论非常多,达到了 10w 条,那就可以单独为这首歌创建一张表@RequestMapping("/addComment")public ResponseBodyMessage<Boolean> addComment(@RequestParam String commentStr, @RequestParam String songName, HttpServletRequest request) {HttpSession session = request.getSession(false);if (session == null || session.getAttribute(Constant.USERINFO_SESSION_KEY) == null) {return new ResponseBodyMessage<>(-2, "您尚未登录!", false);}if (!StringUtils.hasLength(commentStr) || !StringUtils.hasLength(songName)) {return new ResponseBodyMessage<>(-1, "评论内容不能为空!", false);}if (commentStr.length() > 50) {return new ResponseBodyMessage<>(-1, "评论内容大于 50 字,评论失败", false);}// 先根据歌名找到歌曲// TODO 先去 redis 中根据歌曲名查找到歌曲 ID,然后再通过 ID 查找到歌曲详情String musicname = "musicName:" + songName;String musicId = stringRedisTemplate.opsForValue().get(musicname);Music music = null;if (StringUtils.hasLength(musicId)) {try {String musicInfoKey = "music:info:" + musicId;music = objectMapper.readValue(stringRedisTemplate.opsForValue().get(musicInfoKey), Music.class);User user = (User) session.getAttribute(Constant.USERINFO_SESSION_KEY);Comment comment = new Comment(user.getId(), music.getId(), commentStr);// 新增评论Integer result = commentService.addComment(comment);if (result > 0) {return new ResponseBodyMessage<>(200, null, true);} else {return new ResponseBodyMessage<>(-1, "评论失败,请联系管理员", false);}} catch (JsonProcessingException e) {log.error(e.getMessage());}} else {// 到这里说明 redis 没有对应歌曲数据music = musicService.getSongByName(songName);}if (music == null) {return new ResponseBodyMessage<>(-1, "你想评论的歌曲不存在", false);}User user = (User) session.getAttribute(Constant.USERINFO_SESSION_KEY);Comment comment = new Comment(user.getId(), music.getId(), commentStr);// 新增评论Integer result = commentService.addComment(comment);if (result > 0) {try {// 将对应音乐存入 redis 中String musicKey = "music:info:" + music.getId();String musicnameKey = "musicName:" + music.getTitle();stringRedisTemplate.opsForValue().set(musicKey, objectMapper.writeValueAsString(music), Duration.ofDays(60));stringRedisTemplate.opsForValue().set(musicnameKey, String.valueOf(music.getId()), Duration.ofDays(60));log.info("getMusics, 音乐不在 redis 中存在,需要新增音乐到 redis 中:" + music);} catch (JsonProcessingException e) {log.error(e.getMessage());}return new ResponseBodyMessage<>(200, null, true);}return new ResponseBodyMessage<>(-1, "评论失败,请联系管理员", false);}@RequestMapping("/getCommentList")public ResponseBodyMessage<Comments> getCommentList(@RequestParam String songName, Integer page, Integer commentsPerPage) {if (!StringUtils.hasLength(songName)) {return new ResponseBodyMessage<>(-1, "歌曲名为空", null);}if (page <= 0 || commentsPerPage <= 0) {// 如果当前页数小于等于 0,或者每页展示数量小于等于 0,则提醒参数非法return new ResponseBodyMessage<>(-1, "要获取的当前评论页数或者每页展示评论数不合法", null);}// redis,根据音乐名获取音乐 IDString musicnameKey = "musicName:" + songName;String redisMusicId = stringRedisTemplate.opsForValue().get(musicnameKey);String redisMusicInfo = "music:info:" + redisMusicId;String redisMusicStr = stringRedisTemplate.opsForValue().get(redisMusicInfo);Music music = null;if (StringUtils.hasLength(redisMusicStr)) {try {music = objectMapper.readValue(redisMusicStr, Music.class);} catch (JsonProcessingException e) {log.error(e.getMessage());}} else {// 将音乐加入 redis 中music = musicService.getSongByName(songName);try {musicnameKey = "musicName:" + music.getTitle();redisMusicInfo = "music:info:" + music.getId();stringRedisTemplate.opsForValue().set(musicnameKey, String.valueOf(music.getId()));String redisStr = objectMapper.writeValueAsString(music);stringRedisTemplate.opsForValue().set(redisMusicInfo, redisStr);stringRedisTemplate.expire(musicnameKey, Duration.ofDays(60));stringRedisTemplate.expire(redisMusicInfo, Duration.ofDays(60));} catch (JsonProcessingException e) {log.error(e.getMessage());}}if (music == null) {return new ResponseBodyMessage<>(-1, "歌曲不存在", null);}Integer startIndex = (page - 1) * commentsPerPage;// 根据 musicId 来获取当前页的评论列表List<Comment> commentList = commentService.getComments(music.getId(), startIndex, commentsPerPage);// 获取这首歌的评论总数Integer totalCommentCount = commentService.getCommentCountsByMusicId(music.getId());// 计算总页数Integer totalPage = (totalCommentCount % commentsPerPage == 0) ? (totalCommentCount / commentsPerPage) : (totalCommentCount / commentsPerPage + 1);Comments comments = new Comments(commentList, totalPage, commentsPerPage);log.info("接收参数" + comments);return new ResponseBodyMessage<>(200, null, comments);}
}

这个也测试一下,点击评论区就行。

没问题。

接下来就是修改删除音乐以及批量删除音乐的代码,就是要删除之前所有的约定过的 key。

而且顺序是先删除 redis 中的 key,再删除 mysql 里的数据,然后再次删除 redis 中对应的 key。

redis 删除两次是为了保证数据的准确性,防止 mysql 里面的数据删了,但是 redis 里对应的数据还存在这种情况。这里之前删除的逻辑也进行了完善,只有是自己上传的歌曲才能删除,不是自己上传的歌曲就不难删除。

删除音乐代码

    // 不仅要从数据库中删除,还要从本地服务器中删除,要是歌曲被删除了,对应的评论也得被删除,// TODO 还得删除音乐标签  听歌记录@RequestMapping("/delete")public ResponseBodyMessage<Boolean> delete(Integer id, HttpServletRequest request) {HttpSession session = request.getSession(false);if (session == null) {return new ResponseBodyMessage<>(-2, "您尚未登录,请登录后再来操作", false);}User user = (User) session.getAttribute(Constant.USERINFO_SESSION_KEY);if (id == null || id < 1) {return new ResponseBodyMessage<>(-1, "id 不合法", false);}Music music = musicService.getMusicById(id);if (music == null) {return new ResponseBodyMessage<>(-1, "没有你要删除的歌曲", false);}//  根据 ID 删除音乐表中的记录//  但是首先得根据 ID 获取到音乐名,if (music.getUserId() != user.getId()) {return new ResponseBodyMessage<>(-1, "这首歌不是您上传的,不能删除", false);}Integer result = musicService.deleteById(id);if (result > 0) {// TODO 还得删除音乐标签  听歌记录// TODO 根据音乐 id 来删除音乐标签,以及根据 id 来删除听歌记录// TODO musicName:音乐名   以及  music:info:<id>// TODO 可以从 redis 中删除:"user:1:listenmusic:27"  "27"   过期时间:30 天// TODO 获取到音乐 ID 后,就能去 redis 中删除对应音乐的标签 "musicLabel:music:27"  "二次元", "摇滚", "动漫"// TODO 删除收藏记录 key:"lovedMusic:user:1:music:*蒼月的懺悔詩*"  value:"37"String redisListenRecord = "user:" + user.getId() + ":listenmusic:" + music.getId();String redisMusicLabel = "musicLabel:music:" + music.getId();String redisMusic = "music:info:" + music.getId();String redisMusicName = "musicName:" + music.getTitle();String redisLovedMusic = "lovedMusic:user:*:music:" + music.getTitle();List<String> deleteRedisRecord = new ArrayList<>();deleteRedisRecord.add(redisListenRecord);deleteRedisRecord.add(redisMusicLabel);deleteRedisRecord.add(redisMusic);deleteRedisRecord.add(redisMusicName);deleteRedisRecord.add(redisLovedMusic);stringRedisTemplate.delete(deleteRedisRecord);Integer ret = loveMusicService.deleteLovedByMusicId(id);Integer commentRet = commentService.deleteCommentByMusicId(id);Integer musicLabelRet = musicLabelService.deleteMusicLabelByMusicId(id);Integer listenRecordRet = listenService.deleteListenRecordByMusicId(id);// 数据库音乐路径为 /music/get?path=xxx.mp3// 还得从服务器中删除音乐Integer index = music.getUrl().indexOf("=");String url = music.getUrl().substring(index + 1);String path = SAVE_PATH + url + ".mp3";File file = new File(path);boolean delete = file.delete();// 多删除一次,保证数据一致性stringRedisTemplate.delete(deleteRedisRecord);if (delete) {return new ResponseBodyMessage<>(200, null, true);} else {return new ResponseBodyMessage<>(-1, "删除失败, 请联系管理员", false);}} else {return new ResponseBodyMessage<>(-1, "删除失败, 请联系管理员", false);}}@RequestMapping("/deleteSel")public ResponseBodyMessage<Boolean> deleteSelMusic(@RequestParam("id[]") List<Integer> id, HttpServletRequest request) {HttpSession session = request.getSession(false);if (session == null) {return new ResponseBodyMessage<>(-2, "您尚未登录,请登录后再来操作", false);}User user = (User) session.getAttribute(Constant.USERINFO_SESSION_KEY);// 批量删除音乐也同理,是自己上传的曲子才能删除,不是自己上传的曲子就不能删除if (id == null || id.size() <= 0) {log.error("ids: " + id);return new ResponseBodyMessage<>(-1, "请选中歌曲后再来删除", false);}//  先根据曲子 ID,查询所有的曲子详情,然后再来一一对比 userID 与当前用户的 ID 是否相同//  如果相同,那就删除该歌曲,如果不相同,那就跳过,如果最后一首歌都没删除,则提示用户选中的歌曲不是您上传的,都不能删除List<Music> musicList = musicService.getMusicsByIds(id);if (musicList == null || musicList.size() <= 0) {return new ResponseBodyMessage<>(-1, "没有你要删除的歌曲", false);}Integer count = 0;for (int i = 0; i < musicList.size(); i++) {// 看看当前歌曲的上传者与当前用户是否相同Music music = musicList.get(i);if (music.getUserId() == user.getId()) {// 可以删除// TODO 还得删除音乐标签  听歌记录// TODO 根据音乐 id 来删除音乐标签,以及根据 id 来删除听歌记录// TODO musicName:音乐名   以及  music:info:<id>// TODO 可以从 redis 中删除:"user:1:listenmusic:27"  "27"   过期时间:30 天// TODO 获取到音乐 ID 后,就能去 redis 中删除对应音乐的标签 "musicLabel:music:27"  "二次元", "摇滚", "动漫"// TODO 删除收藏记录 key:"lovedMusic:user:1:music:*蒼月的懺悔詩*"  value:"37"String redisListenRecord = "user:" + user.getId() + ":listenmusic:" + music.getId();String redisMusicLabel = "musicLabel:music:" + music.getId();String redisMusic = "music:info:" + music.getId();String redisMusicName = "musicName:" + music.getTitle();String redisLovedMusic = "lovedMusic:user:*:music:" + music.getTitle();List<String> deleteRedisRecord = new ArrayList<>();deleteRedisRecord.add(redisListenRecord);deleteRedisRecord.add(redisMusicLabel);deleteRedisRecord.add(redisMusic);deleteRedisRecord.add(redisMusicName);deleteRedisRecord.add(redisLovedMusic);stringRedisTemplate.delete(deleteRedisRecord);// 删除该歌曲,从数据库删除,删除收藏列表,删除本地文件,删除关于这首歌的评论Integer result1 = musicService.deleteById(music.getId());Integer result2 = loveMusicService.deleteLovedByMusicId(music.getId());Integer result3 = commentService.deleteCommentByMusicId(music.getId());// 还得删除音乐标签  听歌记录// 根据音乐 id 来删除音乐标签,以及根据 id 来删除听歌记录Integer musicLabelRet = musicLabelService.deleteMusicLabelByMusicId(music.getId());Integer listenRecordRet = listenService.deleteListenRecordByMusicId(music.getId());Integer index = music.getUrl().indexOf("=");String url = music.getUrl().substring(index + 1);String path = SAVE_PATH + url + ".mp3";File file = new File(path);boolean delete = file.delete();// 多删除一次,保证数据一致性stringRedisTemplate.delete(deleteRedisRecord);if (result1 > 0 && delete) {count++;}}}if (count > 0) {// 删除所有你选中的歌曲并且该歌曲是你之前上传的。return new ResponseBodyMessage<>(200, null, true);} else {return new ResponseBodyMessage<>(-1, "很抱歉,您所选中的歌曲都不是您上传的,不能进行删除", false);}}

写完后来测试一下。

再来试下批量删除。

再试试删除不是自己所上传的音乐:

也没问题。接下来就是部署项目了,就跟之前讲过的流程一摸一样。

部署项目

就是配置文件得稍微修改下。

端口号得改为 redis 的端口号,然后就是打包,重新创建表,清空 redis ,然后新建的音乐文件夹,将打好的 jar 包拖到 xshell 里,然后用 nohup java -jar xxxxx & 来运行即可。 

http://www.xdnf.cn/news/825931.html

相关文章:

  • PlayBook 详解
  • SQL语言基础【学习总结】
  • 在Android Studio下进行NDK开发
  • 极狐GitLab 17.1 到底发布了哪些重大功能?
  • 浅谈网络代理 proxy
  • 【物联网】探索NE555:一款经典的集成电路(超详细)
  • JSON 数组
  • 17.Oracle11g的PL/SQL基础
  • 13个程序员常用开发工具用途推荐整理
  • 原码, 反码, 补码 详解
  • 服务器135、137、138、139、445等端口解释和关闭方法
  • LPC特征提取及语音信号处理
  • f12获取网页文本_F12 - 开发者工具详解
  • SWA(随机权重平均) for Pytorch
  • AspectJ详解
  • web-uploader多文件上传问题,预览问题
  • Mysql数据类型最细讲解
  • 利用weka进行数据挖掘——基于Apriori算法的关联规则挖掘实例
  • 矩阵运算规律总结
  • Sortable.js官方文档记录
  • 【浏览器】五大最好用的浏览器 最受欢迎的浏览器软件
  • 一文读懂上拉电阻:工作原理和阻值确定
  • bootstraptable 手册_JS表格组件神器bootstrap table使用指南详解
  • 一文读懂 K8s 持久化存储流程
  • COCOS学习笔记--TexturePacker使用详解
  • Hutool工具包等常用工具类总结
  • 电子管是什么?
  • CSharp(C#)语言_反射 和 特性
  • Windows 中安装 Mysql
  • 差分逻辑电平——LVDS、CML、LVPECL、HCSL互连