
说实话看到基于SpringBoot的音乐网站这个选题你可能会觉得又是经典毕业设计款但真正动手拆解之后会发现它的含金量比想象中高不少。相比图书管理、学生管理这种纯CRUD项目音乐网站天然带着文件存储、流式播放、检索排序、权限控制几个硬骨头特别适合用来练SpringBoot的完整落地能力。这篇文章不吹不黑把实际搭建这类项目时的功能拆解、存储选型、代码写法、部署过程以及踩过的坑一次性梳理清楚给准备用SpringBoot做音乐网站相关项目的同学一个可复用的路线图。1. 项目整体拆解SpringBoot音乐网站的功能面和复杂度评估1.1 看着是个小项目实际上横跨五条业务线很多人在拿到这个题目时第一反应是不就是一个CRUD吗但等你真正梳理需求就会发现一个能被称作网站的音乐项目至少要覆盖五条核心业务线。第一条是用户体系注册、登录、个人信息、头像上传这块是任何网站的底座在SpringBoot里通常会结合JWT做无状态认证或者用Session做传统方案得根据项目规模取舍。第二条是音乐资源管理歌曲上传、音频转码、封面图片处理、歌词文件维护这是音乐网站区别于普通资源站的核心难度所在涉及文件流和对象存储。第三条是业务交互歌单创建、收藏歌曲、评论留言、点赞这部分就是典型的关系型数据设计加增删改查用来训练表结构设计能力很合适。第四条是检索歌曲名搜索、歌手搜索、歌单搜索从简单的LIKE查询到分词搜索可浅可深。第五条是后台管理用户管理、歌曲上下架、数据统计如果目标是毕设或者个人项目可以用简单的角色判断来做不需要引入重型权限框架。把这几条线放在一起衡量工作量已经接近一个中型Web项目而不是小Demo。这也是我建议选这个题目的原因——它能有层次地暴露问题让你在写代码过程中把SpringBoot周边生态的常用组件过一遍。1.2 核心数据流从上传到播放的一次完整闭环你只有把一条核心链路的每一步都打通这个项目才算真正活了。我习惯用一条用户操作来描述这条链路用户在后台页面上传一首MP3填好歌名、歌手、歌词点击保存。前端通过HTTP将文件和表单数据POST到SpringBoot的song/upload接口后端用MultipartFile接收文件校验格式和大小然后把音频文件交给Minio对象存储同时把歌曲元数据写入MySQL数据库。紧接着另一个用户打开前台页面搜索这首歌点击播放——浏览器向后端请求音频文件后端从Minio取出文件并以流的形式返回同时播放器拿到歌词文件逐行渲染当前播放进度通过history表记录下来。等用户把这首歌加入歌单list_song关联表多一条记录。这条链路的每一环都会牵出一个技术点文件流处理怎么做对象存储和数据库怎么保持一致音频流式传输怎么支持进度条拖动歌词文件的编码格式怎么处理搜索关键词怎么匹配到歌曲。把这个闭环跑通前面说的五条业务线就有了一根主心骨。1.3 一个合理的项目包结构参考SpringBoot项目最怕写成万能Controller所有逻辑塞在Service里。我见过大量音乐网站的源代码包结构混乱后面维护和扩展都费劲。这里给出一个经过实践梳理的分层参考直接照搬也不会出大错。com.example.music ├── MusicApplication.java ├── config │ ├── MinioConfig.java │ ├── WebMvcConfig.java │ └── CorsConfig.java ├── controller │ ├── UserController.java │ ├── SongController.java │ ├── SongListController.java │ ├── SearchController.java │ └── AdminController.java ├── service │ ├── UserService.java │ ├── SongService.java │ ├── SongListService.java │ └── MinioService.java ├── mapper │ ├── UserMapper.java │ ├── SongMapper.java │ ├── SongListMapper.java │ └── ListSongMapper.java ├── entity │ ├── User.java │ ├── Song.java │ ├── SongList.java │ └── ListSong.java ├── dto │ ├── UploadSongDTO.java │ ├── LoginDTO.java │ └── SearchResultDTO.java ├── common │ ├── Result.java │ ├── ResultCode.java │ └── GlobalExceptionHandler.java └── utils ├── JwtUtil.java └── HanLpUtil.java看到没有核心思路是Controller只负责参数接收和结果封装Service承担业务逻辑Mapper专注数据库交互Minio这类外部组件单独抽Service做隔离。以后不管前端对接还是数据库换表都能把改动控制在一个小范围内。2. 存储方案设计为什么把音乐文件交给Minio而不是本地磁盘2.1 本地磁盘的三个致命问题很多人刚开始写音乐网站时偷懒把MP3直接存到项目文件夹下比如D:/upload/再通过后端映射虚拟路径访问。在小规模测试时这个方案确实能跑通但放到稍微真实的场景里立刻暴露三个问题。第一是路径与部署的耦合问题。用IDEA本地启动时路径写死没问题一旦把项目打成Jar包放到服务器上D:/upload这种路径根本不存在你就得改配置如果用Docker部署容器重启文件就丢因为容器层是可变的。第二是数据与代码的分离问题。音乐的实体文件、封面图片、歌词文件都是内容数据它们和数据表属于同一层级不应该跟随工程代码一起被Git管理否则提交一次版本库就膨胀一次。第三是扩展性问题。本地磁盘存文件的方案意味着文件只能由这一台服务器提供访问前端页面部署到另一台服务器时还得跨机器访问资源降低性能、增加耦合。2.2 Minio接入SpringBoot的完整过程Minio是S3兼容的对象存储服务一句话理解就是自己布一套类似OSS的前端。它适合音乐网站这种场景因为音频文件量大但访问模式简单不需要上复杂的分布式文件系统。我最初在项目中引入Minio时第一步是在pom.xml添加依赖dependency groupIdio.minio/groupId artifactIdminio/artifactId version8.5.7/version /dependency这里特别强调版本因为Minio的Java客户端8.x版本才适配SpringBoot 2.x/3.x老用7.x会遇到NoClassDefFoundError的坑。然后写一个配置类把Minio的连接参数做成可配置项Data Component ConfigurationProperties(prefix minio) public class MinioProperties { private String endpoint; private String accessKey; private String secretKey; private String bucket; }配合application.yml里的配置minio: endpoint: http://127.0.0.1:9000 access-key: admin secret-key: admin123456 bucket: music-bucket接下来初始化客户端建议在配置类里声明一个名为MinioClient的Bean这样Service层通过构造器注入就能使用避免每次调用都重新创建连接Configuration EnableConfigurationProperties(MinioProperties.class) public class MinioConfig { Bean public MinioClient minioClient(MinioProperties props) { return MinioClient.builder() .endpoint(props.getEndpoint()) .credentials(props.getAccessKey(), props.getSecretKey()) .build(); } }然后封装MinioService把上传和下载统一入口调用方不需要关心Bucket和文件路径的细节Service public class MinioService { Resource private MinioClient minioClient; Resource private MinioProperties props; public void uploadFile(String objectName, InputStream inputStream, long size, String contentType) { try { PutObjectArgs args PutObjectArgs.builder() .bucket(props.getBucket()) .object(objectName) .stream(inputStream, size, -1) .contentType(contentType) .build(); minioClient.putObject(args); } catch (Exception e) { throw new RuntimeException(上传文件到Minio失败, e); } } public String getFileUrl(String objectName) { try { GetPresignedObjectUrlArgs args GetPresignedObjectUrlArgs.builder() .bucket(props.getBucket()) .object(objectName) .method(Method.GET) .build(); return minioClient.getPresignedObjectUrl(args); } catch (Exception e) { throw new RuntimeException(获取文件访问链接失败, e); } } }注意uploadFile里有一个size参数这是文件流大小。用MultipartFile时直接取file.getSize()即可如果填错会导致上传后文件损坏这是一个容易被忽略的细节。2.3 Bucket权限与内外网Endpoint的取舍音乐网站里Bucket的权限设置要先想清楚。如果所有音频都是公开的你可以把Bucket访问策略设为public readonly这样前端拿固定URL直接播放最简单如果涉及VIP歌曲、付费下载这些控制逻辑就必须设置为private后端通过预签名URL下发临时访问链接。预签名URL的有效期也可以按需设置比如设置7天、24小时或者只在播放时生成。我自己在做的时候一般对外网访问域名的签名地址全部走endpoint配置而内网传输走单独的internalEndpoint这样服务间传输不经过公网降低延迟也更安全。这块如果没有特殊要求最简单的做法就是单独建一个public-read的bucket放封面图片和公开歌曲再建一个private桶放源文件和未审核内容避免为了省事把全部文件都暴露在公网可读的状态下。3. 数据库设计音乐网站最容易翻车的几张表和关联关系3.1 五张核心表的结构与关系歌曲、歌手、歌单、用户、收藏这几张表是音乐网站的骨架设计得不合理后面写业务代码会非常痛苦。以我自己这边实际项目举例设计了这样的核心表结构CREATE TABLE singer ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL, avatar_url VARCHAR(255), intro TEXT ); CREATE TABLE song ( id INT AUTO_INCREMENT PRIMARY KEY, singer_id INT NOT NULL, name VARCHAR(100) NOT NULL, cover_url VARCHAR(255), file_url VARCHAR(255), lyric_url VARCHAR(255), duration INT COMMENT 单位秒, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, status TINYINT DEFAULT 1 COMMENT 1上架 0下架, INDEX idx_singer_id (singer_id) ); CREATE TABLE song_list ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, title VARCHAR(100) NOT NULL, description TEXT, cover_url VARCHAR(255) ); CREATE TABLE list_song ( id INT AUTO_INCREMENT PRIMARY KEY, list_id INT NOT NULL, song_id INT NOT NULL, sort_order INT DEFAULT 0, UNIQUE KEY uk_list_song (list_id, song_id) ); CREATE TABLE collect ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, song_id INT, song_list_id INT, create_time DATETIME DEFAULT CURRENT_TIMESTAMP );list_song是歌单和歌曲的关联表注意UNIQUE KEY uk_list_song用来防止同一首歌重复加入一个歌单collect表同时保留song_id和song_list_id两个字段但同一时间只允许一个非空用来表示收藏的是单曲还是歌单。这种设计有些反范式但胜在查询简单、逻辑好理解适合中小规模项目。3.2 评论表和播放历史的计数处理评论表和播放历史很多人第一次设计时会漏掉但音乐网站缺少这两块会很奇怪。评论表核心字段包括user_id、song_id、content、parent_id其中parent_id用来做回复的嵌套引用。播放历史表不用单独存歌曲快照只存user_id、song_id、play_time即可按时间倒序取最近20条就是最近播放功能。这里有一个容易踩的坑播放历史表千万别做唯一约束否则用户每次播放同一首歌历史就只剩一条了功能就废了。正确做法是查询时GROUP BY song_id配合MAX(play_time)既保证去重又保留了最新播放时间。3.3 为什么建议加一张搜索关键词统计表如果项目有搜索功能强烈建议建一张search_record表记录搜索词和搜索次数。别小看这张表它既能做热门搜索排行又能给后续搜索优化提供真实数据。表结构非常简单id、keyword、search_count、update_time每次搜索命中时对关键词做ON DUPLICATE KEY UPDATE增加计数后面做热搜词接口只用一条SQL排序即可响应速度快也没有额外中间件成本。我在做音乐网站时还把这张表的统计结果用在后台管理的一个页面里展示搜索热度趋势效果很好这里建议你也加上属于投入极小收益极高的设计。4. 核心接口实战音乐上传、播放与歌单操作4.1 音乐上传接口从MultipartFile到对象存储的完整链路上传接口的关键在于文件和表单一起提交前端用FormData把歌曲文件和歌曲信息放在同一个请求体里后端的Controller用一个对象接收表单字段再用一个单独的MultipartFile接收文件这个模式很常见。Controller示例PostMapping(/song/upload) public ResultString uploadSong(RequestParam(file) MultipartFile file, RequestParam(name) String name, RequestParam(singerId) Integer singerId, RequestParam(value cover, required false) MultipartFile cover) { if (file.isEmpty()) { return Result.error(音频文件不能为空); } String originalFilename file.getOriginalFilename(); String ext originalFilename.substring(originalFilename.lastIndexOf(.) 1); if (!Arrays.asList(mp3, wav, flac, m4a).contains(ext.toLowerCase())) { return Result.error(不支持的音频格式); } String objectName song/ UUID.randomUUID() . ext; try { minioService.uploadFile(objectName, file.getInputStream(), file.getSize(), file.getContentType()); } catch (Exception e) { return Result.error(文件上传失败 e.getMessage()); } Song song new Song(); song.setName(name); song.setSingerId(singerId); song.setFileUrl(objectName); if (cover ! null !cover.isEmpty()) { String coverObjectName cover/ UUID.randomUUID() .jpg; minioService.uploadFile(coverObjectName, cover.getInputStream(), cover.getSize(), cover.getContentType()); song.setCoverUrl(coverObjectName); } songMapper.insert(song); return Result.success(上传成功); }注意几个细节文件扩展名校验做了但还不够真实项目中最好区分后缀校验和MIME类型校验两层防止有人改后缀传一个伪装成MP3的可执行文件文件名一律重命名不要用用户原始文件名直接存Minio因为不同用户上传同名文件很容易互相覆盖加UUID是为了全局唯一歌曲信息和文件上传本身不是事务性的——如果文件传到了Minio但数据库写入失败会产生孤儿文件这个问题可以通过定时任务扫描Minio和数据库对照清理也可以在业务层先写数据库再看情况回滚属于经典的一致性取舍。4.2 流式播放接口Range请求头与206状态码播放功能是整个项目最有技术含量的点因为普通文件下载接口一次返回整个文件浏览器也可以正常播放MP3但一旦用户点击进度条拖动要么等全量下载要么就直接卡住。正确的做法是支持HTTP的Range分段请求让播放器可以只请求音频文件的某一段字节。SpringBoot中实现方式有很多最直接的是用ResponseEntity结合Resource来做但想精细控制还是得自己处理请求头。GetMapping(/song/stream/{objName}) public ResponseEntityResource streamSong(PathVariable String objName, RequestHeader(value Range, required false) String rangeHeader) { // objName song/uuid.mp3从Minio获取文件流 GetObjectArgs args GetObjectArgs.builder() .bucket(minioProperties.getBucket()) .object(objName) .build(); GetObjectResponse response minioClient.getObject(args); long fileSize response.headers().get(Content-Length) ! null ? Long.parseLong(response.headers().get(Content-Length)) : -1; if (rangeHeader null) { // 未带Range返回全部文件状态码200 return ResponseEntity.ok() .contentType(MediaType.APPLICATION_OCTET_STREAM) .body(response); } // 解析Range头形如 bytes0-1023 String range rangeHeader.replace(bytes, ); String[] ranges range.split(-); long rangeStart Long.parseLong(ranges[0]); long rangeEnd ranges.length 1 ? Long.parseLong(ranges[1]) : fileSize - 1; long contentLength rangeEnd - rangeStart 1; // 从Minio中跳过rangeStart字节后读取 GetObjectArgs rangeArgs GetObjectArgs.builder() .bucket(minioProperties.getBucket()) .object(objName) .offset(rangeStart) .length(contentLength) .build(); GetObjectResponse rangeResponse minioClient.getObject(rangeArgs); return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT) // 206 .header(HttpHeaders.CONTENT_RANGE, bytes rangeStart - rangeEnd / fileSize) .header(HttpHeaders.ACCEPT_RANGES, bytes) .contentLength(contentLength) .body(rangeResponse); }上面的代码用MinioClient的offset和length参数实现了精确的段读取前端播放器比如audio标签或Vue组件会自动在请求头带上Range: bytes0-后端返回206后就可以随意拖动进度条体验和成熟音乐平台几乎一致。这个接口是整个项目里最有含金量的部分比几十个简单的增删改查接口加起来都值钱。如果前期想简化也可以直接在查询出file_url后让前端访问Minio的预签名链接并直接由Minio处理Range但那样就失去了用代码控制权限的机会。4.3 歌单操作关注关联表的原子性歌单是一对多和多对多关系的经典实践。创建歌单是用户插入一条song_list记录把歌单里的歌排序更新list_song.sort_order把一首歌加进歌单先查list_song是否有相同记录没有才执行插入。这里最需要注意的是添加歌曲到歌单这个操作要加防重点不要因为用户点了两下按钮就没完没了地插入重复关联记录唯一索引uk_list_song就是兜底方案。删除歌曲操作则要处理级联歌曲下架时要考虑歌单引用关系可以直接不清歌单数据而让前端按status过滤也可以把list_song关联数据一起删除后者的好处是后台管理更干净坏处是误删歌曲后歌单恢复成本高这里要根据实际需要取舍。5. 搜索功能的设计从LIKE查询到分词检索的升级路径5.1 当数据量小的时候LIKE查询够用音乐网站的搜索很常见前端输入框输入一个关键词期望返回匹配的歌曲、歌手、歌单。在第一版实现里大部分人想到的写法是SELECT * FROM song WHERE name LIKE CONCAT(%, #{keyword}, %);这种写法在几千条数据量、并发不高的时候响应都在几十毫秒以内展示效果好开发效率也高。但是有一个明显的缺陷不支持分词比如用户搜周杰伦晴天数据库里歌曲名是晴天周杰伦搜索时如果不把关键词拆开而只整体搜索周杰伦晴天这一个串就会查不到任何结果。因此简单LIKE只能满足Demo阶段的需求真实使用场景里会出现越来越多明明有这首歌却搜不到的反馈。5.2 HanLP接入SpringBoot实现真正的分词搜索我当时的解决方案是引入HanLP分词它是开源的中文NLP工具包依赖不会太重接入SpringBoot也快。先加依赖dependency groupIdcom.hankcs/groupId artifactIdhanlp/artifactId versionportable-1.8.4/version /dependency在Service里直接调用HanLP对用户输入的关键词进行分词得到多个词项然后用这些词项拼接查询配合布尔逻辑找到更精确的结果。public ListSong searchSongs(String keyword) { ListString terms HanLP.segment(keyword) .stream() .map(term - term.word) .collect(Collectors.toList()); if (terms.isEmpty()) { terms.add(keyword); } String likePattern terms.stream() .map(term - % term %) .collect(Collectors.joining( AND name LIKE , name LIKE , )); // 实际可以用MyBatis的动态SQL来拼接避免注入问题 return songMapper.searchByKeywords(terms); }更容易落地的做法是分词后在Mapper里写动态SQL对多个词进行逐条LIKE匹配并把匹配到的记录按命中词数排序——命中两个词的排在只命中一个词的前面比单纯ORDER BY更有相关性。还应该对歌曲名、歌手名、歌单名分别赋予不同权重比如歌曲名权重最高歌手名次之歌单名最低最终按加权得分排序返回这种设计配合几百行代码就能做出搜索效果明显优于纯LIKE的体验。5.3 搜索关联表和搜索热榜上一节提到的search_record表在这里真正发挥作用。每次搜索时先分词再去查询歌曲同时把原始关键词插入搜索统计表。热门搜索榜的SQL很简单SELECT keyword, search_count FROM search_record ORDER BY search_count DESC LIMIT 10;这种设计让搜索功能看起来非常完整和专业。在做完核心搜索之后还可以继续扩展给search_record加user_id字段就可以记录用户的个人搜索历史配合登录用户的ID提供给前端做最近搜索记录功能这个扩展在实际项目里非常讨喜也几乎是搜索类需求的标准配套实现成本不高但成果感强。这里建议你如果用了HanLP就考虑把搜索词统一做一次拼音首字母的扩展分词尤其针对歌手名可以支持用户输缩写找歌手属于另一个量的加分项。6. 前后端一体化部署Vue打包结果塞进SpringBoot的那些细节6.1 开发环境的跨域处理音乐网站的前端如果用Vue开发开发服务器默认localhost:8080和后端SpringBoot默认localhost:8081端口不一致注定产生跨域不解决这个前端调后端接口全被拦截。传统做法是在SpringBoot里写一个CorsConfig允许指定来源、请求头和请求方法。另一种在开发中更好用也更贴近正式环境的方式是利用Vue的proxy功能把请求代理到后端服务器前端页面里的axios请求统一以/api开头通过vue.config.js里的devServer.proxy做转发// vue.config.js module.exports { devServer: { port: 3000, proxy: { /api: { target: http://localhost:8081, changeOrigin: true, pathRewrite: { ^/api: } } } } }这样开发时前端请求地址和后端接口路径完全一致不需要后端额外放开跨域。等到生产部署时也不存在跨域问题因为前端静态页面由SpringBoot同一端口提供请求就是同源的所以不需要后端专门配置CORS。这是一种最简单也最稳妥的方式能避免后期生产环境忘改跨域配置。6.2 打包进static目录后的路径与路由坑生产环境下把Vue打包后的dist目录内容直接复制到src/main/resources/static/底下然后打包SpringBoot就能用一个Jar同时提供前端页面和后端接口非常方便。但这里有两个高频坑十个人有八个人会踩。第一个坑是静态资源路径。Vue2和Vue3的默认publicPath是/打包后生成的index.html里引用的/js/app.js和/css/app.css等等都是绝对路径如果你的SpringBoot部署在IP根路径下没问题但一旦在server.servlet.context-path上加了一个前缀比如/music那前端页面访问时会变成/music/js/app.js找不到静态资源导致白屏。解决办法是把publicPath改为./相对路径这样打包后的资源引用就是相对路径不管部署在哪一层都能找到。第二个坑是路由模式。Vue Router默认的history模式依赖服务器对任意路径都返回index.html但SpringBoot的静态资源服务只会精确匹配文件你直接访问/song/1这种前端路由地址时后端会返回404。解决办法有两个一是干脆把前端路由改成hash模式URL上带个#号对服务器没有任何要求最省事二是想保持history模式就写一个转发Controller或者自定义WebMvcConfigurer对非/api开头的路径统一转发到index.html。实际项目中我建议直接用hash模式省心且不影响播放器的路由传参。6.3 端口配置、Banner和IDEA启动参数SpringBoot应用默认端口是8080但如果本地同时跑着多个SpringBoot项目经常发生端口冲突报错一看是Port 8080 was already in use。在application.yml里设置server.port: 8081是最基础的操作。如果你用的是IDEA还可能在启动配置里临时指定端口打开右上角运行配置在VM options一栏填-Dserver.port8081或者在Environment variables里加SERVER_PORT8081效果一样。这里顺手提一句IDEA里配置SpringBoot启动项的入口在Run/Debug Configurations能编辑端口、环境变量、活动Profile比如--spring.profiles.activedev。很多同学明明改对了代码却启动的还是老配置就是因为运行配置里缓存了旧的Profile或者环境变量排查时要多看一眼。SpringBoot启动时的Banner字符串也是可以定制的网上有各种在线Banner生成器生成的ASCII艺术字粘贴到banner.txt放进src/main/resources下即可对功能无影响但展示效果确实更有个性。7. 版本与依赖的连环踩坑SpringBoot版本过高、Maven构建和Minio兼容性7.1 SpringBoot 3.x与2.x的差异javax到jakarta的迁移时代变了现在新建SpringBoot项目默认会给你拉最新的3.x版本但音乐网站这类项目往往涉及很多第三方依赖比如Minio的Java SDK、HanLP、旧版MyBatis插件它们对SpringBoot 3.x的适配程度参差不齐。SpringBoot 3.x最核心的变化之一就是包名从javax.*迁移到jakarta.*意味着你以前搜到的所有教程里引入javax.servlet的代码全部失效必须替换为jakarta.servlet。如果你发现网上的代码在ServletRequest相关的地方报红大概率是版本跨度差异导致的不是依赖没引全。顺带提一句SpringBoot 3.x对JDK要求是17及以上如果你的电脑只装了JDK8要么换版本用SpringBoot 2.7.x要么升级JDK。对毕设和练手项目来说用好2.7.x难度低、资料多、兼容性好因为主流教程和第三方组件适配这个版本最成熟如果追求新特性再上3.x但要有自己排查依赖兼容性的心理准备。这里建议刚上手的人直接选择SpringBoot 2.7.x搭配JDK8先把主流程跑通等对依赖体系熟悉后再考虑升级。7.2 Maven项目构建方法的重点记录Maven构建SpringBoot项目时最常见的问题是依赖下载慢而且容易报网络错误。mvn install时卡在中央仓库下载是常态特别是Minio、HanLP这些体积较大的依赖。解决方法有两条一是修改Maven的settings.xml里的镜像源换成阿里云镜像二是本地仓库出现过半截文件损坏的情况时直接把~/.m2/repository下对应的目录删掉重新执行构建。此外打Jar包时注意SpringBoot Maven插件要单独配置repackage目标否则打出来的包是普通Jar启动会报no main manifest attribute。具体配置如下build plugins plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId version${spring-boot.version}/version executions execution goals goalrepackage/goal /goals /execution /executions /plugin /plugins /build构建完后在target目录下拿到形如music-0.0.1-SNAPSHOT.jar的包用java -jar启动前后端就一体化运行了。构建时如果报找不到符号或者程序包xxx不存在一般是两种可能一个是本地依赖没装全另一个是某依赖版本内部的传递依赖冲突后面会专门讲。7.3 一次Minio运行时异常的完整定位过程我想讲一个自己遇到过的比较典型的排错过程它涉及Minio、SpringBoot、Maven三个环节正好用来展示排查思路。当时的场景是项目本地IDEA启动一切正常上传歌曲、播放歌曲都流畅但把项目打成Jar包放到服务器上之后上传接口一直报Connection refused: connect /127.0.0.1:9000。第一步看报错堆栈果断定位到MinioService的uploadFile。第二步检查服务器上Minio是否启动在服务器上执行systemctl status minio发现服务是运行中的。第三步检查端口监听netstat -tlnp | grep 9000发现9000端口只监听了127.0.0.1而外部程序访问的是容器网关地址链路不通。第四步检查Minio的启动配置MINIO_OPTS发现绑定地址写的是127.0.0.1:9000改成0.0.0.0:9000后重启Minio端口监听变为所有网卡。再试上传接口成功。这个排查过程其实暴露了一个通用的思路本地能用服务器不能用先查网络可达性再查服务监听地址最后查防火墙和安全组不要一上来就怀疑代码。7.4 Maven依赖冲突的一个典型排查场景Maven依赖冲突是SpringBoot项目里最让人头秃的隐藏Boss。有一次我在项目里引了第三方工具包本来挺正常结果启动时SpringBoot就报了一长串NoSuchMethodError指向某个类的方法不存在。这类报错的规律性比较强编译期没问题、运行期才炸十有八九是同一个类出现在多个依赖版本中运行时加载到了旧版本。排查可以使用IDEA自带的Maven Helper插件打开pom.xml的依赖关系图搜索冲突的类名看有哪些传递依赖引入了不同版本也可以直接在命令行用mvn dependency:tree -DincludesgroupId:artifactId查看依赖树定位到重复依赖后在pom.xml中用exclusion把传递依赖里不需要的旧版本剔掉。如果涉及SpringBoot内嵌的某个库冲突还可以用maven-enforcer-plugin在构建期强制规则阻止冲突版本出现。这类问题没有固定解但排查思路是固定的先看异常类型再查冲突来源再做版本剔除。8. 实测中容易忽视的细节连带收获账号体系、歌词文件和播放统计8.1 JWT登录态在音乐网站里的选择音乐网站的用户体系最常用的是JWT无状态方案。用户登录后服务端生成一个Token返回前端前端存到localStorage或者Cookie每次发请求时放到Authorization请求头里SpringBoot拦截器校验Token并从Redis或数据库取用户信息。这套方案的好处是后端不存Session天然适合前后端分离部署配合网关做登录校验也方便。但要注意JWT的密钥一定要放在配置文件里不要硬编码到代码中否则代码泄露时Token还能被伪造。如果做的是纯毕设项目不想引入Spring Security可以自己写一个拦截器继承HandlerInterceptor在preHandle方法里解析Token简单又可控足够覆盖大部分需求。8.2 歌词文件的编码与时间轴很多做音乐网站的人在歌词这块翻过车。歌词文件LRC格式如果直接用中文保存在IDEA里看是中文打包到Linux服务器上运行可能会变成乱码原因几乎都是文件编码问题。建议统一约定歌词文件用UTF-8编码上传读取时也显式用UTF-8解析MySQL表字段用utf8mb4字符集这样能同时兼容歌曲名里生僻字和各种特殊字符。歌词文件本身看起来是纯文本一行一行以[00:30.00]开头前端的歌词插件对时间轴的解析已经有了完整方案我们只需要保证存储和读取不出乱码即可。测试时记得专门用带生僻字的歌名和带日文/韩文的歌词做一遍验证这类边缘情况最容易暴露编码隐患。8.3 播放统计的简单实现想实现每日播放量排行或者最热歌曲不需要引入复杂的埋点系统简单做法是每次播放请求成功时在内存里维护一个计数器并异步把播放记录写入数据库。因为播放的请求量和用户量都不会特别大用AtomicLong加定时任务即可不一定上消息队列。更糊一点的思路是直接用search_record表的方式建一张song_play_count表每次播放就UPDATE song_play_count SET play_count play_count 1 WHERE song_id ?配合定时任务把内存数据刷到数据库搞定。这样后台管理页面的歌曲热度就有真实数据支撑也能给首页推荐提供排序依据。我个人在实际操作中的体会是音乐网站这个项目最值得投入精力的地方不是把歌曲CRUD写得多么花哨而是把上传-存储-播放这条核心链路打磨顺畅再把搜索和歌单这些看似简单但实际很吃经验的功能做好。这里面的文件存储选型、流式传输和版本兼容问题随便挑一个出来都能在面试时聊很久比堆功能有价值得多。动手做的时候不用追求一步到位先跑通最简版本再逐层加硬核能力你会发现这个项目能带给你的成长是实打实的。