尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

SpringBoot音乐网站实战:流式播放、断点续传与防盗链设计

SpringBoot音乐网站实战:流式播放、断点续传与防盗链设计 简介基于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 17javax.servlet也整体迁移到jakarta.servlet还在 JDK 8 上跑的老代码直接启动失败。我的做法是全新个人项目用 SpringBoot 3.2.x JDK 17毕设或要部署到客户老环境的退回 2.7.18这是 2.x 最后一个维护版本。数据访问层选 MyBatis-Plus。注意它从 3.5.4 起拆分了两套 starterJDK 8 用mybatis-plus-boot-starterSpringBoot 3 必须用mybatis-plus-spring-boot3-starter用错会在启动时抛Invalid value type for attribute factoryBeanObjectType之类的错误。版本组合关系如下表。组合JDKSpringBootMyBatis-Plus 依赖老环境8 / 112.7.18mybatis-plus-boot-starterJDK8 版新项目17 / 213.2.xmybatis-plus-spring-boot3-starter另外提一句网上很多老教程还在教 parent 里配version2.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歌曲、用户、收藏三张表是底线。歌曲表是业务中心先看它的 DDLCREATE 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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: bytes0-服务器按媒体协议返回 206浏览器拿到头部元数据后就能算出总时长用户拖进度条时浏览器再发Range: bytes起始-服务器从目标位置继续返回而不是重新下载整个文件。反过来如果后端无视 Range每次都返回 200 加完整文件用户每拖一次进度条浏览器就重新下载一次 MP3流量翻几倍播放还会频繁卡顿。把 206 的语义讲清楚本身就是 springboot 面试题里 HTTP 状态码的高频考点。所以播放接口的正确姿势是支持 Range、返回 206、带上Content-Range。3.2 用ResourceRegion返回206 Partial ContentSpring 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 ResponseEntityResourceRegion 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) { ListHttpRange 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: bytes1024-2048解析成HttpRange列表getRangeStart(contentLength)和getRangeEnd(contentLength)由 Spring 处理开放结尾和bytes-500这类后缀写法。状态码固定PARTIAL_CONTENT206Content-Range格式必须按bytes 起始-结束/总长度拼结束位是包含的所以长度要end - start 1。提示Content-Range的结束位写错会导致播放器分片错位表现是能出声但拖拽后时间轴乱跳排查时先核对响应头。这里没有直接用range.toResourceRegion()而是自己解析目的是在越界时能返回带bytes */total的 416让播放器知道文件的真实长度。3.3 流式响应的两个细节缓冲与MIME206 响应下发时不要先读整个文件到 byte[]FileSystemResource本身就是懒读取Spring MVC 底层会流式写响应体内存占用只和分片大小有关和文件总大小无关。Content-Type 有一个经典错误很多代码统一回application/octet-stream部分浏览器会直接触发下载而不是播放。上传时把真实 MIME 存进 song 表播放接口从库里读出再设置。常见映射如下。扩展名MIME 类型.mp3audio/mpeg.flacaudio/flac.oggaudio/ogg.wavaudio/wav4. 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 MapString, 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的 srcaudio :srcaudioUrl controls preloadmetadata erroronAudioError/audioasync 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); }preloadmetadata让浏览器只拉文件头部的元数据不预下载整个 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 SetString 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-size1MB30MBMP3 普遍超 1MB不改上传必 500spring.servlet.multipart.max-request-size1MB35MB必须大于单个文件否则请求被截断server.tomcat.threads.max200400播放接口是 IO 密集型适当提线程数前两个管上传接口max-request-size校验整个 multipart 请求体它必须不小于max-file-size否则文件没超限但请求超限一样报错。线程数在 4C8G 机器提到 400 不会让 CPU 打满因为音频流式返回时线程大多在等 IO但别盲目上千连接数要配合同步压测验证。收工前跑这条命令curl -i -H Range: bytes0-1023 http://localhost:8080/api/audio/10001?expire1700000000signxxxx期望看到HTTP/1.1 206、Accept-Ranges: bytes和Content-Range: bytes 0-1023/4823911这类响应头再把 Range 改成bytes2048-请求一次拿到第二个 206 说明断点续传链路完整。如果返回 200 且响应体是完整文件检查请求是不是落到了静态资源映射而不是这个 Controller。本文还有配套的精品资源点击获取
返回列表