MyMusic音乐管理系统
Spring Boot + Vue 3 全栈音乐平台:从零开发到部署上线
> 用 Spring Boot + Vue 3 做了一个在线音乐播放系统,包括用户端、管理端和鸿蒙端。这篇文章记录一下整个开发过程、核心代码和踩过的坑。
## 一、项目介绍
MyMusic 是一个在线音乐播放平台,支持用户注册登录、在线听歌、收藏、评论,管理端可以管理歌曲、歌手、歌单等。整个系统前后端分离,部署在阿里云服务器上,配了域名和 HTTPS。
主要功能:
用户端:邮箱验证码注册、JWT 登录、在线播放、进度控制、上下首切换、播放列表、按歌手/歌单/风格浏览、搜索、收藏、评论、修改头像/昵称/密码、轮播图
管理端:歌曲管理、歌手管理、歌单管理(绑定/解绑歌曲)、轮播图管理、用户管理(启用/禁用)、评论管理、反馈管理
鸿蒙端:用 ArkWeb 组件加载用户端页面,在鸿蒙系统上直接运行
## 二、技术选型
| 层次 | 技术 | 版本 | 说明 |
|------|------|------|------|
| 后端框架 | Spring Boot | 3.3.7 | Web 框架 |
| ORM | MyBatis-Plus | 3.5.9 | 简化数据库操作 |
| 数据库 | MySQL | 8.x | 关系型数据库 |
| 缓存 | Redis | 7.x | 存验证码和缓存 |
| 文件存储 | MinIO | — | 存音频和图片 |
| 认证 | JWT | 4.4.0 | 无状态令牌认证 |
| 前端框架 | Vue 3 | 3.5.x | 组合式 API |
| UI 组件库 | Element Plus | 2.9.x | 管理端用 |
| CSS 框架 | Tailwind CSS | 3.4.x | 用户端用 |
| 状态管理 | Pinia | 2.3.x | Vue 3 官方推荐 |
| 构建工具 | Vite | 6.x | 前端构建 |
| Web 服务器 | Nginx | 1.24 | 反代 + SSL |
| JDK | OpenJDK | 17 | — |
| 鸿蒙端 | ArkWeb | — | 内嵌 Web 页面 |
选型思路:Spring Boot 是课程要求的技术栈。Vue 3 比 Vue 2 写起来更舒服,组合式 API 逻辑更清晰。Tailwind 写样式快,不需要来回切 CSS 文件。MinIO 比 OSS 便宜,自建服务器就能跑。JWT 比 Session 更适合前后端分离的场景。
## 三、系统架构
整个系统部署在阿里云 ECS 上,Nginx 做反向代理:
```
用户浏览器
│
├── hxakej.cn ────→ Nginx ──→ vibe-music-client/ (用户端静态文件)
│ │
│ ├── /api/ ──→ Spring Boot:8080
│ └── /minio/ ──→ MinIO:9005
│
└── admin.hxakej.cn → Nginx ──→ vibe-music-admin/ (管理端静态文件)
│
└── /api/ ──→ Spring Boot:8080
│
├── MySQL:3306
├── Redis:6379
└── MinIO:9005
鸿蒙系统 → HarmonyOS 应用 → @kit.ArkWeb Web 组件 → 加载 https://hxakej.cn
```
Nginx 做了这几件事:
1. 用户端和管理端分别用不同域名,各自指向对应的静态文件目录
2. `/api/` 路径的请求代理到后端 8080 端口
3. `/minio/` 路径代理到 MinIO 9005 端口,这样前端可以通过域名访问 MinIO 的文件
4. HTTP 全部 301 跳转到 HTTPS,两个域名各自配了 SSL 证书
## 四、项目结构
```
vibe_music/
├── vibe-music-server/ # 后端(Spring Boot)
│ ├── pom.xml
│ └── src/main/java/cn/edu/seig/vibemusic/
│ ├── config/ # 配置类
│ ├── constant/ # 常量
│ ├── controller/ # 控制器
│ ├── enumeration/ # 枚举
│ ├── handler/ # 全局异常处理
│ ├── interceptor/ # 登录拦截器
│ ├── mapper/ # MyBatis Mapper
│ ├── model/
│ │ ├── dto/ # 数据传输对象
│ │ ├── entity/ # 实体类
│ │ └── vo/ # 视图对象
│ ├── result/ # 统一响应封装
│ ├── service/impl/ # 业务实现
│ └── util/ # 工具类
│
├── MyMusic_Admin/ # 管理端(Vue 3 + Element Plus)
├── vibe-music-data/ # 项目资源文件
│ ├── artists/ # 歌手头像
│ ├── banners/ # 轮播图
│ ├── playlists/ # 歌单封面
│ ├── songCovers/ # 歌曲封面
│ ├── songs/ # 音频文件
│ └── users/ # 用户头像
└── 技术文档.md
```
后端按照 controller → service → mapper 三层架构组织。DTO 负责接收前端参数,VO 负责返回给前端的数据,Entity 对应数据库表,三者分离,避免直接暴露数据库字段。
## 五、数据库设计
数据库名 `vibe_music`,字符集 utf8mb4,一共 11 张表。核心的几张表如下:
**tb_user(用户表)**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | bigint | 主键,自增 |
| username | varchar(20) | 用户名,唯一 |
| password | varchar(64) | MD5 加密存储 |
| email | varchar(128) | 邮箱,唯一 |
| user_avatar | varchar(255) | 头像 URL |
| status | tinyint | 0 启用 / 1 禁用 |
| create_time | datetime | 创建时间 |
**tb_song(歌曲表)**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | bigint | 主键 |
| artist_id | bigint | 所属歌手 ID |
| name | varchar(255) | 歌曲名 |
| lyric | text | 歌词 |
| cover_url | varchar(255) | 封面图片 URL |
| audio_url | varchar(255) | 音频文件 URL |
| style | varchar(255) | 风格 |
**tb_artist(歌手表)**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | bigint | 主键 |
| name | varchar(100) | 歌手名 |
| avatar | varchar(255) | 头像 URL |
| area | varchar(30) | 地区 |
**tb_playlist(歌单表)**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | bigint | 主键 |
| title | varchar(255) | 歌单标题 |
| cover_url | varchar(255) | 封面 |
| style | varchar(255) | 风格 |
**tb_playlist_binding(歌单-歌曲关联表)**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | bigint | 主键 |
| playlist_id | bigint | 歌单 ID |
| song_id | bigint | 歌曲 ID |
表之间的关系:
```
tb_user ──1:N──→ tb_user_favorite ←──N:1── tb_song
│ │
├──1:N──→ tb_comment │
└──1:N──→ tb_feedback │
│
tb_artist ──1:N──────────────────────────────→│
│
tb_playlist ──1:N──→ tb_playlist_binding ←────┘
```
歌单和歌曲是多对多关系,用 tb_playlist_binding 中间表关联。用户收藏用 tb_user_favorite,一条记录代表一个用户对一首歌的收藏。
## 六、核心代码
### 6.1 统一响应封装
后端所有接口返回统一格式,方便前端统一处理:
```java
@NoArgsConstructor
@AllArgsConstructor
@Data
public class Result<T> {
private Integer code; // 0-成功 1-失败
private String message; // 提示信息
private T data; // 响应数据
public static <T> Result<T> success(T data) {
return new Result<>(0, "操作成功", data);
}
public static Result error(String message) {
return new Result<>(1, message, null);
}
}
```
前端 axios 拦截器里根据 code 判断成功还是失败,失败直接弹 message 提示。
### 6.2 JWT 令牌认证
JWT 工具类,用 HMAC256 算法签名,token 有效期 6 小时:
```java
public class JwtUtil {
private static final String SECRET_KEY = "VIBE_MUSIC";
private static final long EXPIRATION_TIME = 1000 * 60 * 60 * 6; // 6小时
// 生成 token
public static String generateToken(Map<String, Object> claims) {
return JWT.create()
.withClaim("claims", claims)
.withExpiresAt(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
.sign(Algorithm.HMAC256(SECRET_KEY));
}
// 解析 token
public static Map<String, Object> parseToken(String token) {
return JWT.require(Algorithm.HMAC256(SECRET_KEY))
.build()
.verify(token)
.getClaim("claims")
.asMap();
}
}
```
登录成功后,把用户 ID、用户名、邮箱、角色信息放进 claims 生成 token,返回给前端。前端每次请求在 Header 里带上 `Authorization: Bearer <token>`。
### 6.3 登录拦截器
拦截器负责校验 token 和权限:
```java
@Component
public class LoginInterceptor implements HandlerInterceptor {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private RolePermissionManager rolePermissionManager;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// OPTIONS 预检请求直接放行
if (request.getMethod().equalsIgnoreCase("OPTIONS")) {
return true;
}
// 白名单路径放行(登录、注册、公开接口等)
List<String> whiteListPaths = Arrays.asList(
"/admin/login", "/user/login", "/user/register",
"/user/sendVerificationCode", "/banner/getBannerList",
"/playlist/getAllPlaylists", "/artist/getAllArtists",
"/song/getAllSongs", "/song/getRecommendedSongs"
);
PathMatcher pathMatcher = new AntPathMatcher();
boolean isWhiteListPath = whiteListPaths.stream()
.anyMatch(pattern -> pathMatcher.match(pattern, path));
if (isWhiteListPath) return true;
// 从 Header 取 token
String token = request.getHeader("Authorization");
if (token != null && token.startsWith("Bearer ")) {
token = token.substring(7);
}
// 未登录但访问的是公开详情页,也放行
if (token == null || token.isEmpty()) {
// ...检查是否是允许未登录访问的路径
sendErrorResponse(response, 401, "未登录");
return false;
}
// 校验 token:先查 Redis 是否存在(防止 token 被手动失效后仍可用)
String redisToken = stringRedisTemplate.opsForValue().get(token);
if (redisToken == null) {
sendErrorResponse(response, 401, "会话已过期");
return false;
}
// 解析 token,校验角色权限
Map<String, Object> claims = JwtUtil.parseToken(token);
String role = (String) claims.get("role");
if (rolePermissionManager.hasPermission(role, requestURI)) {
ThreadLocalUtil.set(claims); // 存到 ThreadLocal,后续业务代码直接取
return true;
} else {
sendErrorResponse(response, 403, "无权限访问");
return false;
}
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
ThreadLocalUtil.remove(); // 请求结束后清理 ThreadLocal,防止内存泄漏
}
}
```
这里有两个关键点:
1. token 除了 JWT 本身的校验,还要查 Redis 是否存在。因为用户登出时会从 Redis 删掉 token,这样即使 token 没过期,登出后也无法继续使用
2. 用户信息存在 ThreadLocal 里,后续业务代码通过 `ThreadLocalUtil.get()` 直接取,不用每个方法都传 userId
### 6.4 邮箱验证码注册
注册流程:用户输入邮箱 → 请求验证码 → 后端发邮件 + 存 Redis → 用户提交注册 → 校验验证码 → 创建用户
发送验证码:
```java
@Service
public class EmailServiceImpl implements EmailService {
@Autowired
private JavaMailSenderImpl mailSender;
@Value("${spring.mail.username}")
private String from;
public String sendVerificationCodeEmail(String email) {
String verificationCode = RandomCodeUtil.generateRandomCode();
String subject = "【Vibe Music】验证码";
String content = "您的验证码为:" + verificationCode;
boolean success = sendEmail(email, subject, content);
return success ? verificationCode : null;
}
public boolean sendEmail(String to, String subject, String content) {
MimeMessage mimeMessage = mailSender.createMimeMessage();
try {
MimeMessageHelper helper = new MimeMessageHelper(mimeMessage);
helper.setFrom(from);
helper.setTo(to);
helper.setSubject(subject);
helper.setText(content);
mailSender.send(mimeMessage);
return true;
} catch (MessagingException e) {
return false;
}
}
}
```
验证码存 Redis,5 分钟过期:
```java
public Result sendVerificationCode(String email) {
String verificationCode = emailService.sendVerificationCodeEmail(email);
if (verificationCode == null) {
return Result.error("验证码发送失败");
}
// 存到 Redis,5 分钟过期
stringRedisTemplate.opsForValue().set(
"verificationCode:" + email, verificationCode, 5, TimeUnit.MINUTES
);
return Result.success("验证码发送成功");
}
```
注册时校验验证码:
```java
public Result register(UserRegisterDTO userRegisterDTO) {
// 先删验证码(一次性使用)
stringRedisTemplate.delete("verificationCode:" + userRegisterDTO.getEmail());
// 检查用户名和邮箱是否已存在
User userByUsername = userMapper.selectOne(
new QueryWrapper<User>().eq("username", userRegisterDTO.getUsername())
);
if (userByUsername != null) return Result.error("用户名已存在");
User userByEmail = userMapper.selectOne(
new QueryWrapper<User>().eq("email", userRegisterDTO.getEmail())
);
if (userByEmail != null) return Result.error("邮箱已注册");
// 密码 MD5 加密存储
String passwordMD5 = DigestUtils.md5DigestAsHex(
userRegisterDTO.getPassword().getBytes()
);
User user = new User();
user.setUsername(userRegisterDTO.getUsername())
.setPassword(passwordMD5)
.setEmail(userRegisterDTO.getEmail())
.setCreateTime(LocalDateTime.now())
.setUserStatus(UserStatusEnum.ENABLE);
userMapper.insert(user);
return Result.success("注册成功");
}
```
验证码设计成一次性的,验证完直接从 Redis 删掉,防止重复使用。
### 6.5 MinIO 文件上传
所有音频和图片都存 MinIO,不走本地磁盘。上传、删除的核心逻辑:
```java
@Service
public class MinioServiceImpl implements MinioService {
private final MinioClient minioClient;
@Value("${minio.bucket}")
private String bucketName;
@Value("${minio.endpoint}")
private String endpoint;
// 上传文件
public String uploadFile(MultipartFile file, String folder) {
try {
// 生成唯一文件名,避免覆盖
String fileName = folder + "/" + UUID.randomUUID() + "-" + file.getOriginalFilename();
minioClient.putObject(
PutObjectArgs.builder()
.bucket(bucketName)
.object(fileName)
.stream(file.getInputStream(), file.getSize(), -1)
.contentType(file.getContentType())
.build()
);
// 返回可访问的 URL
return endpoint + "/" + bucketName + "/" + fileName;
} catch (Exception e) {
throw new RuntimeException("文件上传失败:" + e.getMessage());
}
}
// 删除文件
public void deleteFile(String fileUrl) {
try {
String filePath = fileUrl.replace(endpoint + "/" + bucketName + "/", "");
minioClient.removeObject(
RemoveObjectArgs.builder()
.bucket(bucketName)
.object(filePath)
.build()
);
} catch (Exception e) {
throw new RuntimeException("文件删除失败: " + e.getMessage());
}
}
}
```
MinIO 配置类:
```java
@Configuration
public class MinioConfig {
@Value("${minio.endpoint}")
private String endpoint;
@Value("${minio.accessKey}")
private String accessKey;
@Value("${minio.secretKey}")
private String secretKey;
@Bean
public MinioClient minioClient() {
return MinioClient.builder()
.endpoint(endpoint)
.credentials(accessKey, secretKey)
.build();
}
}
```
application.yml 中的配置
```yaml
minio:
endpoint: http://47.108.193.47:9005
accessKey: root
secretKey: your-secret-key
bucket: vibe-music-data
``
文件名用 UUID 拼接原始文件名,避免同名文件覆盖。上传成功后返回完整的访问 URL,存到数据库里。
### 6.6 鸿蒙端适配
鸿蒙端没有用原生 ArkUI 开发页面,而是用 `@kit.ArkWeb` 的 Web 组件直接加载用户端线上地址。这样用户端已有的移动端适配样式在鸿蒙上也能用,开发成本几乎为零:
```typescript
// entry/src/main/ets/pages/Index.ets
import { webview } from '@kit.ArkWeb';
@Entry
@Component
struct Index {
controller: webview.WebviewController = new webview.WebviewController();
build() {
Column() {
Web({ src: 'https://hxakej.cn', controller: this.controller })
.width('100%')
.height('100%')
}
.width('100%')
.height('100%')
}
}
```
这种方式适合 Web 应用快速适配鸿蒙系统,缺点是性能不如原生开发,但对于我们这种以展示和播放为主的场景够用了。
## 七、服务器部署
### 7.1 服务器信息
| 项目 | 配置 |
|------|------|
| 云服务商 | 阿里云 ECS |
| 操作系统 | Ubuntu 24.04 LTS |
| CPU | 2 核 |
| 内存 | 2 GB |
| 域名 | hxakej.cn |
| 带宽 | 1 Mbps |
### 7.2 后端部署
后端打包成 JAR,用 systemd 管理:
```bash
# 打包
cd vibe-music-server
mvn clean package -DskipTests
# 上传到服务器
scp target/vibe-music-server-0.0.1-SNAPSHOT.jar root@47.108.193.47:/opt/vibe-music/
# 启动
systemctl start vibe-music
systemctl status vibe-music
```
systemd 服务文件 `/etc/systemd/system/vibe-music.service`:
```ini
[Unit]
Description=MyMusic Backend Service
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/vibe-music
ExecStart=/usr/bin/java -jar vibe-music-server-0.0.1-SNAPSHOT.jar
Restart=on-failure
[Install]
WantedBy=multi-user.target
```
### 7.3 前端部署
前端在本地打包,上传 dist 到服务器:
```bash
# 用户端
cd vibe-music-client
pnpm install
pnpm run build
# 上传 dist/ 到 /var/www/vibe-music-client/
# 管理端
cd MyMusic_Admin
pnpm install
pnpm run build
# 上传 dist/ 到 /var/www/vibe-music-admin/
```
服务器内存只有 2G,跑不起 Node.js 打包,所以必须本地打包后上传。
### 7.4 Nginx 配置
用户端和管理端各自独立的 server 块:
```nginx
# 用户端
server {
listen 443 ssl;
server_name hxakej.cn;
ssl_certificate /etc/nginx/ssl/hxakej.cn.pem;
ssl_certificate_key /etc/nginx/ssl/hxakej.cn.key;
# 静态文件
location / {
root /var/www/vibe-music-client;
index index.html;
try_files $uri $uri/ /index.html; # SPA 路由支持
}
# API 代理
location /api/ {
proxy_pass http://127.0.0.1:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# MinIO 代理
location /minio/ {
proxy_pass http://127.0.0.1:9005/;
}
}
# 管理端
server {
listen 443 ssl;
server_name admin.hxakej.cn;
ssl_certificate /etc/nginx/ssl/admin.hxakej.cn.pem;
ssl_certificate_key /etc/nginx/ssl/admin.hxakej.cn.key;
location / {
root /var/www/vibe-music-admin;
index index.html;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://127.0.0.1:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
`try_files` 这行很关键,SPA 应用刷新页面时 Nginx 会尝试找对应的文件,找不到就返回 index.html,交给前端路由处理。不加这行的话,刷新页面会 404。
## 八、踩坑记录
这部分是开发过程中遇到的实际问题,写出来希望能帮到遇到同样问题的人。
### 坑 1:MinIO endpoint 不能用公网地址
一开始想把 `application.yml` 里的 MinIO endpoint 改成公网地址 `https://hxakej.cn/minio`,这样后端返回给前端的文件 URL 就是公网可访问的。结果改成公网地址后,后端直接 502 启动失败。
原因:MinIO endpoint 是后端 Java 进程内部连接 MinIO 用的,改成 HTTPS 的公网地址后,Java 客户端走了一遍 Nginx 反代 + SSL,连接逻辑就乱了。MinIO SDK 直连 localhost:9005 就行。
解决方案:endpoint 保持 `http://localhost:9005` 不动,前端拿到 URL 后做字符串替换,把 `http://localhost:9005` 替换成 `https://hxakej.cn/minio`。
### 坑 2:前端头像 URL 替换
接上一个坑,后端返回的用户头像 URL 是 `http://localhost:9005/vibe-music-data/users/xxx.jpg`,前端直接用这个地址是访问不到的(localhost 指的是服务器的 localhost,不是用户的电脑)。
解决方案在前端的 Pinia store 里,用户信息存入时做 URL 替换:
```javascript
// 用户端 stores/modules/user.ts
function setUserInfo(data) {
if (data.userAvatar) {
data.userAvatar = data.userAvatar
.replace('http://localhost:9005', 'https://hxakej.cn/minio')
.replace('http://127.0.0.1:9005', 'https://hxakej.cn/minio');
}
userInfo.value = data;
}
```
同时数据库里存的历史数据也要批量替换:
```sql
UPDATE tb_user SET user_avatar = REPLACE(user_avatar, 'http://localhost:9005', 'https://hxakej.cn/minio') WHERE user_avatar LIKE '%localhost:9005%';
UPDATE tb_song SET cover_url = REPLACE(cover_url, 'http://localhost:9005', 'https://hxakej.cn/minio') WHERE cover_url LIKE '%localhost:9005%';
-- 其他表同理...
```
### 坑 3:打包后管理端白屏
管理端打包上传后,页面一直加载,打开浏览器控制台发现 JS 文件请求返回的是 HTML 而不是 JS。
排查过程:先怀疑 Nginx 配置问题,用 `curl` 直接请求 JS 文件,返回的 Content-Type 是 `text/html`。然后去服务器看文件结构,发现 `static/` 目录下面嵌套了一层 `static/static/`,外层的 js/css/png 目录是空的。
原因:构建时输出的目录结构不对,可能是打包配置的 base 路径和实际部署路径不一致,导致多套了一层。
解决:把内层 static 的内容移到外层,页面正常加载。
### 坑 4:Apache httpd 反复抢占 80 端口
服务器重启后好几次发现网站访问不了,排查发现是 Apache httpd 自动启动占用了 80 端口,Nginx 起不来。看起来是系统某个依赖装了 httpd 并且设了开机自启。
```bash
# 查看谁占了 80 端口
lsof -i :80
# 停掉 httpd 并禁用开机自启
systemctl stop httpd
systemctl disable httpd
# 启动 Nginx
systemctl start nginx
```
这个问题反复出现,每次服务器重启都要检查一下。最根本的解决方式是卸载 httpd:`apt remove apache2`。
### 坑 5:服务器带宽 1Mbps 音频加载慢
一首 3MB 的歌,在 1Mbps 带宽下理论上需要 24 秒才能下载完,实际体验就是点播放后要等很久才能开始放。
临时方案:前端 audio 标签加 `preload="none"`,不预加载音频,用户点了播放才请求。
长期方案:加 CDN 加速,或者升级带宽。对于学生项目来说成本太高,暂时没搞。
## 九、本地运行
如果想本地跑起来看看,需要装 JDK 17、Node.js 18+、pnpm。MySQL、Redis、MinIO 不用本地装,项目已经配好连接云服务器。
后端:
```bash
cd vibe-music-server
mvn clean install -DskipTests
mvn spring-boot:run
```
用户端(开发模式):
```bash
cd vibe-music-client
pnpm install
pnpm dev
```
管理端(开发模式):
```bash
cd MyMusic_Admin
pnpm install
pnpm dev
```
## 十、总结
从架构设计到部署上线,基本走了一遍完整的项目流程。几个觉得做得还不错的地方:
1. 前后端分离,后端只提供 API,前端独立开发和部署
2. MinIO 做对象存储,音频和图片统一管理,不占服务器磁盘
3. JWT + Redis 的认证方案,登出时删 Redis 让 token 立刻失效
4. 完整部署到阿里云,配了域名和 HTTPS
5. 鸿蒙端通过 ArkWeb 零成本适配
当然也有不少不足:
1. 服务器带宽太小,音频加载慢
2. 歌词只是纯文本显示,没有逐行高亮
3. Redis 和 MySQL 端口对公网开放了,安全上有隐患
4. 鸿蒙端只是 Web 壳,体验不如原生
5. 缺少播放量统计和排行榜
如果这篇文章对你有帮助的话,点个赞支持一下~
更多推荐




所有评论(0)