免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

Spring Boot构建音乐播放网站:元数据、Range流式播放与鉴权实践

Spring Boot构建音乐播放网站:元数据、Range流式播放与鉴权实践 简介一份基于Spring Boot框架的音乐播放网站项目源码主要面向正在做Java课程设计、毕业设计的学生以及希望提升后端开发能力的初级工程师。项目围绕在线音乐播放场景实现了用户注册登录、歌曲分类浏览、播放列表维护、播放历史记录等常见业务使用关系型数据库MySQL保存用户、歌曲和播放数据后端通过对象关系映射简化数据库操作同时提供基于令牌的权限校验与接口访问控制。整个项目采用分层架构控制器、服务层、数据访问层职责清晰并配有前端页面与样式资源便于读者对照学习理解一个实际网站从需求分析、数据库建表、后端接口开发到页面联调的全过程。整个压缩包共包含940个文件大小约43.29MB文件类型涵盖前端脚本、页面结构、样式表、后端Java源码、XML配置、SQL脚本以及图片素材等基本覆盖项目运行与二次开发所需的全部内容。目前该资源已有170人浏览学习适合作为毕业设计参考或SpringBoot入门实战项目。1. 用 Spring Boot 搭一个能播的歌单服务核心不在「能跑」而在「播得顺」把一首歌拖进网页点播放进度条能拖能停、封面正常、切歌不断流——这套体验背后是音频元数据、文件存储和 HTTP 流式协议三件事一起撑起来的。基于 Spring Boot 的音乐播放网站很多教程做到「上传成功」「列表渲染」「用 audio 标签直链播放」就结尾了但真实用起来马上会撞到三个问题拖动进度条没反应、上传的 MP3 时长永远是 0、播放地址被人拿到后绕过页面直接下载。这三个问题分别对应元数据抽取、Range 协议和播放鉴权也是这个标题下最值钱的知识点。这里不聊播放器皮肤和前端动画只写 Spring Boot 这边从建表到上线的完整走法文件与数据库的边界怎么划、上传时如何识别伪音频、播放接口如何正确响应 Range 请求、搜索和歌单怎么落地。读者可以是正在做毕设的学生、接手内部音频素材库的后端或者单纯想整理私人收藏的 Java 开发者读完能照着把接口写出来也知道出问题的时候该看哪个响应头。2. 先把「文件在磁盘、元数据在数据库」的模型搭对Spring Boot 工程骨架2.1 为什么音频文件不能塞进 MySQL而元数据必须入库一个 3MB 的 MP3 放进 MySQL 的 BLOB 字段读一次播放要换两次 16KB 的 InnoDB 页进来热歌多起来的时候整个缓冲池都会被二进制页占满其他业务查询跟着变慢。更关键的是流式播放需要按字节偏移随机读文件数据库的页存储天生不是为这个场景设计的。所以工程上最常见的做法是文件按目录落盘数据库只存相对路径和描述信息。目录上再按年份或歌手分一层避免单个目录文件数过万。再说相对路径。private String filePath千万别存D:/music/xxx.mp3这种带盘符的绝对路径部署到 Linux 就崩。我一般只存2025/06/xxx.mp3配合一个可配置的根目录来拼接完整路径换环境只改 application.yml 里的一个配置项。这个约定在后面写播放接口时会省很多事也能避免一套代码里到处拼字符串的尴尬。2.2 song 表的字段切分存什么、不存什么字段类型说明idBIGINT主键播放接口直接用它titleVARCHAR(255)歌名上传时优先取 ID3 标签artistVARCHAR(128)歌手可空缺省显示「未知歌手」albumVARCHAR(128)专辑冗余字符串即可不必单独建表duration_secondsINT时长单位秒前端显示 mm:ssbitrate_kbpsINT比特率码率展示与转码判断用file_pathVARCHAR(512)相对路径如 2025/06/xxx.mp3cover_urlVARCHAR(512)封面相对路径可空play_countBIGINT播放次数排行接口的排序依据statusTINYINT0 上传中 / 1 可用 / 2 已下线created_atDATETIME上传时间updated_atDATETIME更新时间配合 ON UPDATE 自动刷新CREATE TABLE song ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(255) NOT NULL, artist VARCHAR(128) DEFAULT 未知歌手, album VARCHAR(128) DEFAULT , duration_seconds INT NOT NULL DEFAULT 0, bitrate_kbps INT DEFAULT 0, file_path VARCHAR(512) NOT NULL, cover_url VARCHAR(512) DEFAULT , play_count BIGINT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_play_count (play_count), KEY idx_status (status) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4;duration_seconds用整数秒而不是字符串「03:25」排序、聚合和前端格式化都方便显示格式交给前端去做。album不单独建表私人点歌站和中小型内部站点里同一个专辑名可能被不同用户传两遍做成字段冗余反而少一堆 join。file_path在 utf8mb4 下按 4 字节/字符计算会占 2048 字节新版 InnoDB 默认行格式没问题如果还在用 5.6 之前的老版本建议把长度降到 190。idx_play_count就是给热榜排序用的和 status 组合起来做「只取可用歌曲」的查询也够用。2.3 工程分层与版本选择2.7.x 还是 3.xData Component ConfigurationProperties(prefix music.storage) public class StorageProperties { private String rootPath ./data/music; private String coverPath ./data/covers; private String playSecret please-change-me; }用ConfigurationProperties而不是一堆Value的原因springboot 配置类可以把同一前缀的配置聚合成一个类型安全的对象启动时 key 缺失或类型不匹配直接报错而不是等到业务跑起来才发现 null。配合spring-boot-configuration-processorIDEA 里写 application.yml 还有补全提示。这是 springboot 配置里最值得养成的习惯比在 Controller 里写Value(${music.storage.rootPath})干净得多。工程结构上controller 只做参数接收和状态码转换service 编排业务mapper 只碰 SQL。音乐播放网站的 controller 层通常薄得只剩几行真正复杂的是 service 里的文件处理和 Range 计算。Controller 里出现「解析 MP3、计算字节长度、拼接磁盘路径」这类代码基本就是分层失守的信号。版本选择上现在的 Spring Boot 分两个大版本线JDK 8 就留在 2.7.xJDK 17 或 21 用 3.x。网上大量教程是 2.x 时代的写法3.x 里javax.servlet变成了jakarta.servlet照抄会直接编译失败这也是很多人感觉「springboot 版本太高」的根源。做这个项目不必追新2.7.x 足够依赖生态也更稳。3. 上传校验与元数据抽取让 Spring Boot 真正「认识」一首歌3.1 上传接口与 multipart 参数的基本盘PostMapping(/api/songs/upload) public SongVO upload(RequestParam(file) MultipartFile file, RequestParam(value title, required false) String customTitle) { if (file.isEmpty()) { throw new BizException(上传文件为空); } if (file.getSize() 50L * 1024 * 1024) { throw new BizException(文件不能超过 50MB); } return musicService.handleUpload(file, customTitle); }这个接口只做三件事判空、限大小、交给 service。业务逻辑不堆在 Controller 里是为了后面加格式校验和元数据抽取时不用改动接口签名。50MB 对 MP3 来说足够宽裕一首 320kbps 的歌大约 10MB/分钟但 application.yml 里的 multipart 上限要记得同步改spring: servlet: multipart: max-file-size: 50MB max-request-size: 60MBmax-file-size是单个文件上限max-request-size是整个请求体上限。只改前者不改后者多个文件同时传时请求体可能先被卡住反过来只改后者单个大文件还是会被拒。这两个参数经常被一起写错看到 413 报错先来这里查。3.2 用 Apache Tika 探测真实文件类型挡住伪音频上传内容扩展名客户端 Content-TypeTika 判定真正的 MP3.mp3audio/mpegaudio/mpegzip 压缩包改名为 .mp3.mp3audio/mpegapplication/zipJPEG 图片改名为 .mp3.mp3audio/mpegimage/jpeg很多上传接口第一版都用file.getContentType()判断是不是 audio/mpeg。这个值直接来自请求头客户端传什么就是什么把 zip 改成 mp3 后缀再传上来Content-Type 照样是 audio/mpeg等于没有校验。可靠的做法是读文件头做二进制探测Apache Tika 就是用 magic bytes 识别真实类型的标准方案依赖tika-core就够不需要引入整套搜索引擎。import org.apache.tika.Tika; private static final Tika TIKA new Tika(); public MediaType detectAudio(RequestParam MultipartFile file) throws IOException { String mime TIKA.detect(file.getInputStream(), file.getOriginalFilename()); if (mime null || !mime.startsWith(audio/)) { throw new BizException(不是音频文件实际类型: mime); } return MediaType.parseMediaType(mime); }detect方法会先看文件内容里的魔数拿不到结论时才使用第二个参数文件名作提示。判断用startsWith(audio/)而不是白名单是因为不同封装格式的 MIME 变体很多——FLAC 既有 audio/flac 也有 audio/x-flacOgg 也不止一种——白名单很容易误杀合法文件。判完类型再按 Tika 返回的类型决定走 MP3 解析还是 FLAC 解析。3.3 用 jaudiotagger 抽时长、比特率与封面import org.jaudiotagger.audio.AudioFile; import org.jaudiotagger.audio.AudioFileIO; import org.jaudiotagger.audio.mp3.MP3AudioHeader; import org.jaudiotagger.tag.FieldKey; import org.jaudiotagger.tag.Tag; import org.jaudiotagger.tag.images.Artwork; AudioFile audioFile AudioFileIO.read(diskFile); MP3AudioHeader header (MP3AudioHeader) audioFile.getAudioHeader(); int durationSeconds header.getTrackLength(); int bitrateKbps header.getBitRateAsNumber(); Tag tag audioFile.getTag(); String title tag.getFirst(FieldKey.TITLE); String artist tag.getFirst(FieldKey.ARTIST); String album tag.getFirst(FieldKey.ALBUM); Artwork artwork tag.getFirstArtwork(); if (artwork ! null) { byte[] coverBytes artwork.getBinaryData(); // 单独落盘到 coverPath不要把 base64 塞进数据库大字段 }getTrackLength()返回的是秒数不是毫秒返回 0 一般说明音频头解析出错或者文件本身损坏这时候落库会让列表页时长显示为 0:00所以看到 0 应当视为上传失败。getBitRateAsNumber()对 VBR可变码率文件返回平均码率写进bitrate_kbps字段做列表展示足够。FieldKey的枚举名跨格式通用对 FLAC 的 Ogg 容器同样有效所以解析代码不用针对格式写两份。getFirstArtwork()拿到的封面图要单独写文件、把路径存进cover_url而不是转 base64 塞数据库——base64 会把体积膨胀 33%列表页一次查询几十首歌光封面字段就能拖慢响应。3.4 上传链路的编排顺序先落盘、再解析、后写库Transactional public SongVO handleUpload(MultipartFile file, String customTitle) { String relativePath storageService.save(file); // 1. 先落盘 AudioFile parsed; try { parsed audioMetadataService.parse(storageService.resolve(relativePath)); // 2. 解析元数据 } catch (Exception e) { storageService.delete(relativePath); // 3. 解析失败就删文件 throw new BizException(音频文件已损坏或格式不支持); } Song song songMapper.insert(buildSong(parsed, relativePath, customTitle)); // 4. 写库 return SongVO.from(song); }顺序必须是「落盘 → 解析 → 写库」而不是「解析好了再落盘」因为绝大多数解析库都要读真实文件而非 MultipartFile 流——先转到磁盘可以减少内存占用也让后续封面和转码处理复用同一份文件。注意Transactional只负责回滚数据库管不了磁盘所以第 3 步解析失败时必须在 catch 里手动删文件否则会留下永远没进库的孤儿文件时间长了把磁盘塞满。4. 流式播放与 Range 请求把 Spring Boot 变成不拖进度条的音频服务器4.1 先看懂 audio 标签到底在向服务器要什么浏览器播放/api/songs/1/play地址时audio 标签会先发一个带Range: bytes0-的请求。服务端有两种回应方式无视 Range 直接回 200 和全量字节或者识别 Range 回 206 和指定的字节区间。前者在局域网里也能播但拖动进度条时播放器会发现问题——浏览器期望服务端支持 seek发出去的Range: bytes1048576-只收到全量 200于是进度条卡死或从头重新加载。能让用户拖动进度条的唯一前提就是服务端正确实现 HTTP Range。反过来看Range 支持也让播放器省流量用户只听前 30 秒不会把整首 8MB 下载完。对内部站点来说这比任何缓存配置都更直接地降低带宽压力。实现 Range 的核心就三个响应要素Accept-Ranges: bytes、状态码206、以及Content-Range里描述的字节区间下面直接在 Spring Boot 里落地。4.2 用 ResourceRegion 返回 206 Partial ContentGetMapping(/api/songs/{id}/play) public ResponseEntityResourceRegion play(PathVariable Long id, RequestHeader HttpHeaders headers) throws IOException { Song song songService.getAvailable(id); File file storageService.resolve(song.getFilePath()); long contentLength file.length(); Resource fileResource new FileSystemResource(file); ListHttpRange ranges headers.getRange(); if (ranges.isEmpty()) { ResourceRegion full new ResourceRegion(fileResource, 0, contentLength); return ResponseEntity.ok() .header(HttpHeaders.ACCEPT_RANGES, bytes) .contentLength(contentLength) .contentType(MediaType.APPLICATION_OCTET_STREAM) .body(full); } HttpRange range ranges.get(0); long start range.getRangeStart(contentLength); long end range.getRangeEnd(contentLength); long length end - start 1; ResourceRegion region new ResourceRegion(fileResource, start, length); return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT) .header(HttpHeaders.ACCEPT_RANGES, bytes) .header(HttpHeaders.CONTENT_RANGE, bytes start - end / contentLength) .contentLength(length) .contentType(MediaType.APPLICATION_OCTET_STREAM) .body(region); }headers.getRange()返回 List 是因为 HTTP 规范允许多个区间例如Range: bytes0-1,5-6。audio 标签实际只发单区间所以取get(0)真要支持多区间返回 multipart/byteranges 会让代码复杂度翻倍音频场景不需要。getRangeStart()和getRangeEnd()是 Spring 对bytesstart-end的解析结果start/end 是闭区间字节数是end - start 1这个加一漏掉后 Content-Length 会差一个字节用 curl 验证时能看到文件末尾读不出来。没带 Range 时也要回Accept-Ranges: bytes这等于告诉播放器「我支持 seek」。要素示例含义状态码206 Partial Content部分内容而非全量 200Accept-Rangesbytes告诉客户端支持按字节 seekContent-Rangebytes 0-1048575/8388608本次返回的区间 / 文件总长Content-Length1048576本次实际返回的字节数注意Content-Type 示例里用了 application/octet-stream实际项目建议按扩展名返回 audio/mpeg 或 audio/flac用 octet-stream 在大多播放器里也能播但部分 iOS Safari 版本会因类型不明确拒绝预加载。4.3 播放地址签名别让文件路径变成公开下载链接id 连续自增的播放接口意味着任何人猜一下/api/songs/1/play就能遍历全部歌曲再配合 Range 请求等于有了不受限的下载通道。常见做法是给播放地址加一个短期签名参数过期作废。签名用 HMAC 对「歌曲 id 过期时间戳」做哈希服务端校验时不依赖数据库天然适合多实例部署。public String buildPlayUrl(Long songId) { long expiresAt System.currentTimeMillis() / 1000 600; // 10 分钟有效 String payload songId : expiresAt; String sign hmacSha256(payload, storageProperties.getPlaySecret()); return /api/songs/play?sid songId expires expiresAt sign sign; } public boolean verifyPlayUrl(Long songId, long expiresAt, String sign) { if (expiresAt System.currentTimeMillis() / 1000) { return false; } String expect hmacSha256(songId : expiresAt, storageProperties.getPlaySecret()); return MessageDigest.isEqual(expect.getBytes(StandardCharsets.UTF_8), sign.getBytes(StandardCharsets.UTF_8)); }expiresAt 给 10 分钟是参考值。播放器拿到短链接后还要做预加载、CDN 回源太短会频繁重新签名太长又失去防扩散意义纯内网站点 10 分钟合理。校验用MessageDigest.isEqual而不是 String.equals能避免时间侧信道这个项目里影响有限但写成习惯总归没错。verifyPlayUrl要放在播放接口入口处先执行校验不通过直接 403不进入后续 Range 逻辑。4.4 前置 Nginx 与 Tomcat 并发上线的站点我一般会在 Spring Boot 前面挂一层 Nginx。播放请求是长连接大流量Tomcat 默认线程池 200几十个用户同时拖进度条就能占满。Nginx 负责处理静态文件的 Range 请求回源时才落到 Spring Boot。Nginx 对Range头透传是默认支持的关键是别在 location 里把$http_range覆盖掉。location /api/songs/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Range $http_range; proxy_set_header If-Range $http_if_range; proxy_buffering off; access_log off; }proxy_buffering off对音频流比较关键——开着缓冲会让 Nginx 攒够缓冲区才转发音频首包延迟变大关掉后客户端拿到第一个字节的时间明显缩短。注意这个 location 只对播放路径生效别整个站点都关缓冲。5. 搜索、歌单与热榜缓存把播放网站做成产品而不是接口堆砌5.1 歌曲模糊搜索的 SQL 写法与索引陷阱搜索框是这类站点的基本入口。最简单直接的 SQL 是LIKE %关键词%数据量在十万行以内时全表扫描大约 20ms 上下用户感知不明显。真正需要注意的坑是——前导通配符%关键词会让 MySQL 放弃索引即使 title 列建了索引也不走。这个知识点面试也常拿「like 索引失效」来问根因是 B 树只能按前缀匹配节点前导百分号要求从任意位置匹配。Select( SELECT * FROM song WHERE status 1 AND (title LIKE CONCAT(%, #{kw}, %) OR artist LIKE CONCAT(%, #{kw}, %)) ORDER BY play_count DESC LIMIT #{limit} ) ListSong search(Param(kw) String kw, Param(limit) int limit);参数绑定用#{}MyBatis 会做预编译字符串拼接方式的${}是注入重灾区搜关键词的接口尤其不能省。ORDER BY play_count DESC让搜索结果自然偏向热门避免冷门同名词占满首页。limit不能在前端放开一般限制 50这个参数经常被忽略实际是控制响应体大小的关键。数据量过了十万、或要求分页深翻再考虑 MySQL 内置全文索引或 Elasticsearch在这个项目阶段LIKE 加 limit 是性价比最高的方案别一上来就上 ES运维成本完全不同。5.2 歌单表的多对多设计playlist 与 playlist_song表名字段说明playlistid, name, user_id, cover_song_id, created_at歌单主体cover_song_id 用哪首歌做封面playlist_songplaylist_id, song_id, sort_order, added_at关联表联合主键防重复CREATE TABLE playlist_song ( playlist_id BIGINT NOT NULL, song_id BIGINT NOT NULL, sort_order INT NOT NULL DEFAULT 0, PRIMARY KEY (playlist_id, song_id), KEY idx_song_id (song_id) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4;关联表不设自增主键用(playlist_id, song_id)联合主键从根上挡住同一首歌重复加入歌单。sort_order给手动排序留了口子前端拖拽调整顺序时后端一次批量 UPDATE 就能落库。需要注意的是查询歌单详情要 join song 表播放量大的歌单建议在 service 层做一层缓存而不是每次请求都全量 join 两张表再排序。5.3 热歌榜的本地缓存Caffeine 比 Redis 更省事热榜接口是典型的高频读、低频写场景。每次请求都实时执行ORDER BY play_count不是不行但几百首歌以下完全没必要。常见做法是用 Caffeine 做进程内缓存TTL 给 60 秒就已经秒开。只有当站点部署了多个实例、且希望歌单被修改后所有实例同步失效时才需要把缓存换成 Redis——Spring Data Redis 的Cacheable用法一致换底层实现不影响业务代码。Cacheable(cacheNames hotSongs, key top- #limit, unless #result null || #result.size() 0) public ListSong hotSongs(int limit) { return songMapper.selectHot(limit); }Cacheable会先查缓存命中就返回不执行方法体。key 设计成top-10、top-50这种不同 limit 各缓存一份unless 条件避免空结果也被缓存——空结果缓存 60 秒会让人以为系统坏了不如让它每次穿透到数据库。启动类上记得加EnableCaching否则注解会静默失效这是最隐蔽的一个坑。5.4 播放次数更新别让每次播放都打一次 UPDATE如果每播放一次就执行一条UPDATE play_count play_count 1热门歌曲同时在线几百人时这条行的行锁会变成瓶颈。常见做法是内存计数器攒批落库用 ConcurrentHashMap 累计增量定时任务每 30 秒批量 UPDATE 一次。歌曲规模到十万级以前这个方案都够稳定还顺带解决了列表页实时刷新不准确的问题——播放在线的瞬时抖动不应该直接写进排行定时批量写反而让数据更平滑。6. 发布前的压测与验证Range 并发和三个容易忽略的坑6.1 用 ab 模拟播放器的 Range 请求ab -n 2000 -c 50 -H Range: bytes0-1048575 \ http://127.0.0.1:8080/api/songs/1/play-n 2000是总计 2000 个请求-c 50模拟 50 个并发播放器-H手动带 Range 头模拟用户拖动到文件前 1MB 位置。重点关注输出里的 Failed requests 是否为 0以及 Time per request 是否在几十毫秒量级。如果 Failed 不为 0 且全是 500多半是 4.3 里签名校验接口的并发 bug而不是 Range 本身的问题。6.2 三个最常见的掉链子点现象原因处理上传大文件报 413multipart 只配了 max-file-size同时改 max-request-size见 3.1列表页封面全裂cover_url 存了相对路径页面按绝对路径渲染前端拼 /covers/ 前缀或用配置注入完整前缀点击播放 404文件却在静态资源映射覆盖了 /api/**检查 WebMvcConfigurer 的 addResourceHandlers 是否设成了 /api/**第三个最隐蔽Spring Boot 的静态资源默认在 classpath:/static如果又手动把 resource handler 映射到 /api/**Controller 的路径会被吞掉大半常见表现是接口文档能打开但业务接口 404。出现这种情况先别怀疑业务代码直接查配置类里的路径映射。6.3 一分钟确认网站是否支持 seekcurl -I -H Range: bytes0- http://127.0.0.1:8080/api/songs/1/play看响应头里有没有HTTP/1.1 206、Accept-Ranges: bytes和Content-Range三者齐了播放器拖动进度条就不会出问题。这一步比在浏览器里人工拖进度条可靠得多也适合写进发布流程的 smoke test 脚本。本文还有配套的精品资源点击获取
返回列表