
毕业设计选“民谣网站”这个题目说实话一开始我心里是打鼓的。市面上图书管理、商品系统一抓一大把民谣网站听起来小众可真正动手之后你会发现它的内容形态恰恰比那些“标准项目”丰富得多——歌曲、歌手、专辑、评论、收藏每一样都对应着不同的数据模型和交互方式。用Spring Boot做后端接口用HTML5做前端展示与播放最后跑通的这套项目既能覆盖Web开发的核心链路又保留了足够的个性空间。这篇内容就围绕这个项目的设计与实现把我的完整思路和踩坑过程整理出来如果你也在做类似的网站类毕设或者想用Spring Boot加HTML5练手应该能省下不少弯路。1. 把需求说清楚民谣网站的访客、会员与管理员都想要什么1.1 从一条用户旅程倒推出全部功能点做这个项目之前我给自己定的第一件事不是画ER图而是模拟真实用户完整走一遍网站。过程很简单一个人想听民谣打开网站先看到的是推荐歌单和热门歌曲排行随便点开一首开始播放接着想看歌词、看歌手是谁、看别人怎么评价听到喜欢的想收藏想发一条评论之后想按歌手浏览、按模糊的歌名搜索。把这一个流程走完功能清单就基本成形了。最终的功能模块我划分成了六块歌曲模块列表展示、按分类浏览、关键词搜索、歌曲详情页。播放模块基于HTML5 audio原生的播放器支持播放/暂停、切歌、显示歌词。歌手模块歌手主页、歌手名下歌曲列表。用户模块注册、登录、退出。互动模块歌曲收藏、歌曲评论。管理模块歌曲数据维护、歌手信息维护、用户状态管理。这里有一个设计上的小决定我没有给评论做回复功能。原因很简单民谣网站的评论形态接近于“听歌打卡”用户更关心的是“有没有人也在听这首歌”而不是“某个评论下面有没有人回复我”。去掉回复功能评论表的结构就清爽很多前端交互也少了一层渲染负担。这个取舍在答辩时反而成了一个可说的地方——你清楚说明白了为什么不做比什么都往上堆要更有说服力。1.2 管理端该管什么、不该管什么管理端最难的是克制。我见过不少同学一上来就搞RBAC权限、多级菜单、审批流结果答辩时连主流程都没演示完。民谣网站的管理端本质就是内容维护歌曲资料的增删改查歌手信息的维护用户的启用与禁用。我把后台做成了一组独立的页面通过拦截器判断登录状态和角色没有引入Spring Security这类重量级权限框架。具体到后台页面我分了三个标签页歌曲管理、歌手管理、用户管理。歌曲管理里可以上传音频文件、填写歌手ID、修改歌词文本歌手管理可以上传头像、维护简介用户管理只有两个按钮启用和禁用。禁用用户后该用户再登录时会直接被拦截器挡住提示“账号已被禁用”。这个小功能实现成本极低但会让管理员的角色感非常明确。1.3 需求文档里容易被忽视的细节写需求分析时我额外记录了两类容易忽略的内容。第一类是并发场景。比如某一首歌突然火了同时有几百个人播放播放次数怎么更新如果每次播放都去UPDATE一次play_count数据库压力会很大。我的做法是先在前端播放事件触发时调一个异步接口接口里用乐观锁更新失败就重试一次再不行就丢弃。对毕业设计来说这个方案能体现对并发问题的基本认知。第二类是内容安全。用户提交的评论不能直接塞进数据库至少要过滤掉明显的脚本文本。我在后端加了一个简单的词条过滤器对评论内容做了长度限制和非法字符清洗。这块不需要做得像大型社区那么复杂但得有因为真实场景里评论输入是不可信的。2. 选型复盘为什么是Spring Boot加HTML5而不是前后端分离2.1 Spring Boot把“能跑起来”的成本降到了很低我搭环境时最直观的体会是和以前SSH框架时代的对比。那时候要配置Spring、配置Hibernate、配置Struts的XML光框架集成就能折腾两三天。Spring Boot把内嵌Tomcat、默认数据源、依赖版本管理全部交给了自动配置application.yml里写几行配置就能启动工程。对做毕设、做个人项目的开发者来说省下的时间可以全部投入到业务代码本身。依赖这块我选的是非常主流的组合spring-boot-starter-web、mybatis-plus-boot-starter、mysql-connector-java加上lombok减少实体类的样板代码。MyBatis-Plus提供了分页插件和LambdaQueryWrapper写单表查询时几乎不用手写SQL这对开发速度的提升非常明显。2.2 HTML5不是“落后”的代名词它在这个场景里很合适很多同学听到HTML5觉得不够炫非要上Vue、React。但我选择HTML5有实实在在的理由。民谣网站最核心的交互是音频播放HTML5的audio标签原生支持播放、暂停、进度拖拽移动端浏览器还有自己的控制条不需要引入任何第三方播放器插件。语义化标签header、nav、main、section、footer能让页面结构清晰搜索引擎和屏幕阅读器都能正确理解表单验证API可以在不写JavaScript的情况下做必填检测和格式校验localStorage可以记录用户最近播放过的歌曲刷新页面后还在。这些能力都是HTML5标准本身的积累用在内容展示为主的网站上完全够用。你要做的不是“技术最前沿”而是在合理范围内把每个技术选型的价值最大化。2.3 不选前后端分离的真实原因不选前后端分离不是说它不好而是要考虑项目交付的复杂度。前后端分离意味着要维护两套工程要处理跨域问题要在登录鉴权上重新设计一套方案比如Token机制。而Spring Boot内嵌Tomcat之后前端静态资源直接放在classpath的static目录下后端接口和前端页面打包在同一个jar包里部署时一个文件搞定。对单人开发、单机部署的毕业设计来说这种方式极大降低了部署成本。你不需要在服务器上单独装Nginx不需要管理两个进程更不需要担心跨域导致的Session丢失。这也是我最后坚持这个方案的最重要理由——把复杂度控制在你真正需要处理的地方。3. 数据库建模歌曲、歌手、用户这三张核心表怎样设计才不返工3.1 从实体关系到表结构我画ER图时核心实体是四个歌手、歌曲、用户、评论外加一个收藏关联表。这里的关键是一首歌必须属于一个歌手但一个歌手会有多首歌所以在歌曲表里放singer_id外键。用户和歌曲之间是多对多关系用户收藏多首歌、一首歌被多个用户收藏中间用favorite表连接。歌曲表的最终设计如下字段类型说明idbigint主键自增singer_idbigint所属歌手IDnamevarchar(100)歌名albumvarchar(100)专辑名durationint时长单位秒audio_urlvarchar(255)音频文件路径lyrictext歌词文本play_countbigint播放次数statustinyint1上架0下架create_timedatetime创建时间歌手表设计字段类型说明idbigint主键namevarchar(50)歌手名avatarvarchar(255)头像路径regionvarchar(20)地区如大陆、台湾introductiontext歌手简介create_timedatetime创建时间用户表设计字段类型说明idbigint主键usernamevarchar(50)登录名唯一passwordvarchar(64)MD5加盐后的密码nicknamevarchar(50)显示昵称avatarvarchar(255)头像roletinyint0普通用户1管理员statustinyint0正常1禁用create_timedatetime注册时间3.2 收藏表为什么要单独做索引怎么加收藏本质是多对多关系必须用中间表。favorite表字段很简单id、user_id、song_id、create_time。但这里有一个容易被忽略的点——一定要给(user_id, song_id)加联合唯一索引。如果不加这个索引用户对同一首歌连续点两次收藏数据库里就会出现两条重复记录。到时候你不得不在代码里先select一次判断有没有收藏过再决定是insert还是update多一次查询不说逻辑也变得绕。加了联合唯一索引之后重复收藏会被数据库直接拦截代码里捕获一下DuplicateKeyException就能友好处理比如提示“你已收藏过这首歌”。3.3 字段设计里值得注意的取舍密码字段存的是MD5加盐后的哈希值不是明文。盐值我直接用固定的项目字符串拼接用户名虽然不算绝对安全但已经能挡住大部分“拿到数据库后直接用明文登录”的低级风险。角色字段用tinyint存0和1而不是用字符串这样查询和比较都更高效。歌曲时长我直接用int秒数不在数据库里存“04:32”这种格式化字符串格式化交给前端去做。播放次数用bigint而不是int防止某首歌播放次数超过int上限——虽然民谣网站很难有这种量级但习惯性的规范还是要有的。4. 后端实现从启动类到业务接口Spring Boot三层架构怎么写4.1 项目结构与依赖清单后端采用的还是经典的Controller-Service-Mapper三层结构分包如下com.example.folk ├── controller ├── service ├── mapper ├── entity ├── config └── commoncommon放统一返回结果类Result、异常处理类、MD5工具类。config放拦截器注册、静态资源映射配置。pom.xml里依赖不多核心就这几个web、mybatis-plus、mysql驱动、lombok。这里提醒一句MyBatis-Plus的版本不要选太新的选一个稳定版本即可。某个新版本里分页插件需要额外加配置详见后面踩坑部分。4.2 启动类与配置文件启动类是最标准的写法加上MapperScan扫描Mapper接口省去每个Mapper上写Mapper注解SpringBootApplication MapperScan(com.example.folk.mapper) public class FolkApplication { public static void main(String[] args) { SpringApplication.run(FolkApplication.class, args); } }application.yml里核心配置如下server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/folk_music?useUnicodetruecharacterEncodingutf8useSSLfalse username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: truemybatis-plus配置里的map-underscore-to-camel-case必须为true这样数据库的create_time字段才能自动映射到实体的createTime属性否则查询结果里时间字段全是null。4.3 核心接口实现分页查询与关键词搜索歌曲列表是网站流量最大的接口我直接使用MyBatis-Plus的分页功能。先注册分页拦截器Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }然后是Controller和ServiceRestController RequestMapping(/api/song) public class SongController { Resource private SongService songService; GetMapping(/page) public Result page( RequestParam(defaultValue 1) Integer page, RequestParam(defaultValue 10) Integer size, RequestParam(required false) String keyword) { PageSong result songService.pageSongs(page, size, keyword); return Result.ok(result); } }Service实现里的查询逻辑public PageSong pageSongs(Integer page, Integer size, String keyword) { PageSong p new Page(page, size); LambdaQueryWrapperSong wrapper new LambdaQueryWrapper(); if (StringUtils.hasText(keyword)) { wrapper.and(w - w.like(Song::getName, keyword) .or().like(Song::getAlbum, keyword) .or().like(Song::getLyric, keyword)); } wrapper.eq(Song::getStatus, 1) .orderByDesc(Song::getPlayCount); return songMapper.selectPage(p, wrapper); }之所以用wrapper.and再套一层是为了避免or条件把后面的status筛选条件“带偏”。如果直接写wrapper.like(...).or().like(...).eq(Song::getStatus, 1)生成的SQL会是name like ? or album like ? and status ?由于AND优先级高于OR结果会混入status为0的歌曲。这个坑我第一次就踩了必须用and把一组or条件包起来。4.4 登录鉴权与拦截器登录接口的核心逻辑是把用户信息放进SessionPostMapping(/api/login) public Result login(RequestBody LoginDTO dto, HttpSession session) { User user userService.findByUsername(dto.getUsername()); if (user null) { return Result.error(用户不存在); } String pwd MD5Util.md5(dto.getPassword() dto.getUsername()); if (!user.getPassword().equals(pwd)) { return Result.error(密码错误); } if (user.getStatus() 1) { return Result.error(账号已被禁用); } session.setAttribute(loginUser, user); return Result.ok(user); }拦截器里的处理很简单从Session取出loginUser取不到就重定向到登录页public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(); if (session.getAttribute(loginUser) null) { response.sendRedirect(/login.html); return false; } return true; } }这个方案在前后端同源部署时非常顺畅不需要处理跨域、不需要管理Token过期时间。Session失效由容器自动管理开发期只需要在配置里把Session超时时间设置成长一些方便调试。5. 前端HTML5实现语义化标签、原生音频播放与移动端适配5.1 页面骨架与复用方式前端页面我用了最传统、也最稳的方式每个页面都是语义化标签组成的HTML公共部分用简单的模板片段拼接。以歌曲列表页为例结构是这样的header nav ul lia href/首页/a/li lia href/song/list.html歌曲库/a/li lia href/singer/list.html歌手/a/li li iduserArea/li /ul /nav /header main section classfilter-bar input typesearch idkeyword placeholder搜索歌名、专辑、歌词 button idsearchBtn搜索/button /section section classsong-grid idsongGrid !-- 动态渲染 -- /section /main footer民谣网站项目/footer这里用HTML5的header、nav、main、section、footer比全篇div的语义清晰很多。前端动态渲染用原生JavaScript的fetch接口请求后端数据没有引入axios和Vue减少依赖体积也让页面加载更快。5.2 原生audio播放器与播放历史记录播放器是网站的核心。我用了一个固定在底部的播放条不管用户浏览到哪个页面切歌、暂停、下一首都在同一个位置figure classplayer-bar audio idplayer src controls/audio figcaption idcurrentSongLabel未选中歌曲/figcaption /figure点击歌曲列表里的播放按钮时JavaScript会把歌曲的audio_url赋值给audio标签的src然后调用play()方法function playSong(song) { const player document.getElementById(player); player.src song.audioUrl; player.play(); document.getElementById(currentSongLabel).textContent song.name - song.singerName; saveHistory(song); }播放历史用localStorage持久化不需要请求后端function saveHistory(song) { let history JSON.parse(localStorage.getItem(playHistory) || []); history history.filter(item item.id ! song.id); history.unshift(song); if (history.length 20) history.length 20; localStorage.setItem(playHistory, JSON.stringify(history)); }这个交互形式很轻但体验很好。用户下次打开网站时“最近播放”区块直接从这个数组里读出来不用登录也能保留自己的听歌轨迹。5.3 表单验证与移动端适配注册、登录表单全部用HTML5自带的验证属性不写额外JSform idregisterForm input typetext nameusername placeholder用户名 required pattern[a-zA-Z0-9_]{4,16} input typepassword namepassword placeholder密码 required minlength6 input typeemail nameemail placeholder邮箱 required button typesubmit注册/button /formrequired确保必填pattern约束用户名只能包含字母数字下划线且长度4到16位minlength控制密码最短长度email字段会自动校验邮箱格式。这些都是浏览器原生行为不用自己写正则判断和错误提示。移动端适配我用了媒体查询。歌曲列表在PC端是四列网格在窄屏下变成单列.song-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; } media (max-width: 768px) { .song-grid { grid-template-columns: 1fr; } }实测下来手机浏览器的audio原生控制条和整体布局都能正常展示基本不需要写复杂的响应式逻辑。6. 部署上线与排坑记录本地能跑只是第一步6.1 Maven打包与服务器部署项目开发完部署步骤很简单。先在根目录执行mvn clean package -DskipTests执行完成后target目录下会生成folk-0.0.1-SNAPSHOT.jar。把这个jar上传到服务器执行nohup java -jar folk-0.0.1-SNAPSHOT.jar folk.log 21 然后访问服务器的8080端口就能看到网站首页。这里要注意服务器防火墙和安全组策略8080端口必须放通否则外部永远访问不到。我当时在腾讯云上就因为这个卡了半小时本地curl能通换到外部IP就超时。6.2 真实踩坑记录MyBatis-Plus版本导致分页失效第一个坑是MyBatis-Plus版本问题。我在pom里用了3.5.7的较新版本结果分页查询返回的总记录数是0数据也只有一页。排查后发现新版把分页插件的注册方式改了如果没有显式添加PaginationInnerInterceptorselectPage不会真正执行LIMIT。解决方案就是在MyBatisPlusConfig里注册拦截器。这个坑很隐蔽因为接口不报错只是返回数据不对。6.3 真实踩坑记录中文乱码和音频文件404第二个坑是中文乱码。MySQL数据库连接串里如果没有设置characterEncodingutf8从数据库查出来的中文就会变成问号。这个问题在本地开发时偶尔出现部署到Linux服务器后必现。解决方案是在JDBC URL上加上useUnicodetruecharacterEncodingutf8。第三个坑是音频文件404。歌曲音频和歌手头像我放在项目外的本地目录比如/opt/folk/audio但Spring Boot默认只映射classpath/static下的静态资源。后来在WebMvcConfigurer里加了一段资源映射Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/audio/**) .addResourceHandler(/upload/**) .addResourceHandler(file:/opt/folk/audio/); }这样上传音频时保存到/opt/folk/audio前端访问/audio/xxx.mp3时就能正确读取。这个配置在多个环境间需要同步否则换一台服务器就找不到文件。6.4 登录Session丢失的排查过程最后记录一个比较折腾的Session问题。部署后发现用户在登录页登录成功后跳转到首页再点击进入详情页时接口又提示未登录。一开始我以为是Session超时配置问题检查后发现根本不是。真正的原因是端口不一致。本地开发时前端页面通过8080端口访问部署后用Nginx把80端口的请求转发到了8080但转发配置中丢失了Cookie头。浏览器在80端口收到的页面向8080端口发Ajax请求时Cookie不会自动携带过来。解决方案是在Nginx配置里加上proxy_set_header Cookie $http_cookie。这个问题在前后端分离项目里很常见在非分离项目里只要保持同源访问就不会遇到。现在回头再看这个项目最有价值的不是代码量而是把“为什么这么选择”想明白了。Spring Boot加HTML5的组合在内容展示类网站场景中属于配合非常默契的方案。如果你想在这个基础上继续扩展可以考虑接入用户歌单、歌词滚动同步、歌手详情页的图片画廊。但在此之前把本文提到的基础模块跑稳再做加法也不迟。