Spring Boot + Vue 3 音乐网站管理系统:从数据库到缓存优化
2026/9/16 22:55:14 网站建设 项目流程

简介:这是一份基于SpringBoot与Vue实现的在线音乐网站管理系统,采用前后端分离架构,适合作为Java毕业设计或全栈开发学习项目。系统涵盖音乐播放、用户登录注册、用户信息与头像修改、歌曲和歌单搜索、歌单打分、歌单与歌曲评论、列表分页、歌词同步显示、音乐收藏下载、拖动与音量控制,以及后台对用户、歌曲、歌手、歌单信息的管理,功能体系完整。技术栈主要包括SpringBoot、SSM、MyBatis-Plus、MySQL5.7以上、Vue-Router、Vuex、Axios、ElementUI,开发工具支持IDEA和Eclipse,依赖管理使用Maven。整个RAR压缩包共427个文件,大小8.62MB,其中61个Java文件对应后端业务逻辑,41个Vue组件实现前端界面,108个XML文件涵盖MyBatis映射和Maven配置,另附SQL初始化脚本、JS交互脚本、SCSS样式、图片资源及Maven包装器,目录结构清晰,便于按模块检索,目前已有1792人学习下载。代码风格规范、分层明确,从登录鉴权到评论打分形成完整闭环,尤其适合借鉴前后端数据交互方式,以及歌单评分、歌词同步等典型功能的实现思路;后台管理模块完整覆盖用户、歌曲、歌手、歌单等数据维护,适合在课程设计或毕设场景下直接进行二次开发和功能扩展。

1. 基于这套四件套做音乐站,先把架构立住

做音乐网站管理系统,听上去最抢眼的是前端播放器,但真正决定项目能走多远的,是后端那套数据层能不能顶住歌单、专辑、歌手、评论、播放量这些错综复杂的关系。常见做法是拿 Spring Boot 做接口层,MyBatis-Plus 管数据访问,MySQL 8.0 存数据,Vue 3 做管理后台和前台页面,这套组合在中小型项目里几乎成了默认选型,原因很简单:Spring Boot 把繁琐的配置收敛了,MyBatis-Plus 把单表 CRUD 的样板代码砍掉大半,Vue 的组件化又让播放列表、歌单管理这类交互不至于写成一团乱麻。选这套方案的人通常要的是三天内能跑通前后端联调,而不是花两周去手写 Mapper XML。这套组合能解决的问题集中在快速交付、二次开发和后台管理,适合刚起步的在线音乐项目,也适合做毕设或企业内部点播系统的团队作为底座。下面直接从数据库设计开始,把每层怎么落地说清楚。

2. MySQL 表结构设计:把歌、人、单、评论先拆明白

2.1 音乐业务的核心实体关系

音乐网站管理系统在数据层面绕不开四个实体:用户、歌手、歌曲、歌单。歌曲需要关联歌手,歌单需要关联歌曲,用户需要收藏歌单和评论歌曲,这就是典型的多对多和一对多混合模型。在设计表结构之前,先确定一个原则:不在数据库里建物理外键,外键约束交给应用层去保证。原因是 MyBatis-Plus 的批量操作和分页查询在物理外键存在时容易出现死锁和额外开销,而且后续做分库分表时物理外键会成为迁移的障碍。逻辑外键只要保证字段命名规范和索引到位,一样能维持数据一致性。

用户表不需要太复杂,id、用户名、密码(加密存储)、昵称、头像、角色、创建时间这几个字段就够了。歌手表要区分歌手名、别名、封面图、简介、地区、类型。歌曲表是核心,字段要包含歌曲名、歌手 id、专辑 id、封面、音频文件 URL、歌词、时长、播放量、发布日期。歌单表包含歌单名、创建者 id、封面、描述、播放量。歌单和歌曲的关联表单独建一张中间表,这样歌单内歌曲排序、添加时间都能记录。评论表挂到歌曲或歌单上,用 target_type 和 target_id 区分评论对象,比建两张评论表更省事。

2.1.1 建表 SQL 与关键字段的取舍
CREATE TABLE `song` ( `id` bigint NOT NULL AUTO_INCREMENT, `song_name` varchar(128) NOT NULL COMMENT '歌曲名', `singer_id` bigint NOT NULL COMMENT '歌手id', `album_id` bigint DEFAULT NULL COMMENT '专辑id', `cover_url` varchar(255) DEFAULT NULL COMMENT '封面地址', `play_url` varchar(255) NOT NULL COMMENT '音频地址', `lyric` text COMMENT '歌词文本或LRC地址', `duration` int DEFAULT 0 COMMENT '时长,单位秒', `play_count` bigint DEFAULT 0 COMMENT '播放量', `publish_date` date DEFAULT NULL COMMENT '发布日期', `status` tinyint DEFAULT 1 COMMENT '1上架 0下架', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_singer_id` (`singer_id`), KEY `idx_album_id` (`album_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='歌曲表';

这个建表语句里两个细节值得注意:第一个是duration用 int 存秒,不用 varchar 存“03:25”这种格式,前端拿到秒数自己格式化,避免后端做字符串解析。第二个是play_url只存相对路径或者对象存储的 key,不存完整的 http 地址,这样以后迁移存储服务不用改数据库。status字段做歌曲上下架,逻辑删除交给 MyBatis-Plus 处理,物理删歌曲会破坏歌单关联,很少用。

关联表的设计上,歌单-歌曲中间表建议把 id 设成自增主键,不要用 (playlist_id, song_id) 做联合主键,因为 MyBatis-Plus 的 BaseMapper 默认按主键操作,联合主键在通用 Service 里要额外写自定义 SQL,不划算。加一个sort_order字段记录歌曲在歌单里的顺序,拖拽排序时只需要更新这个值。

2.2 逻辑删除和自动填充的配置

MySQL 表里的deleted字段不是必须的,但做了逻辑删除后 MyBatis-Plus 会默认在查询时加WHERE deleted=0条件。逻辑删除适合用户表、评论表这种需要审计数据的场景,歌曲表不建议加,因为歌曲下架用 status 字段控制更直观。配置逻辑删除很简单,在实体类的 deleted 字段上加@TableLogic注解,然后在 application.yml 里声明全局逻辑值。自动填充则是让 create_time、update_time 不用每次手动 set,实现 MetaObjectHandler 接口就能统一处理。

实体类的写法会影响后续所有 Service 层的代码量。比如 song 实体里 singerId 字段用@TableField("singer_id")映射下划线命名,MyBatis-Plus 默认开启驼峰映射,只要数据库字段是下划线风格,这个注解其实可以省略。真正推荐用的是@TableName注解把实体类和表名绑定,防止类名和表名不一致时启动报错。

3. Spring Boot 集成 MyBatis-Plus:从通用 Mapper 到分页插件

3.1 依赖引入和核心配置

Spring Boot 集成 MyBatis-Plus 比集成原生 MyBatis 少很多步骤,不需要自己写 SqlSessionFactory 的配置类,引入mybatis-plus-boot-starter依赖后,框架自动装配数据源和 SqlSessionFactory。需要注意版本匹配:Spring Boot 2.x 用 MyBatis-Plus 3.5.x,Spring Boot 3.x 用mybatis-plus-spring-boot3-starter,版本对应错了会出现 Bean 创建异常。热词里搜“mybatis-plus深度解析”的人,多半是碰到条件构造器不会用或者分页插件失效,这两个点下面单独展开。

spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/music_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: yourpassword mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 mapper-locations: classpath*:mapper/**/*.xml

配置里map-underscore-to-camel-case即使不写,MyBatis-Plus 默认也是 true,写出来是显式声明。mapper-locations必须写,虽然只写注解和 BaseMapper 不写 XML 也能跑,但一旦 Later 需要手写多表联查,没配这个路径会直接报 Invalid bound statement。log-impl建议开发环境打开,控制台能直接看到自动生成的 SQL,排查关联查询字段不匹配时非常有用。

3.2 BaseMapper 与条件构造器的正确用法

Service 层继承IService<T>,实现类继承ServiceImpl<M, T>,就能直接获得list()getById()page()这些方法,常规增删改查一行 SQL 都不用写。这层封装的意义在于把单表操作的重复代码消掉,但代价是遇到多表查询时,条件构造器容易写过度。一个典型的错误是查询歌曲列表带着歌手名,有人直接QueryWrapper.in("singer_id", singerIdList)查两次,这种做法不推荐,应该多表联查用自定义 SQL。

public IPage<SongVO> getSongPage(int current, int size, String keyword) { Page<SongVO> page = new Page<>(current, size); QueryWrapper<Song> wrapper = new QueryWrapper<>(); wrapper.like(StringUtils.hasText(keyword), "song_name", keyword) .eq("status", 1) .orderByDesc("play_count"); return songMapper.selectSongPage(page, wrapper); }

QueryWrapper 的like方法第一个参数是 boolean 类型,条件成立才拼这个 SQL 片段,这样 keyword 为空时不会生成无效的LIKE '%%',避免全表扫描。eq后面还能继续链式调用orderByDesc,这是 MyBatis-Plus 条件构造器维护成本最低的写法。selectSongPage 是写在 SongMapper 接口里的自定义分页方法,XML 里写两个查询,一个查列表一个查总数,MyBatis-Plus 的分页插件会自动把 count 查询和 limit 拼上。

3.2.1 分页插件失效的排查顺序

分页插件不生效是搜索量极大的问题,现象是page.getRecords()返回全部数据而不是当前页。第一个要查的是MybatisPlusInterceptor这个 Bean 有没有注册,Spring Boot 3 环境下注册方式不同。第二个是 paginationInnerInterceptor 的 DbType 必须是 mysql,写成 other 会导致 count 查询生成错误。第三个是分页查询返回类型用了 List 而不是 IPage,分页插件只对 IPage 参数生效。第四个是多表联查时 SQL 里有group by,分页插件生成的 count 语句会忽略分组条件,导致总数不准确。

@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(100L); pagination.setOverflow(false); interceptor.addInnerInterceptor(pagination); return interceptor; } }

setMaxLimit(100L)是保护机制,防止有人传size=10000一次把整张表拉走,后端接口暴露出分页参数时这个限制必须有。setOverflow(false)表示页码超出总页数时返回空页,而不是自动跳到最后一页,避免列表页出现诡异的跳页。分页插件还有一个隐藏值:它默认优化 count 语句,把 join 和 order by 去掉,如果 XML 里的查询带聚合函数,count 正确性需要人工验证。

4. 业务层与鉴权:登录、上传和接口防刷

4.1 JWT 登录方案与拦截器的取舍

音乐网站管理系统需要区分普通用户和管理员,但没必要直接上 Spring Security,它的过滤器链对这种轻量级接口项目过于笨重。常见做法是登录接口签发 JWT,前端把 token 存到 localStorage,Axios 请求拦截器在 Header 里带Authorization: Bearer <token>,后端写一个 HandlerInterceptor 校验 token 并把用户信息放进 ThreadLocal。Spring Boot 里注册拦截器要继承 WebMvcConfigurer,把放行的路径和拦截路径明确分开:登录、注册、歌曲列表、播放接口都不需要 token,而歌单创建、收藏、评论、后台管理接口必须过拦截器。

public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (!StringUtils.hasText(token) || !token.startsWith("Bearer ")) { response.setStatus(401); return false; } try { Long userId = JwtUtil.parseToken(token.substring(7)); UserContext.set(userId); return true; } catch (Exception e) { response.setStatus(401); return false; } } }

拦截器里第一个判断 OPTIONS 请求直接放行,是前后端分离项目最容易踩的坑。浏览器发跨域请求时会先发一个 OPTIONS 预检,如果拦截器把这个也拦截了,前端会看到 CORS 错误,而实际上后端接口没报错。JWT 解析失败要返回 401 而不是 500,状态码语义要准确,前端才能根据 401 跳登录页。用 ThreadLocal 存用户信息要注意用完后清理,否则线程池复用时会读到上一个请求的用户,内存泄漏排查起来很隐蔽。

4.1.1 角色权限用注解还是用拦截器判断

管理员接口和普通用户接口混在同一个 Controller 里时,可以用自定义注解@RequireRole("admin")加在方法上,拦截器里通过 HandlerMethod 拿到注解再做判断。这种方案比在拦截器里写死在路径上灵活,因为同一个路径可能是POST /api/song添加歌曲,也可能是DELETE /api/song删除,按路径判断容易误伤。角色存到 JWT 的 claims 里,解析时一起取出来,不用每次查数据库,性能开销更小。JWT 过期时间建议设 24 小时,音乐系统用户登录频次低,设太短体验差,设太长不安全。

4.2 音频和封面文件上传的本地存储方案

文件上传是音乐系统的必选项,歌曲 MP3 文件可能十几 MB,封面图片几百 KB,上传接口需要限制文件大小和类型。常见做法是先存本地磁盘,用 UUID 重命名文件,再把相对路径存数据库,后续如果迁移到 OSS 或者 MinIO,只需要改存储策略这层代码。Spring Boot 的 MultipartFile 接口直接接收文件,但要注意默认的单文件大小限制只有 1MB,必须在配置文件里调大 spring.servlet.multipart.max-file-size 和 max-request-size。

public String uploadFile(MultipartFile file, String dir) { if (file.isEmpty()) { throw new BusinessException("文件不能为空"); } String originalFilename = file.getOriginalFilename(); String ext = originalFilename.substring(originalFilename.lastIndexOf(".")); String allowedExt = ".mp3,.flac,.wav,.jpg,.png,.webp"; if (!allowedExt.contains(ext.toLowerCase())) { throw new BusinessException("不支持的文件类型"); } String fileName = UUID.randomUUID() + ext; String fullPath = uploadDir + "/" + dir + "/" + fileName; File dest = new File(fullPath); if (!dest.getParentFile().exists()) { dest.getParentFile().mkdirs(); } file.transferTo(dest); return "/files/" + dir + "/" + fileName; }

文件后缀白名单比黑名单安全,黑名单永远列不全。transferTo方法在文件较大时要确保磁盘剩余空间充足,否则会抛 IOException。返回的路径要能通过静态资源映射直接访问,Spring Boot 里配置一下 WebMvcConfigurer 的 addResourceHandlers,把/files/**映射到本地磁盘目录。音频文件的播放 URL 不要写在数据库里,前端根据文件路径拼接完整地址,这样换域名或者加 CDN 时只改前端配置。

5. Vue 3 前台与后台联调:从列表渲染到播放器状态管理

5.1 Vite 代理解决跨域和接口统一前缀

Vue 3 项目用 Vite 启动时,默认端口是 5173,后端接口是 8080,直接请求必然跨域。开发环境的常见做法是在 vite.config.js 里配 proxy,把/api前缀的请求代理到后端,这样浏览器看到的是同源请求,后端也不需要额外配 CORS。生产环境则让 Nginx 做反向代理,前后端部署在同一域名下,这个代理配置在开发阶段就能提前把接口前缀规范定下来。

export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })

changeOrigin: true必须开,不然后端拿到的 Host 还是前端地址,有些框架的判断逻辑会出问题。接口请求统一封装在 src/api 目录下,每个模块对应一个文件。Axios 拦截器里带上 token,响应拦截器里统一处理 401 跳转和业务错误码提示,这样每个页面不需要重复写 try-catch。MyBatis-Plus 分页返回的结构是{ records, total, size, current },前端封装的分页组件直接对接这个结构,不需要后端再做一层包装,减少前后端沟通成本。

5.2 播放器组件和播放量统计的设计

音乐前台页面最重要的组件是播放器,放在全局还是局部要看需求。普通音乐列表页的歌曲预览用<audio>标签就够了,但如果有歌单连续播放需求,就要做一个独立的播放器组件,用 Vuex 或 Pinia 管理播放队列和当前播放索引。Vue 播放 m3u8 的搜索量很高,但那是视频源的场景,音频文件一般走 mp3 或 flac,浏览器原生 audio 标签直接支持。真正要处理的是 playlist 循环模式,切歌时要把播放状态同步到全局 store,组件销毁时不能打断播放。

// store/player.js export const usePlayerStore = defineStore('player', { state: () => ({ playList: [], currentIndex: 0, isPlaying: false }), getters: { currentSong: (state) => state.playList[state.currentIndex] }, actions: { playSong(song) { this.playList = [song] this.currentIndex = 0 this.isPlaying = true }, nextSong() { this.currentIndex++ } } })

播放量统计的接口要做防刷,不用每次播放完都调接口,常见做法是播放 30 秒后才上报一次播放量,后端统计接口加上 IP 和用户维度的限流。MySQL 更新播放量用UPDATE song SET play_count = play_count + 1 WHERE id = ?,这是原子操作,先查再 set 会丢更新。管理后台的播放量排行就是 orderByDesc play_count,配上分页就是现成的排行榜接口。播放器组件里续播功能可以用 localStorage 存 currentSongId 和 currentTime,刷新页面后恢复进度,这个交互是提高用户留存的低成本方案。

5.3 管理后台的表格和表单构建

后台管理页面用 Vue 加 Element Plus 是最省力的组合,el-table 绑定 MyBatis-Plus 的 records,el-pagination 绑定 total 和 current。歌曲管理表单里的歌手选择用远程搜索的 el-select,输入关键字调歌手搜索接口,而不是一次性把全表歌手拉下来。表单校验注意上传文件组件的值类型,el-upload 的 fileList 是数组,提交前要取出 response 里的路径再传给后端。编辑和新增共用同一个弹窗表单,用 ref 调 resetFields 重置数据,编辑时把行数据深拷贝到表单对象,避免直接修改表格数据。

6. 慢查询定位与缓存方案:把接口响应压到百毫秒内

系统跑起来之后,第一个瓶颈通常出现在歌曲列表接口,因为它是全站最高频的查询。定位慢查询的最快方式不是加索引,而是先开启 MySQL 慢查询日志,看真实请求打到了哪条 SQL。MySQL 8.0 里用一条 SQL 就能动态开启,不用改配置文件重启数据库,临时排查完关掉就行。

SET GLOBAL slow_query_log = ON; SET GLOBAL long_query_time = 1; SET GLOBAL slow_query_log_file = '/var/log/mysql/mysql-slow.log';

执行完之后,用mysqldumpslow -s t /var/log/mysql/mysql-slow.log按耗时排序看哪几条 SQL 最慢。常见的问题是分页深翻页,LIMIT 100000, 10会扫描前十万条数据再丢弃,优化手段是改成子查询或者记录上一次查询的最大 id,WHERE id > ? ORDER BY id LIMIT 10。MyBatis-Plus 的分页参数如果允许前端传 current,深翻页风险就存在,管理后台列表一般限制只能看前 100 页就能规避。索引不是建得越多越好,覆盖索引的代价是写入变慢,歌曲表的idx_singer_ididx_album_id属于高频查询,值得建,但play_count这种更新频繁的字段不建议建索引。

热点数据用 Redis 缓存是第二层优化手段,歌曲详情和歌单详情是典型的读多写少场景,缓存 key 按song:detail:{id}设计,更新时主动删除缓存而不是等过期,避免脏数据。注意缓存穿透问题,如果查询一个不存在的歌曲 id,Redis 里始终没有,请求直接打到数据库。恶意攻击者对不存在的 id 做遍历查询,就会打垮数据库。常用解决方案是缓存空值,设置短过期时间比如 60 秒,或者用布隆过滤器做前置判断。一个接口里的多个缓存操作,用 Spring 的@Cacheable注解最省事,但注解没法设置每种 key 的过期时间,需要自定义 RedisCacheManager 配置,把每个缓存名的 TTL 分离。最后验收时用 ab 或者 wrk 压一下接口,对比加缓存前后的 QPS 和平均响应时间,把压测数据留档。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询