简介:基于SpringBoot的音乐播放网站是一份完整的Java Web项目源码,采用SpringBoot + MySQL实现,定位为课程设计或毕业设计参考,适合正在学习Spring Boot、MyBatis/JPA、前端开发的初学者。压缩包共940个文件,约43.29MB,包含28个Java源文件、107个XML配置(含Maven配置与MyBatis映射)、84个CSS、115个HTML、254个JS以及大量JPG/PNG图片素材,还有SQL数据库脚本,基本覆盖前端页面、后端逻辑、静态资源与数据表结构。项目采用经典的Bootstrap风格界面,包含用户登录、歌曲列表、播放控制、收藏管理等模块,可供直接导入IDE运行,也可作为二次开发的基础。已有170人学习浏览,适合用于快速了解企业级Spring Boot项目的目录结构与前后端交互方式。通过阅读源码可掌握Spring Boot自动配置、RESTful接口设计、模板渲染及MySQL表关系设计等关键技能。
1. 基于SpringBoot的音乐播放网站,卡点从来不在播放器
把一首 MP3 丢给<audio>标签,三分钟就能出声。真正让这类项目拖上两个月的,是音频流怎么发、热歌并发怎么扛、播放链接怎么防盗链。基于 SpringBoot 做音乐站,核心要解决的是:用 ResourceRegion 处理 HTTP Range 请求实现拖拽播放,用 Redis 承载播放量与榜单,用带时效的签名 URL 保护音频资源。这套方案对毕设、公司内部点歌台、独立小站都成立,不需要上重型中间件,一台 4C8G 的机器就能撑到日活几千的量级。下文按「选型 → 建表 → 音频流 → 对接 → 调优」展开,命令和代码都是可直接复用,迭代过几版的老项目也能挑对应章节补强。
2. SpringBoot音乐网站的选型与表结构设计
2.1 版本别踩坑:SpringBoot 3.x 与 JDK 的兼容组合
搜索「springboot版本太高」的人,多半是把 3.x 拉下来后发现全家桶报错。SpringBoot 3.0 起强制 JDK 17,javax.servlet也整体迁移到jakarta.servlet;还在 JDK 8 上跑的老代码直接启动失败。我的做法是:全新个人项目用 SpringBoot 3.2.x + JDK 17;毕设或要部署到客户老环境的退回 2.7.18,这是 2.x 最后一个维护版本。
数据访问层选 MyBatis-Plus。注意它从 3.5.4 起拆分了两套 starter,JDK 8 用mybatis-plus-boot-starter,SpringBoot 3 必须用mybatis-plus-spring-boot3-starter,用错会在启动时抛Invalid value type for attribute 'factoryBeanObjectType'之类的错误。版本组合关系如下表。
| 组合 | JDK | SpringBoot | MyBatis-Plus 依赖 |
|---|---|---|---|
| 老环境 | 8 / 11 | 2.7.18 | mybatis-plus-boot-starter(JDK8 版) |
| 新项目 | 17 / 21 | 3.2.x | mybatis-plus-spring-boot3-starter |
另外提一句:网上很多老教程还在教 parent 里配<version>2.4.0</version>,照抄这些旧配置去建 SpringBoot 3 项目,第一步就会死在依赖冲突上。IDEA 新建 SpringBoot 项目时直接从 start.spring.io 拉对应版本依赖,别手动改 parent 管理版本。
2.2 音频文件落盘:本地目录与 MinIO 的分界
音频文件单个几 MB 到几十 MB,不适合进数据库,也不能整段塞 Redis。常见做法是:单机部署直接落本地磁盘,多实例部署用 MinIO 对象存储,上云就挂 OSS 再套 CDN。核心原则是数据库只存相对路径,不存完整 URL,将来换存储只改配置和下载逻辑,不动表结构。
本地目录建议按 songId 分目录,避免单个目录文件数过多:
/data/music/ cover/10001.jpg audio/10001/1.mp3上传时用 UUID 重新命名文件,文件名里不保留用户原始输入,防止路径穿越和重名覆盖。对象存储的路径规则保持一致,后续迁移成本会低很多。
2.3 核心表结构与建表SQL
歌曲、用户、收藏三张表是底线。歌曲表是业务中心,先看它的 DDL:
CREATE TABLE `song` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '歌曲ID', `title` VARCHAR(128) NOT NULL COMMENT '歌曲名', `artist` VARCHAR(64) NOT NULL COMMENT '歌手', `album_id` BIGINT DEFAULT NULL COMMENT '专辑ID', `cover_path` VARCHAR(255) DEFAULT NULL COMMENT '封面相对路径', `audio_path` VARCHAR(255) NOT NULL COMMENT '音频相对路径', `mime_type` VARCHAR(64) DEFAULT 'audio/mpeg' COMMENT '音频MIME', `duration` INT DEFAULT 0 COMMENT '时长(秒)', `play_count` BIGINT DEFAULT 0 COMMENT '播放量冗余字段', `status` TINYINT DEFAULT 1 COMMENT '1上架 0下架', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), KEY `idx_artist` (`artist`), KEY `idx_play_count` (`play_count`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='歌曲表';play_count是冗余列,最终由 Redis 异步累加后回写,榜单查询直接ORDER BY play_count DESC LIMIT 50,不需要 JOIN。audio_path只存相对路径,播放接口再拼配置里的 baseDir。用户表存账号密码和昵称,收藏表建唯一索引(user_id, song_id)防重复收藏即可,结构常规不展开。
2.4 用@ConfigurationProperties集中管理上传与播放参数
配置文件里把路径、大小限制、签名密钥集中起来,这是 springboot 配置里最值得规范化的地方:
music: storage: base-dir: /data/music max-size-mb: 30 player: sign-expire-seconds: 600 secret-key: change-me-in-prod对应配置类:
@Component @ConfigurationProperties(prefix = "music") public class MusicProperties { private Storage storage = new Storage(); private Player player = new Player(); // getter / setter 省略,IDEA 一键生成即可 public static class Storage { private String baseDir; private int maxSizeMb; // getter / setter } public static class Player { private long signExpireSeconds; private String secretKey; // getter / setter } }@ConfigurationProperties(prefix = "music")会把music.*下的配置按字段名绑定到这个类。相比散落的@Value,它把上传、播放、签名三处参数聚成一个对象,改配置不用翻代码。这也是面试常问的「自动装配」在实际项目里的触点:配置属性通过@EnableConfigurationProperties注册进容器,再被各组件引用。SpringBoot 3 里可以改 record 风格,但构造绑定要配合@EnableConfigurationProperties,老项目建议保持 getter/setter 写法。
3. SpringBoot实现音频流式播放:Range请求与断点续传
3.1 拖进度条的本质是HTTP Range请求
浏览器里的<audio>请求音频时默认带Range头。首次加载只发Range: bytes=0-,服务器按媒体协议返回 206,浏览器拿到头部元数据后就能算出总时长;用户拖进度条时,浏览器再发Range: bytes=起始-,服务器从目标位置继续返回,而不是重新下载整个文件。
反过来,如果后端无视 Range,每次都返回 200 加完整文件,用户每拖一次进度条浏览器就重新下载一次 MP3,流量翻几倍,播放还会频繁卡顿。把 206 的语义讲清楚,本身就是 springboot 面试题里 HTTP 状态码的高频考点。所以播放接口的正确姿势是:支持 Range、返回 206、带上Content-Range。
3.2 用ResourceRegion返回206 Partial Content
Spring Framework 提供了ResourceRegion,配合Resource可以少写很多 Range 解析逻辑。先看 Controller 主体:
@RestController @RequestMapping("/api/audio") public class AudioController { private final SongService songService; private final MusicProperties musicProperties; public AudioController(SongService songService, MusicProperties musicProperties) { this.songService = songService; this.musicProperties = musicProperties; } @GetMapping("/{songId}") public ResponseEntity<ResourceRegion> stream(@PathVariable Long songId, @RequestHeader HttpHeaders headers) throws IOException { Song song = songService.getById(songId); if (song == null || song.getStatus() != 1) { return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); } Resource resource = new FileSystemResource( musicProperties.getStorage().getBaseDir() + song.getAudioPath()); long contentLength = resource.contentLength(); ResourceRegion region = this.resourceRegion(resource, headers, contentLength); if (region == null) { // 起始位置超出文件长度,返回 416 并告知真实大小 return ResponseEntity.status(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE) .header("Content-Range", "bytes */" + contentLength) .body(null); } return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT) .contentType(MediaType.parseMediaType(song.getMimeType())) .contentLength(region.getCount()) .header("Accept-Ranges", "bytes") .header("Content-Range", buildContentRange(region, contentLength)) .body(region); } private ResourceRegion resourceRegion(Resource resource, HttpHeaders headers, long contentLength) { List<HttpRange> ranges = headers.getRange(); if (ranges.isEmpty()) { // 没有 Range 头,返回整个文件,由浏览器决定怎么用 return new ResourceRegion(resource, 0, contentLength); } // 音频场景只取第一个区间,多段 Range 没有实际意义 HttpRange range = ranges.get(0); long start = range.getRangeStart(contentLength); long end = range.getRangeEnd(contentLength); if (start >= contentLength) { return null; } return new ResourceRegion(resource, start, end - start + 1); } private String buildContentRange(ResourceRegion region, long total) { long end = region.getPosition() + region.getCount() - 1; return "bytes " + region.getPosition() + "-" + end + "/" + total; } }headers.getRange()把Range: bytes=1024-2048解析成HttpRange列表;getRangeStart(contentLength)和getRangeEnd(contentLength)由 Spring 处理开放结尾和bytes=-500这类后缀写法。状态码固定PARTIAL_CONTENT(206),Content-Range格式必须按bytes 起始-结束/总长度拼,结束位是包含的,所以长度要end - start + 1。
提示:
Content-Range的结束位写错会导致播放器分片错位,表现是能出声但拖拽后时间轴乱跳,排查时先核对响应头。
这里没有直接用range.toResourceRegion(),而是自己解析,目的是在越界时能返回带bytes */total的 416,让播放器知道文件的真实长度。
3.3 流式响应的两个细节:缓冲与MIME
206 响应下发时,不要先读整个文件到 byte[],FileSystemResource本身就是懒读取,Spring MVC 底层会流式写响应体,内存占用只和分片大小有关,和文件总大小无关。
Content-Type 有一个经典错误:很多代码统一回application/octet-stream,部分浏览器会直接触发下载而不是播放。上传时把真实 MIME 存进 song 表,播放接口从库里读出再设置。常见映射如下。
| 扩展名 | MIME 类型 |
|---|---|
| .mp3 | audio/mpeg |
| .flac | audio/flac |
| .ogg | audio/ogg |
| .wav | audio/wav |
4. SpringBoot音乐网站的前端播放器对接:签名URL与跨域调试
4.1 audio标签带不了Header,后端出签名URL
前端<audio>的src是浏览器直接发起的 GET 请求,没法像 fetch 那样手动加Authorization头。如果音频接口严格校验 JWT,登录后照样 401。常见做法有两个:一是把 token 放 URL 查询参数,二是后端发放短期有效的签名 URL。
查询参数方案最省事,但 token 会出现在 Nginx 访问日志、浏览器历史、CDN 回源日志里,泄露面太大。我一般用签名 URL:播放接口本身不校验 JWT,而是校验 URL 上的expire和sign,签名过期后即使链接被转发也播不了。这也是音乐网站防盗链的常见做法,能挡住爬虫批量收割音频文件。
@GetMapping("/signed-url/{songId}") public Map<String, String> signedUrl(@PathVariable Long songId, @RequestHeader("Authorization") String token) { // 正常业务里这里先解析 JWT 校验登录态,代码略去 long expire = System.currentTimeMillis() / 1000 + musicProperties.getPlayer().getSignExpireSeconds(); String raw = songId + ":" + expire + ":" + musicProperties.getPlayer().getSecretKey(); String sign = DigestUtils.md5DigestAsHex(raw.getBytes(StandardCharsets.UTF_8)); String url = "/api/audio/" + songId + "?expire=" + expire + "&sign=" + sign; return Map.of("url", url, "expire", String.valueOf(expire)); }对应地,第 3 章的 stream 接口在返回 206 前先校验签名:expire大于当前时间,且md5(songId + ":" + expire + ":" + secretKey)与sign一致。secretKey放配置里,不要提交进 Git。签名原串只拼 songId、expire 和密钥,拼入文件名会导致文件重命名后所有历史链接失效。签名算法用 MD5 做演示够用,正式环境建议换成 HMAC-SHA256。
4.2 Vue端加载音频与错误处理
前后端分离部署时,页面在 5173,接口在 8080,要先配好 CORS 或用 Vite 代理转发。拿到签名 URL 后直接赋给<audio>的 src:
<audio :src="audioUrl" controls preload="metadata" @error="onAudioError"></audio>async function loadAudio(songId) { const resp = await fetch('/api/signed-url/' + songId, { headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') } }); if (!resp.ok) { // 401 登录失效,403 无权播放,分别跳转登录和提示 return; } const data = await resp.json(); audioUrl.value = data.url; } function onAudioError() { // audio 的 error 事件信息有限,去 DevTools 网络面板看音频请求状态码 console.error('audio load failed'); }preload="metadata"让浏览器只拉文件头部的元数据,不预下载整个 MP3,列表页几十首歌能省大量流量。audio的 error 事件给的信息很少,排查时优先看网络面板里音频请求的状态码:401/403 是鉴权问题,404 是路径拼错,416 是拖拽位置超出文件长度,200 则多半是 MIME 不对导致播放器拒绝解析。
4.3 播放请求挂起的排查方向
音频请求一直 pending 时,第一反应不是改后端代码,而是看 Nginx 的proxy_buffering。默认开启缓冲时,Nginx 会等上游攒够数据再转发,对流式分片响应造成明显延迟。在 location 里关掉:
location /api/audio/ { proxy_buffering off; proxy_cache off; add_header Cache-Control "no-store"; }proxy_buffering off让分片即时透传,拖进度条的体感差异非常明显。如果还挂了 CDN,要确认 CDN 是否透传Range和Content-Range,部分 CDN 默认不缓存 206,但会缓存完整文件导致断点失效,域名配置里要专门留意。
5. SpringBoot播放网站的播放量异步统计与上线前必调参数
5.1 Redis递增播放量,定时任务批量落库
直接把play_count的 UPDATE 写在播放接口里,流量上来后是锁竞争和磁盘写放大。常见做法是 Redis 计数,定时任务批量回写 MySQL,计数和榜单一并放 Redis:
// 播放接口里只做两件事:返回 206、递增计数 stringRedisTemplate.opsForValue().increment("song:play:" + songId, 1); stringRedisTemplate.opsForZSet().incrementScore("song:rank", String.valueOf(songId), 1);@Component public class PlayCountFlushTask { @Scheduled(cron = "0 */5 * * * ?") public void flush() { // 每 5 分钟把增量累加到 song 表,生产环境请用 SCAN 替换 keys Set<String> keys = stringRedisTemplate.keys("song:play:*"); for (String key : keys) { Long songId = Long.parseLong(key.substring("song:play:".length())); String deltaStr = stringRedisTemplate.opsForValue().getAndDelete(key); if (deltaStr != null) { long delta = Long.parseLong(deltaStr); if (delta > 0) { songService.incrPlayCount(songId, delta); } } } } }@Scheduled是 SpringBoot 内置定时任务注解,启动类加@EnableScheduling才生效。getAndDelete是 Spring Data Redis 2.6 起提供的原子操作,低版本要自己get后delete。这个方案最多丢 5 分钟的增量,对播放量这种非强一致指标完全可接受。
注意:
keys命令在 key 规模大时会阻塞 Redis 主线程,生产环境改成SCAN游标遍历,别照抄示例。
5.2 三个必调参数与一条验证命令
上线前把这几项检查掉,能少挨一半告警:
| 参数 | 默认值 | 建议值 | 为什么改 |
|---|---|---|---|
| spring.servlet.multipart.max-file-size | 1MB | 30MB | MP3 普遍超 1MB,不改上传必 500 |
| spring.servlet.multipart.max-request-size | 1MB | 35MB | 必须大于单个文件,否则请求被截断 |
| server.tomcat.threads.max | 200 | 400 | 播放接口是 IO 密集型,适当提线程数 |
前两个管上传接口:max-request-size校验整个 multipart 请求体,它必须不小于max-file-size,否则文件没超限但请求超限一样报错。线程数在 4C8G 机器提到 400 不会让 CPU 打满,因为音频流式返回时线程大多在等 IO,但别盲目上千,连接数要配合同步压测验证。
收工前跑这条命令:
curl -i -H "Range: bytes=0-1023" "http://localhost:8080/api/audio/10001?expire=1700000000&sign=xxxx"期望看到HTTP/1.1 206、Accept-Ranges: bytes和Content-Range: bytes 0-1023/4823911这类响应头;再把 Range 改成bytes=2048-请求一次,拿到第二个 206 说明断点续传链路完整。如果返回 200 且响应体是完整文件,检查请求是不是落到了静态资源映射而不是这个 Controller。
本文还有配套的精品资源,点击获取