基于JavaWeb的音乐网站开发实战:Servlet、JSP与MySQL完整案例

发布时间:2026/10/4 6:07:46
基于JavaWeb的音乐网站开发实战:Servlet、JSP与MySQL完整案例 简介一套基于Java Web的小型音乐网站源码包面向Java Web初学者、做课程设计或毕业设计的学生也适合需要快速搭建音乐播放类项目进行二次开发的程序员。项目采用Eclipse、Tomcat、MySQL与JDK作为开发环境war包可直接放入Tomcat运行测试源码内含完整代码与全部jar包SQL脚本也能帮助初始化数据库。包内共881个文件压缩后约189.75MB素材相当丰富80个JSP页面和60个Java源文件构成主要业务逻辑44个jar包解决依赖问题大量png、jpg、gif图片用于界面展示23个MP3文件可直接用于在线播放测试整体结构完整目录清晰便于定位与修改。项目已实现音乐在线播放、下载、分类和排行榜等功能涵盖音乐分类、排行展示、在线播放与下载等常见场景适合理解JavaWeb分层架构、数据库操作以及文件流处理。目前已有915人学习下载适合作为项目模板或课程设计蓝本也方便在此基础上扩展新功能。1. 从零跑通一个 JAVAWEB 音乐网站它解决什么问题适合谁如果你正卡在课程设计或毕业设计上“基于 JAVAWEB 的小型音乐网站”这个题目应该不陌生。它不像商城那样卷商品和订单也不像博客那样只有普通增删改查真正的难点在于文件上传、播放器接入、Session 状态管理这几块恰好能把 Servlet、JSP、AJAX、MySQL 这些 JAVAWEB 核心知识点串成一条线。这个方案适合两类人一是想拿完整案例当简历项目的新手二是带学生做课设的指导老师。我按自己的开发顺序讲选型、建表、写登录、做列表、加播放最后给你一份避坑清单。不只讲“能跑通”还给到可以交给别人部署验收的程度。2. 技术选型与目录设计基于 JAVAWEB 的音乐网站该选哪些组件2.1 JSP Servlet MySQL为什么这是 JavaWeb 项目最经典、最容易讲清楚的组合做基于 JAVAWEB 的小型音乐网站选择传统 Servlet JSP MySQL 不是老顽固而是这个题目的标准答案。Spring Boot 当然也能做但启动类一亮相依赖注入、内嵌 Tomcat、自动配置这些遮住了太多中间环节面试时被问“请求从浏览器到数据库到底经过哪些层”反而是 Servlet 时代的东西更容易讲明白。小型音乐网站核心数据量就几千首歌、几百个用户单台 Tomcat 完全扛得住没有必要为一个 B/S 练习项目引入重量级框架。JSP 负责页面响应Servlet 负责接收请求和调度MySQL 存歌曲和用户信息配合 Bootstrap 和 jQuery 做前端交互够用且边界清晰。小型音乐网站不需要把业务设计得特别复杂五张表足够支撑前端大部分功能用户表、歌曲表、歌单表、歌曲-歌单关联表、评论表。这种规模适合用整表 SQL 建库不搞 Flyway。我一般把建表脚本放在项目的 sql 目录下命名 init.sql方便别人用 Navicat 一键执行。需要提醒的是MySQL 建库时要指定 utf8mb4否则中文评论和中文歌名很容易乱码尤其是 emoji 表情符号utf8 无法存。2.2 在 IDEA 里搭建工程Maven 依赖、目录结构和 Tomcat 配置IDEA 里创建项目时如果使用 Maven Archetype 选 maven-archetype-webapp会自动生成 webapp 目录。这里要注意生成的目录里没有 java 目录需要手动在 src/main 下建 java 目录并右键 Mark Directory as Sources Rootresources 目录也要标记为 Resources Root。很多 IDEA 运行 javaweb 项目配置教程都跳过了这步导致后面写 Servlet 时包名建不了。目录结构我习惯长这样。music-web/ ├── pom.xml └── src/main/ ├── java/ │ └── com/example/music/ │ ├── controller/ # Servlet 控制器 │ ├── dao/ # JDBC 数据访问 │ ├── model/ # 实体类 │ ├── filter/ # 编码/login 过滤器 │ └── util/ # DB工具、加密工具 ├── resources/ │ ├── db.properties │ └── log4j.properties └── webapp/ ├── static/ │ ├── css/ │ ├── js/ │ └── music/ # 上传的 mp3 文件 ├── WEB-INF/ │ ├── web.xml │ └── views/ # JSP 页面 └── index.jsp为什么要把 JSP 放在 WEB-INF/views 下而不是直接放 webapp 根目录放在 WEB-INF 里浏览器不能直接通过 URL 访问所有页面跳转都必须经过 Servlet 再 forward 进去。这样能保证登录校验 Filter 先执行别人不能绕过登录直接拼 URL 看页面。这个习惯在做登录类网站时很有价值。只用 Servlet 和 JSP如果不引入 Maven手写 lib 太痛苦现在大部分人的工作环境都用 IDEAMaven 是标配。创建一个 maven webapp 项目pom.xml 里只需要四件事servlet-api、jsp-api、jstl、mysql-connector-java。注意 servlet-api 和 jsp-api 由 Tomcat 提供打包时要用 provided 作用域不能打进 war否则和容器自带的类冲突mysql 驱动要选和你本机版本匹配的 jar5.x 和 8.x 的驱动类名不一样连接 URL 也不一样。完整的 pom 如下。project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion groupIdcom.example/groupId artifactIdmusic-web/artifactId packagingwar/packaging version1.0-SNAPSHOT/version properties maven.compiler.source8/maven.compiler.source maven.compiler.target8/maven.compiler.target /properties dependencies dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency dependency groupIdjavax.servlet.jsp/groupId artifactIdjavax.servlet.jsp-api/artifactId version2.3.3/version scopeprovided/scope /dependency dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency /dependencies /project这段代码里servlet-api 用 provided 是比较多人忽略的细节。如果用了默认 compile 作用域Tomcat 启动时可能出现类冲突最明显的表现是启动期间 NoSuchMethodError。jstl 用 1.2 就够了注意 JSP 页面里要写 taglib 指令否则c:forEach无法解析。mysql-connector-java 8.x 会要求加载 com.mysql.cj.jdbc.Driver而不像 5.x 用 com.mysql.jdbc.Driver。2.3 数据库表设计用户、歌曲、歌单和评论的最小模型小型音乐网站不需要把表拆得太散五张表足够支撑前端大部分功能用户表、歌曲表、歌单表、歌曲-歌单关联表、评论表。这种规模适合用整表 SQL 建库不搞 Flyway。我一般把建表脚本放在项目的 sql 目录下命名 init.sql方便别人用 Navicat 一键执行。需要提醒的是MySQL 建库时要指定 utf8mb4否则中文评论和中文歌名很容易乱码尤其是 emoji 表情符号utf8 无法存。CREATE DATABASE IF NOT EXISTS music_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE music_db; CREATE TABLE user ( id int(11) NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL, password varchar(64) NOT NULL COMMENT SHA-256后密文, salt varchar(32) NOT NULL COMMENT 加密盐, avatar varchar(255) DEFAULT NULL, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE song ( id int(11) NOT NULL AUTO_INCREMENT, name varchar(100) NOT NULL, singer varchar(100) NOT NULL, album varchar(100) DEFAULT NULL, type varchar(30) DEFAULT pop COMMENT pop/rock/classical..., duration int(11) DEFAULT NULL COMMENT 时长, 单位秒, file_path varchar(255) NOT NULL COMMENT mp3相对路径, play_count int(11) NOT NULL DEFAULT 0, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE playlist ( id int(11) NOT NULL AUTO_INCREMENT, user_id int(11) NOT NULL, name varchar(100) NOT NULL, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE playlist_song ( playlist_id int(11) NOT NULL, song_id int(11) NOT NULL, add_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (playlist_id,song_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE comment ( id int(11) NOT NULL AUTO_INCREMENT, song_id int(11) NOT NULL, user_id int(11) NOT NULL, content varchar(500) NOT NULL, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_song (song_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这几张表的关键在于 password 和 salt 分开存绝对不要出现明文密码play_count 用 int 而不是 varchar方便排序comment.content 用 varchar(500)虽然页面上可以多行输入但数据库层限制长度能防止一条评论把整页撑破。还有一个小细节song.file_path 存的是相对路径比如 /static/music/song_20240101_001.mp3不要存绝对路径。绝对路径一旦项目迁移到服务器路径全乱相对路径配合 getServletContext().getRealPath() 去解析到了哪个环境都是对的。这一点在 javaweb 项目完整案例 mysql 里经常被当成亮点写实际是个最基本的选型。为什么歌单关联表要用主键 (playlist_id, song_id)这样同一首歌不能重复添加进同一歌单省了应用层再去判断重复后续要扩展收藏数、播放量也不需要动这张表的结构。字段和数据不要完全照抄别人笔记里的建表语句网上流传的 JavaWeb 讲义里有的案例会把字段写成 user_name而代码里用的是 username结果跑起来一直报 Column not found。你自己定义字段时保证数据库列名和实体类 getter 对应即可。3. 从登录到播放核心功能的实现顺序与关键代码3.1 用 Servlet 写登录注册JDBC、Session 与密码加密先讲注册。常见做法是做一个 UserDao里面封装数据库操作Servlet 里只做参数校验和流程控制。密码处理上我不推荐用 MD5虽然旧教程到处都是 MD5但在简历里写 MD5 容易被追问“为什么不用加盐”直接用 SHA-256 加盐代码不多安全上比裸 MD5 好一截。写一个 PasswordUtil注册时生成随机盐登录时拿盐再算一次摘要比对。package com.example.music.util; import java.security.MessageDigest; import java.security.SecureRandom; public class PasswordUtil { private static final char[] HEX 0123456789abcdef.toCharArray(); // 生成 16 字节随机盐转成 hex 字符串 public static String generateSalt() { byte[] bytes new byte[16]; new SecureRandom().nextBytes(bytes); return toHex(bytes); } // SHA-256(salt rawPassword) 两次摘要 public static String hash(String rawPassword, String salt) { try { MessageDigest md MessageDigest.getInstance(SHA-256); String input salt rawPassword; byte[] digest md.digest(input.getBytes(UTF-8)); digest md.digest(digest); // 再来一次增加彩虹表成本 return toHex(digest); } catch (Exception e) { throw new RuntimeException(Password hash failed, e); } } private static String toHex(byte[] bytes) { StringBuilder sb new StringBuilder(bytes.length * 2); for (byte b : bytes) { sb.append(HEX[(b 4) 0x0F]).append(HEX[b 0x0F]); } return sb.toString(); } }这段代码把盐写到 16 字节 128 位安全强度对课设和简历项目足够。hash() 里做两次 SHA-256是为了防止有人拿简单的彩虹表直接撞如果你愿意可以用 BCrypt 依赖替代但那样要去了解 Spring Security 全家桶对基于 JAVAWEB 的小型项目来说这个自研工具类更直接。然后是 LoginServlet。登录接口我已经习惯用 JSON 返回前端 jQuery 发起 ajax 请求而不是表单刷新页面。因为音乐网站首页可能有播放器正在放歌整页刷新会把正在播的音频断掉局部登录能保留播放状态。Servlet 代码如下package com.example.music.controller; import com.example.music.dao.UserDao; import com.example.music.model.User; import com.example.music.util.PasswordUtil; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.*; import java.io.IOException; WebServlet(/login) public class LoginServlet extends HttpServlet { Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { resp.setContentType(application/json;charsetutf-8); String username req.getParameter(username); String password req.getParameter(password); UserDao dao new UserDao(); User user dao.findByUsername(username); if (user null || !user.getPassword().equals(PasswordUtil.hash(password, user.getSalt()))) { resp.getWriter().write({\success\:false,\msg\:\用户名或密码错误\}); return; } HttpSession session req.getSession(); session.setAttribute(userId, user.getId()); session.setAttribute(nickname, user.getUsername()); resp.getWriter().write({\success\:true,\msg\:\登录成功\}); } }注意这里我不建议把整个 User 对象塞进 Session只放 userId 和 nickname 就够。因为你可能之后在页面展示头像、邮箱等字段如果塞了整个对象用户修改资料后旧 Session 里的数据还是旧的容易出现“改了头像页面还是老样子”的怪问题。每次请求按 userId 从数据库查最新数据才是通用做法。硬要说效率几千用户的系统里这样做没有任何压力。3.2 歌曲列表与搜索JDBC 查询、分页和模糊匹配歌曲列表是音乐网站的门面不能一页全倒出来。常见做法是封装 PageBeanDao 层用 LIMIT 做分页。参数有 pageNum、pageSize、keyword。下面这段是 SongDao 的分页查询代码。package com.example.music.dao; import com.example.music.model.Song; import com.example.music.util.DBUtil; import java.sql.*; import java.util.ArrayList; import java.util.List; public class SongDao { public ListSong search(String keyword, int pageNum, int pageSize) { ListSong list new ArrayList(); String baseSql SELECT id,name,singer,album,type,duration,play_count FROM song ; if (keyword ! null !keyword.trim().isEmpty()) { baseSql WHERE name LIKE ? OR singer LIKE ? ; } baseSql ORDER BY play_count DESC, id DESC LIMIT ? OFFSET ?; int offset (pageNum - 1) * pageSize; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(baseSql)) { int idx 1; if (keyword ! null !keyword.trim().isEmpty()) { String like % keyword.trim() %; ps.setString(idx, like); ps.setString(idx, like); } ps.setInt(idx, pageSize); ps.setInt(idx, offset); ResultSet rs ps.executeQuery(); while (rs.next()) { Song s new Song(); s.setId(rs.getInt(id)); s.setName(rs.getString(name)); s.setSinger(rs.getString(singer)); s.setDuration(rs.getInt(duration)); s.setPlayCount(rs.getInt(play_count)); list.add(s); } } catch (SQLException e) { throw new RuntimeException(查询歌曲失败, e); } return list; } }分页为什么用 OFFSET 而不是 LIMIT (offset, pageSize)两种写法都能用但 OFFSET 在 MySQL 8 里更清晰且 PreparedStatement 占位符更直观。这里最容易被忽略的是 LIKE 里的 % 必须拼在占位符参数里而不是拼进 SQL 模板。如果写成 WHERE name LIKE %?%PreparedStatement 会把问号当普通字符处理查出来永远是空。另外一个常见坑是 ORDER BY play_count DESC 会让播放量为 0 的新歌沉底演示时如果刚插入歌曲看不到会以为功能坏了。我一般会先给测试数据补一些 play_count 随机值比如 UPDATE song SET play_count FLOOR(RAND()*1000)让列表看起来有真实感。对应 Servlet 端只需要读取 pageNum、pageSize、keyword 参数在请求里做默认值。pageNum 小于 1 的时候要强制赋 1pageSize 超过 50 要截断否则用户手动改 URL 参数可以把整张表一次拉出来。这些边界很影响简历里的代码质量印象。3.3 在线播放与歌单管理前端 audio 标签和后端接口怎么配合列表页面用audio标签在线播放是前端的事但“播了一次要计数”必须走后端。常见做法是播放器 onplay 事件发生时调一个 /song/play?idxxx 的接口把 play_count1。这里要控制频率否则用户拖动进度条会触发多次 onplay计数会虚高。我用一个很简单的节流记录 lastPlay 时间5 秒内重复调用直接忽略。let lastReportTime 0; function reportPlay(songId) { const now Date.now(); if (now - lastReportTime 5000) return; lastReportTime now; $.ajax({ url: /song/play, method: POST, data: { id: songId }, success: function (res) { if (!res.success) console.error(上报失败); } }); } // audio 的 onplay 绑定示例 audio.addEventListener(play, function () { reportPlay(currentSongId); });这个接口对应的 Servlet 不需要返回页面只返回 JSON所以体积很小先 UPDATE song SET play_count play_count 1 WHERE id?再查一次最新播放量返回给页面用于更新列表中的播放次数。还可以顺手把这个 User-Song 的播放记录写到一张 user_song 表用于之后的推荐这里先不展开。歌单管理更简单把歌加入歌单的 URL 是 /playlist/addSong传 playlistId 和 songId。因为主键是两列Dao 里 insert 时用 INSERT IGNORE 就可以避免重复主键报错String sql INSERT IGNORE INTO playlist_song (playlist_id, song_id) VALUES (?, ?);前端就一个按钮点击后直接调这个接口弹出“已加入歌单”。不需要事务因为失败了 INSERT IGNORE 也不报错。真正需要事务的是“创建歌单并在同一个操作里把歌曲加进去”那种场景我会在 Service 层用 Connection 的 setAutoCommit(false) 包一层。后端还有个绕不开的接口音频文件的输出。如果 mp3 文件就放在 webapp/static/music 目录下浏览器可以直接访问 URL不需要 Servlet 转发但如果想控制权限比如只有登录用户能听需要写一个 SongStreamServlet 把文件以流的方式输出。常见实现是拿 song.file_path 拼上项目路径用 FileInputStream 写入 response.getOutputStream()。要注意设置 Content-Type 为 audio/mpeg并支持 Range 头否则播放器拖动进度条时会重新从头开始。Range 头的处理有点繁琐如果只是演示可以不写写的话要解析 req.getHeader(Range) 里的 start 位置再跳流。4. JavaWeb 项目避坑指南部署、乱码、路径和并发问题排查4.1 IDEA 运行 JavaWeb 项目的三个配置误区现象一点了 Run 之后 Tomcat 启动了浏览器却 404项目页面打不开。原因多半是 Run Configuration 里的 Deployment 没有添加 Artifact或者 Deploy at server startup 没有勾选。IDEA 运行 JavaWeb 项目配置和普通 Java 程序不一样它先把 war exploded 丢到 Tomcat 的部署目录再启动容器。点击右上角 Edit Configurations在 Deployment 里加 music-web:war exploded并把 Application context 改为 /music。这样访问路径才是 http://localhost:8080/music/。现象二修改 JSP 或 Java 代码后刷新页面还是旧内容。原因是 Tomcat 默认不会自动编译改动后的 Java 类JSP 改了也可能不生效。解决在配置里勾选 On frame deactivation: Update classes and resources如果改了 Java 文件直接按 CtrlF10 重新编译还不行就 restart。这个坑会浪费大量时间尤其是你刚把 IDEA 运行 JavaWeb 项目配置照着视频敲完以为项目坏了。现象三控制台打印一堆问号和乱码。原因有三层Tomcat 的 logging.properties 编码、IDEA 的 Console encoding、项目页面编码。我在 4.2 详细讲这里先说最快的检查Help - Edit Custom VM Options 里加 -Dfile.encodingUTF-8然后重启 IDEA。这个操作能解决大部分 Windows 上的乱码。注意这三个现象经常连着发生建议按照上面的顺序排查。4.2 数据库中文乱码的排查顺序现象页面显示中文正常插入数据库后变成问号或者页面本身全是乱码。原因往往是字符集在“浏览器 → JSP → Servlet → JDBC → MySQL”的某一环没统一。排查顺序我习惯从后往前先看数据库表字符集执行 SHOW CREATE TABLE song确认 CHARSET 是 utf8mb4不是 latin1再看连接地址 jdbc:mysql://localhost:3306/music_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseUnicode 和 characterEncoding 缺一不可MySQL 8 还要带 serverTimezone。然后是 JSP 页面第一行要写 % page contentTypetext/html;charsetUTF-8 pageEncodingUTF-8 %并且在 web.xml 里配置 CharacterEncodingFilter。为什么不用 request.setCharacterEncoding 逐段写因为一个 Servlet 只能管自己Filter 能管全局。下面是最小编码过滤器。package com.example.music.filter; import javax.servlet.*; import javax.servlet.annotation.WebFilter; import java.io.IOException; WebFilter(/*) public class EncodingFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { request.setCharacterEncoding(UTF-8); response.setCharacterEncoding(UTF-8); response.setContentType(text/html;charsetUTF-8); chain.doFilter(request, response); } }这个过滤器必须注册在所有的 Servlet 之前WebFilter 默认顺序由类名决定如果有多个过滤器建议在 web.xml 里显式声明并配置顺序。注意 response.setContentType 放在这里会让所有接口都变成 text/html如果后面有 JSON 接口Servlet 自己再覆盖一次 Content-Type 就行问题不大。如果在 Filter 里设置了编码数据库端还是乱码下一步直接看 MySQL 服务端变量 character_set_server 是不是 latin1是的话需要改 my.ini 并重启 MySQL。最后还有一种可能你连接 URL 里只写了 characterEncodingutf8没允许 utf8mb4那么 emoji 和特殊符号依然会丢。网上流传很广的 JavaWeb 讲义里的数据表和字段也各不相同如果按别人笔记里的数据导入后查询乱码不要急着改代码先检查导入时的字符集是不是 utf8mb4很多时候是 SQL 文件编码的问题。4.3 路径永远不对URL 和文件上传的坑现象页面加载了但 CSS、JS、图片全部没有样式控制台显示 Failed to load resource: 404或者表单提交后跳转到一个不存在的页面。原因在 JSP 里用了相对路径 srccss/style.css当前浏览器 URL 是 /music/list它会把 CSS 解析成 /music/css/style.css实际资源在 /music/static/css/style.css自然找不到。解决方法是所有静态资源和链接前缀都写 ${pageContext.request.contextPath}也就是项目的绝对上下文路径。比如 href${ctx}/static/css/style.css relstylesheet/ 。我一般会在 JSP 顶部用 c:set varctx value${pageContext.request.contextPath} /后面写 ${ctx}。对前端 ajax 也一样url 写成 ctx /song/play不要写 /song/play因为将来如果部署到 Tomcat 的 webapps 下项目名可能不是 /music写死就废了。文件上传的路径坑更隐蔽。上传 mp3 到 webapp/static/music 下开发环境没问题因为 IDEA 会把你上传的文件写进 target 的部署目录但每次 Redeploy 的时候Tomcat 会把项目目录删掉重建你上传的文件全没了。所以生产环境不应该把上传文件放 webapp 里应该放到 Tomcat 之外的独立目录比如 /data/music_files然后通过 Tomcat 的虚拟路径映射或者写一个文件流 Servlet 对外提供访问。我这边的做法是本地开发放到 target 里图省事服务器上用 Nginx 映射 /static/music 到 /data/music_files。这算是一条血泪经验。4.4 本地好好的一上服务器就翻车Session 和并发连接排查现象本地用 IDEA 跑一切正常部署到云服务器后用户登录没几分钟就掉线或者某个页面转圈很久才打开。原因分两块Session 超时和数据库连接。Tomcat 默认 session 超时是 30 分钟很多人没改如果用户停留在播放页面超过 30 分钟然后点收藏请求里的 JSESSIONID 已经失效服务器开了新会话页面跳回登录页。解决在 web.xml 里设置 120 并且前端要捕获 401 状态跳转登录。但更关键的是Session 只存在单个 Tomcat 内存里如果服务器上面挂了多实例Session 会随机丢失。小型网站不必做负载均衡真要做就别依赖 Servlet Session改成 token 方案。另外数据库连接这块很多人写 DBUtil 每次 getConnection 都用 DriverManager.getConnection又在 finally 里 close看起来没毛病但并发一上来 MySQL 连接数会瞬间耗尽。常见做法是配置一个简单的连接池。没有 Spring 的情况下可以用 DBCP 或 HikariCPHikariCP 需要引入依赖并且初始化 DataSource代码量不大。我在项目里最常见到的问题是“忘记关闭 PreparedStatement”或“ResultSet 在返回前被关闭”导致偶发 No operations allowed after connection closed。解决是确保 ResultSet 先关了再 close 上层用 try-with-resources 能省掉这些。5. 把项目从“能跑”做成“完整案例”过滤链、推荐和管理后台5.1 用 Filter 统一处理编码和登录校验上一章给的 EncodingFilter 已经解决了全局编码。但完整案例还需要一个 LoginFilter除了 /login、/register、/static、/index 这些允许匿名访问的路径其余请求都要检查 Session 里有没有 userId。没有就返回登录页或者 JSON 提示。Servlet 规范里 Filter 匹配 /* 会把静态资源也拦下来要在 doFilter 里排除关键字。package com.example.music.filter; import javax.servlet.*; import javax.servlet.annotation.WebFilter; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; WebFilter(/*) public class LoginFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; String uri req.getRequestURI(); if (uri.endsWith(/login) || uri.endsWith(/register) || uri.contains(/static/) || uri.endsWith(index.jsp)) { chain.doFilter(request, response); return; } HttpSession session req.getSession(false); if (session null || session.getAttribute(userId) null) { if (XMLHttpRequest.equals(req.getHeader(X-Requested-With))) { resp.setContentType(application/json;charsetutf-8); resp.getWriter().write({\success\:false,\code\:401,\msg\:\未登录\}); } else { resp.sendRedirect(req.getContextPath() /index.jsp); } return; } chain.doFilter(request, response); } }这个 Filter 最关键的一个写法是 req.getSession(false)。如果用 req.getSession()它会给每个未登录请求都创建一个空 Session浪费内存的同时也掩盖了 401 判断。排除条件里 uri.contains(/static/) 放行是因为图片、CSS、JS 不应该被登录拦截如果你把歌单页面放在 /playlist 下那它就必须登录。两个 Filter 都要在 web.xml 里注册时注意顺序EncodingFilter 在前LoginFilter 在后。顺序反了LoginFilter 返回的 JSON 没有设置编码中文会变成乱码。在 WebFilter 注解里无法指定顺序所以我建议统一在 web.xml 中配置这样更直观。5.2 做一个小推荐模块基于播放次数和标签的“猜你喜欢”很多简历项目都会写“推荐算法”但真正能讲清楚的人不多。小型音乐网站最合适的推荐不是协同过滤而是规则加权找同类型里播放次数高、但你还没听过的歌。这是最简单、也最能在面试时讲明白的版本。SQL 大概是SELECT id, name, singer, type, play_count FROM song WHERE type (SELECT type FROM song WHERE id #{songId}) AND id ! #{songId} ORDER BY play_count DESC LIMIT 6;细看你就会发现这个 SQL 不需要算向量也不涉及矩阵只是利用了“相同标签的用户可能喜欢相似音乐”这一假设。如果同类型的歌不足 6 首就补全站热门歌曲用两个查询组装。这个推荐模块的定位可以叫“给用户推荐同风格热门歌曲”而不是写一个完整的协同过滤因为对几千首歌的库协同过滤要维护用户-歌曲矩阵内存占用不大但代码量会吓跑新手。真正让推荐效果“看起来好”的是日志里要记录每首歌的播放次数和用户点击行为。我一般会在 user_song 表里记录 user_id、song_id、play_time每播放一次插一行当作埋点数据。推荐模块就基于这首歌的 type 和整个平台最受欢迎的歌曲做加权如果要更细还可以加入“用户近期播放最多的歌手”作为偏好。这不算复杂的算法但足以撑起面试里关于“简单推荐是怎么做出来的”。5.3 加入管理员上传歌曲后端接收文件与重命名后台管理系统可以单独做一个 admin 前缀的页面普通用户无权访问。管理员上传歌曲用 FormData 上传到 /admin/song/upload后端拿到文件后先做校验文件扩展名必须 .mp3大小不超过 8MB避免用户塞一个视频或者其他东西进来。文件名的处理不能直接使用用户上传的原文件名原因有两点中文文件名在不同浏览器上 URL 编码可能不一致可能引起播放乱码如果重名会覆盖已有资源。常见做法是用时间戳拼接随机数music_20240101_1200_1234.mp3。// UploadServlet 核心片段 String saveDir req.getServletContext().getRealPath(/static/music); File dir new File(saveDir); if (!dir.exists()) { dir.mkdirs(); } Part filePart req.getPart(file); String submittedFileName filePart.getSubmittedFileName(); String ext submittedFileName.substring(submittedFileName.lastIndexOf(.)); if (!.mp3.equalsIgnoreCase(ext)) { resp.getWriter().write({\success\:false,\msg\:\仅支持mp3\}); return; } String fileName music_ System.currentTimeMillis() _ (int)(Math.random()*1000) ext; filePart.write(saveDir File.separator fileName);上面的代码有两点值得说明filePart.getSubmittedFileName() 在 Servlet 3.1 接口里有如果你用的 Tomcat 8.5 以下可能没有这个方法要么升级 Tomcat要么从 Content-Disposition 头里自己解析filePart.write 是 Part 接口的方法直接写文件不需要手动关闭 InputStream。校验大小不能等 write 完才发现太大可以先用 filePart.getSize() 判断超过 8MB 直接返回错误这样避免写一半磁盘爆了。管理员上传后还要向 song 表插入一条记录主要字段就是上面建表时设计的 name、singer、album、type、duration、file_path。duration 在服务端拿不到可以前端读取音频元数据后随表单一起提交或者在播放页面加载时懒更新。我通常会在页面上放一个隐藏的audio自动读取 duration赋值给输入框用户不需要手动填。6. 验证与进阶从能跑通到拿得出手6.1 一个验收清单交付给别人之前我会按这个清单过一遍。功能层面注册后能登录登录后刷新不丢歌曲列表分页能翻页搜索关键词能命中歌名和歌手点击播放能出声切歌不刷新页面创建歌单、添加歌曲、查看歌单歌曲管理员登录后能上传 mp3 并立即在列表看到。工程层面页面编码统一 utf-8控制台和数据库无乱码所有 URL 带上下文路径不写死 /music上传文件不因重新部署丢失Session 超时设置明确sql/init.sql 能在干净的 MySQL 上直接执行。6.2 可以继续做的三个方向如果你想把这个课程设计升级成简历里的亮点第一个方向是给 Session 换成 token 登录前端把 token 存 localStorage请求头带 Authorization。这样 Session 的负载均衡问题就消失了也为以后接 App 接口打了基础。第二个方向是把 JDBC DAO 层逐步替换成 MyBatis只换数据访问Controller 和页面不动风险小且能体现出你有迁移框架的能力。第三个方向是给音乐列表加 Redis 缓存歌曲信息变化不频繁缓存热榜数据能让首页打开速度提高一大截面试也能聊缓存穿透和一致性。6.3 我习惯在收尾前做的三件事每次我以为项目做完了都会先做三件收尾用浏览器无痕模式重走一遍主流程避免 Session 残留造成“自己跑得通、别人跑不通”的假象把数据库脚本用一个新的空库执行一遍确保没有漏了字段最后写一个 README把 JDK、Tomcat、MySQL 版本和启动步骤写明。这第三个行为帮我解决过无数次“三个月后自己都看不懂项目”的尴尬。做 JAVAWEB 项目这件事最大的教训不是技术不会而是本地环境和部署环境差异。希望这份踩坑清单能帮到你少几次为路径和编码熬夜。本文还有配套的精品资源点击获取