Java Web小说网站项目实战:Servlet+JSP+MySQL从源码到部署全解析
简介这是一份基于Java Web技术栈开发的网络在线小说网站完整项目源码面向Java Web课程设计、毕业设计以及入门进阶学习者重点解决从零搭建在线小说阅读平台时的业务与代码实现问题。项目完整覆盖小说搜索、分类浏览、章节阅读和文件下载等典型功能可帮助读者理解服务端处理流程、数据库交互方式以及前端页面与后端逻辑的整合方法。压缩包共包含168个文件整体大小约16.82兆字节其中Java源文件46个JSP页面28个HTML页面20个XML配置文件12个JavaScript脚本12个CSS样式表8个并配有多张图片、字体和Git忽略文件等辅助资源Java文件负责业务逻辑JSP与HTML构建页面结构XML用于参数配置JS和CSS完善界面交互与视觉效果。当前已有414人学习或下载此项目代码结构清晰、关键位置注释详尽既可直接导入开发工具运行验证也适合在毕业设计或功能扩展中作为基础模板进行二次开发。1. 这个 Java Web 小说网站项目到底能让你拿到什么很多人在找“基于Java Web的网络在线小说网站开发项目设计源码”时其实不是想要一个能跑的 Demo而是想要一个能交差、能答辩、能扩展的完整骨架。这类项目的核心价值在于它把 Java Web 技术栈里最常用的东西——Servlet 生命周期、JSP 页面渲染、JDBC 数据库访问、Session 会话管理、Filter 过滤器、分页查询——全部串在了一个具体业务里。你把这个项目跑通了等于把 Java Web 的主干过了一遍。这个项目适合三类人准备 Java Web 课程设计或毕业设计的学生需要一份能二次开发的“java课程设计案例源码”作为起点的初学者以及想快速搭一个内容管理型站点练手的前后端分离之前的传统开发者。它的业务模型并不复杂——用户注册登录、小说分类展示、搜索、章节阅读、书架收藏——但每一个模块都对应着 Java Web 面试和考试里绕不开的知识点。与其背十篇 Servlet 教程不如把一个完整的在线小说网站的源码拆开搞清楚每一行代码为什么存在。接下来我会从技术选型开始把数据库设计、核心代码实现、典型踩坑逐个讲透。你可以直接照着复现也可以拿它当底子改成别的内容站。2. 技术选型为什么这个项目用 JSP Servlet 而不是 Spring Boot2.1 JSP Servlet 方案在课程设计里的真实地位现在一搜 Java Web 项目铺天盖地都是 Spring Boot MyBatis Plus Vue 前后端分离。但如果你打开自己的课程要求会发现很多题目里写的还是“基于 Java Web”用的教材还是老牌的 Servlet JSP 路线。这不是教材落后而是课程设计想要验证的恰恰是你对 Web 底层机制的理解请求怎么进来、Servlet 怎么被实例化、请求参数怎么获取、响应怎么回写、Session 怎么维持。常见做法是使用 JSP Servlet JDBC MySQL部署在 Tomcat 上。这个组合的好处是“所见即所得”——JSP 里可以直接写 Java 代码片段也能用 JSTL 标签调试的时候你能清楚地看到每一个环节。而如果直接用 Spring Boot很多细节被框架封装掉了答辩时老师问你“Servlet 是什么”你可能答不上来。我一般会建议如果你的课程设计明确写了“Java Web”而没有指定框架优先选 JSP Servlet。如果老师允许 Spring Boot那可以换成 Spring Boot Thymeleaf但数据库操作部分还是建议用 JDBC 或者 MyBatis别一上来就 JPA否则你讲不清楚。2.2 项目目录结构源码包应该长什么样一个标准的 Java Web 小说网站项目非 Maven 结构通常长这样NovelWeb/ ├── src/ │ └── com/ │ └── novel/ │ ├── bean/ # JavaBeanUser.java, Novel.java, Chapter.java, Shelf.java │ ├── dao/ # 数据访问UserDao.java, NovelDao.java, ChapterDao.java │ ├── servlet/ # 控制器LoginServlet.java, RegisterServlet.java, NovelListServlet.java │ ├── filter/ # 过滤器EncodingFilter.java, LoginFilter.java │ └── util/ # 工具类DBUtil.java, PageBean.java ├── WebContent/ │ ├── index.jsp # 首页 │ ├── login.jsp # 登录页 │ ├── register.jsp # 注册页 │ ├── novel/ │ │ ├── list.jsp # 小说列表带分页 │ │ ├── detail.jsp # 小说详情/章节列表 │ │ └── read.jsp # 阅读页 │ ├── shelf/ │ │ └── shelf.jsp # 我的书架 │ ├── css/ │ ├── js/ │ ├── images/ │ └── WEB-INF/ │ ├── web.xml # 部署描述符 │ └── lib/ # mysql-connector-java.jar 等 └── novel.sql # 建库建表脚本如果你拿到手的是一个 Maven 项目src/main/java 和 src/main/webapp 分别对应上面的 src 和 WebContent本质不变。这个结构的关键点在于分层bean 对应数据库表的一行记录dao 负责 SQL 操作servlet 接收请求并调用 daoJSP 只做展示。千万不要把 JDBC 代码直接写进 JSP 里那是早期 JSP 开发最大的坑后期维护会非常痛苦。2.3 依赖与环境版本的选择这个项目对外部依赖的要求很低。核心就是两个 JAR 包MySQL 驱动mysql-connector-java和 JSTL 标签库如果 JSP 里要用 c:forEach 这类标签。另外如果你用了文件上传还需要 commons-fileupload。版本选择上有个常见误区MySQL 8.x 的驱动类名是com.mysql.cj.jdbc.Driver而 MySQL 5.x 是com.mysql.jdbc.DriverURL 里也需要加serverTimezoneAsia/Shanghai和useSSLfalse参数。很多人拿旧版源码配新版 MySQL结果启动直接报错。Tomcat 版本建议用 8.5 或 9.0对应的 JDK 是 8 或 11。JDK 17 跑老项目有时会有反射访问报错不推荐。3. 数据库设计小说网站的核心是表和表之间的关系3.1 四张核心业务表的设计与字段选择数据库设计决定了这个项目能写到什么程度。最少需要四张表用户表t_user、小说表t_novel、章节表t_chapter、书架表t_shelf。先用一段 SQL 把整个设计建出来。CREATE DATABASE IF NOT EXISTS novel_db DEFAULT CHARSET utf8mb4; USE novel_db; CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT 用户ID, username VARCHAR(50) NOT NULL UNIQUE COMMENT 用户名, password VARCHAR(64) NOT NULL COMMENT MD5密码, nickname VARCHAR(50) DEFAULT COMMENT 昵称, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 注册时间 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_novel ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL COMMENT 书名, author VARCHAR(50) NOT NULL DEFAULT COMMENT 作者, category VARCHAR(20) NOT NULL DEFAULT 玄幻 COMMENT 分类, intro TEXT COMMENT 小说简介, cover VARCHAR(255) DEFAULT COMMENT 封面图路径, chapter_count INT DEFAULT 0 COMMENT 总章节数, click_count INT DEFAULT 0 COMMENT 点击量, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_category (category), INDEX idx_click (click_count) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT小说表; CREATE TABLE t_chapter ( id INT PRIMARY KEY AUTO_INCREMENT, novel_id INT NOT NULL COMMENT 所属小说ID, chapter_name VARCHAR(200) NOT NULL COMMENT 章节名, content MEDIUMTEXT NOT NULL COMMENT 章节正文, sort_no INT DEFAULT 1 COMMENT 章节排序号, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_novel_sort (novel_id, sort_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT章节表; CREATE TABLE t_shelf ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, novel_id INT NOT NULL, add_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_novel (user_id, novel_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT书架表;这段 SQL 里几个细节值得注意。章节表没有设置外键约束而是通过INDEX idx_novel_sort (novel_id, sort_no)建了联合索引——课程设计阶段用逻辑关联比物理外键更灵活删除小说时不用处理外键约束省掉很多麻烦。内容字段用 MEDIUMTEXT 是因为小说一章的文本量通常在几千到几万字TEXT 最多存 64KB遇到长章节会被截断。书架表加了一个联合唯一键uk_user_novel这从数据库层面防止了用户重复收藏同一本书。如果你不加这个约束代码里就得先查一次再插入多一次数据库往返。3.2 JDBC 工具类与连接参数这是源码里第一个要看的地方拿到源码后第一个打开的文件应该是 DBUtil.java。数据库连接配置决定你能不能跑起来。下面是很常见的一个写法package com.novel.util; import java.sql.Connection; import java.sql.DriverManager; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; public class DBUtil { private static final String DRIVER com.mysql.cj.jdbc.Driver; private static final String URL jdbc:mysql://localhost:3306/novel_db ?useUnicodetruecharacterEncodingutf8 serverTimezoneAsia/ShanghaiuseSSLfalse; private static final String USERNAME root; private static final String PASSWORD 123456; static { try { Class.forName(DRIVER); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USERNAME, PASSWORD); } public static void close(Connection conn, Statement stmt, ResultSet rs) { try { if (rs ! null) rs.close(); } catch (SQLException ignored) {} try { if (stmt ! null) stmt.close(); } catch (SQLException ignored) {} try { if (conn ! null) conn.close(); } catch (SQLException ignored) {} } }逻辑说明静态代码块在类加载时注册驱动Class.forName(DRIVER)之后每次调用getConnection()都会创建一个新连接。close方法的参数顺序固定是 Connection、Statement、ResultSet关闭顺序必须从内到外——先关结果集再关语句最后关连接。如果你在代码里用完了只关 ConnectionStatement 和 ResultSet 占用的数据库游标不会被释放高并发下很快报“Too many connections”。参数说明characterEncodingutf8管的是 Java 程序往 MySQL 发 SQL 时的编码serverTimezoneAsia/Shanghai是 MySQL 8.x 必填项不写会报 CST 时区无法识别的异常useSSLfalse是本地开发关闭 SSL 加密否则 MySQL 8 默认开 SSL 会打出大量告警日志。这三个参数是这类基于“Java Web源码”的项目里最常见的翻车点之一后面避坑章节还会展开。3.3 分页查询的 SQL 写法与 PageBean 设计小说列表页不可能一次把几百本书全查出来分页是必做的。基于 MySQL 的分页用 LIMIT 实现配合一个 PageBean 封装分页信息package com.novel.util; import java.util.List; public class PageBeanT { private int pageNum; // 当前页码从1开始 private int pageSize; // 每页条数 private int totalCount; // 总记录数 private int totalPages; // 总页数 private ListT list; // 当前页的数据 public PageBean(int pageNum, int pageSize, int totalCount) { this.pageNum pageNum; this.pageSize pageSize; this.totalCount totalCount; this.totalPages (int) Math.ceil(totalCount * 1.0 / pageSize); } // getter/setter 省略 }DAO 层配合 PageBean 使用时需要两个方法一个是统计总记录数一个是查当前页数据。public PageBeanNovel findByPage(int pageNum, int pageSize, String category) { Connection conn null; PreparedStatement ps null; ResultSet rs null; PageBeanNovel pageBean null; try { conn DBUtil.getConnection(); String countSql SELECT COUNT(*) FROM t_novel WHERE category ?; ps conn.prepareStatement(countSql); ps.setString(1, category); rs ps.executeQuery(); int totalCount 0; if (rs.next()) { totalCount rs.getInt(1); } // 计算偏移量 int offset (pageNum - 1) * pageSize; String listSql SELECT id, title, author, category, intro, cover, chapter_count, click_count FROM t_novel WHERE category ? ORDER BY click_count DESC LIMIT ?, ?; ps conn.prepareStatement(listSql); ps.setString(1, category); ps.setInt(2, offset); ps.setInt(3, pageSize); rs ps.executeQuery(); ListNovel list new ArrayList(); while (rs.next()) { Novel novel new Novel(); novel.setId(rs.getInt(id)); novel.setTitle(rs.getString(title)); // 其他字段赋值省略 list.add(novel); } pageBean new PageBean(pageNum, pageSize, totalCount); pageBean.setList(list); } catch (SQLException e) { e.printStackTrace(); } finally { DBUtil.close(conn, ps, rs); } return pageBean; }逻辑说明所有 SQL 都用 PreparedStatement 而不是 Statement因为 PreparedStatement 用?占位符能防 SQL 注入也能让 MySQL 的预编译缓存命中。COUNT(*)和列表查询必须分开执行不能写在一条 SQL 里因为 LIMIT 会改变返回行数count 会不对。排序用了ORDER BY click_count DESC这会让分页后的数据顺序不稳定——后面避坑章节会专门讲这个问题。4. 核心代码实现从登录到阅读器的 Servlet JSP 串联4.1 过滤器链编码过滤器与登录校验过滤器的实现在写任何一个 Servlet 之前先写两个 Filter。第一个是字符编码过滤器解决 POST 请求中文乱码第二个是登录过滤器拦截未登录用户访问阅读页和书架页。package com.novel.filter; import javax.servlet.*; import javax.servlet.annotation.WebFilter; import java.io.IOException; WebFilter(/*) public class EncodingFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding(UTF-8); resp.setCharacterEncoding(UTF-8); resp.setContentType(text/html;charsetUTF-8); chain.doFilter(req, resp); } }注意req.setCharacterEncoding只对 POST 请求的请求体生效。GET 请求的参数在 URL 上Tomcat 8 及以上版本默认使用 UTF-8 解析 URL所以通常没问题但如果你用的还是 Tomcat 7 或更老的版本必须在 server.xml 的 Connector 上加URIEncodingUTF-8否则 GET 提交中文书名搜索时全是乱码。package com.novel.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(/novel/*) public class LoginFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; HttpSession session request.getSession(false); String uri request.getRequestURI(); // 静态资源和登录请求放行 if (uri.endsWith(.css) || uri.endsWith(.js) || uri.endsWith(.png) || uri.endsWith(.jpg) || uri.contains(login) || uri.contains(register)) { chain.doFilter(req, resp); return; } if (session null || session.getAttribute(user) null) { // 未登录跳转到登录页 response.sendRedirect(request.getContextPath() /login.jsp); return; } chain.doFilter(req, resp); } }逻辑说明第二个过滤器里request.getSession(false)是关键——传 false 表示如果当前没有 Session 就返回 null而不是新建一个。如果不传这个参数未登录用户的每次请求都会创建一个新 Session白白浪费内存。url.contains(login)的写法比较粗糙更严谨的是判断 Servlet 的映射路径但代码展示阶段够用了。4.2 登录 ServletMD5 加密与 Session 管理登录是小说网站里安全性要求最高的功能点。密码不能明文存数据库前端也不能每次请求都明文传密码。常见做法是前端用 MD5 加密一次后端存库前再用 MD5 加盐加密一次。这里演示后端的处理package com.novel.servlet; import com.novel.bean.User; import com.novel.dao.UserDao; import com.novel.util.MD5Util; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.*; import java.io.IOException; WebServlet(/login) public class LoginServlet extends HttpServlet { private UserDao userDao new UserDao(); Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username req.getParameter(username); String password req.getParameter(password); String code req.getParameter(code); // 1. 校验验证码 HttpSession session req.getSession(); String sessionCode (String) session.getAttribute(code); if (sessionCode null || !sessionCode.equalsIgnoreCase(code)) { req.setAttribute(msg, 验证码错误); req.getRequestDispatcher(/login.jsp).forward(req, resp); return; } // 2. 参数非空校验 if (username null || username.trim().isEmpty() || password null || password.trim().isEmpty()) { req.setAttribute(msg, 用户名和密码不能为空); req.getRequestDispatcher(/login.jsp).forward(req, resp); return; } // 3. 数据库校验MD5加盐 String md5Password MD5Util.md5(password username); User user userDao.findByUsernameAndPassword(username, md5Password); if (user null) { req.setAttribute(msg, 用户名或密码错误); req.getRequestDispatcher(/login.jsp).forward(req, resp); return; } // 4. 登录成功Session保存用户 session.setAttribute(user, user); session.setMaxInactiveInterval(30 * 60); // 30分钟 resp.sendRedirect(req.getContextPath() /index.jsp); } }这段代码覆盖了登录接口的四个标准流程验证码校验、非空校验、密码校验、会话创建。MD5Util.md5(password username)就是加盐——盐用的是用户名本身简单但不额外消耗存储。实际生产系统优先用 BCrypt但课程设计阶段 MD5 加盐是主流方案。逻辑说明req.getRequestDispatcher(...).forward(req, resp)是转发URL 不会变request 和 response 对象会原样传给 JSPresp.sendRedirect(...)是重定向浏览器会重新发起一次 GET 请求。登录失败用转发能回显错误消息登录成功用重定向防止刷新页面时重复提交表单。这个选择不是随意的涉及到 POST-Redirect-GET 模式答辩时会被问到。4.3 小说阅读器上一章/下一章的跳转逻辑阅读页是最容易写砸的页面——它要同时处理“顶部有标题和章节信息”“正文内容排版”“底部有上一章/下一章按钮”这些细节。核心 Servlet 的写法package com.novel.servlet; import com.novel.bean.Chapter; import com.novel.dao.ChapterDao; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.*; import java.io.IOException; WebServlet(/chapter) public class ChapterServlet extends HttpServlet { private ChapterDao chapterDao new ChapterDao(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { int novelId Integer.parseInt(req.getParameter(novelId)); int chapterId Integer.parseInt(req.getParameter(chapterId)); Chapter current chapterDao.findById(chapterId); if (current null) { resp.sendError(404, 章节不存在); return; } // 查上一章和下一章按 sort_no 排序 Chapter prev chapterDao.findPrev(novelId, current.getSortNo()); Chapter next chapterDao.findNext(novelId, current.getSortNo()); req.setAttribute(current, current); req.setAttribute(prev, prev); req.setAttribute(next, next); req.setAttribute(novelId, novelId); req.getRequestDispatcher(/novel/read.jsp).forward(req, resp); } }对应的 ChapterDao 里 findNext 的 SQL 是一个很容易踩坑的点-- 下一章取排序号大于当前章节的最小排序号 SELECT * FROM t_chapter WHERE novel_id ? AND sort_no ? ORDER BY sort_no ASC LIMIT 1; -- 上一章取排序号小于当前章节的最大排序号 SELECT * FROM t_chapter WHERE novel_id ? AND sort_no ? ORDER BY sort_no DESC LIMIT 1;逻辑说明这里有一个隐藏问题——如果不使用sort_no直接用id排序同样能把章节排出来。但当你将来做“章节插队”比如在某章后面插入一章番外时id顺序就不再代表阅读顺序了。所以从第一天起就该用sort_no。另外 findNext 用 ? ORDER BY ASC LIMIT 1findPrev 用 ? ORDER BY DESC LIMIT 1二者一升一降语义才对。阅读页 JSP 里正文展示要解决换行问题。数据库里的章节正文通常是带换行符的纯文本HTML 里不会自动显示换行。用 JSTL 的c:out可以防 XSS但换行还是出不来。需要先转义再替换div classchapter-content ${fn:replace(fn:escapeXml(current.content), \\r\\n, br/)} /divfn:escapeXml会把、、转成实体防止脚本注入fn:replace再把换行替换成br/。这个顺序不能反——如果先替换换行再 escapeXmlbr/会被转义成lt;br/gt;页面上看到的就是一串标签文字。这是我最初做这个功能时真实翻过车的顺序问题。5. 从源码到运行环境搭建、部署步骤与四个必踩的坑5.1 用 Eclipse / IDEA 把源码跑起来的标准流程拿到源码包后最常见的卡点不是代码而是环境。下面是我按经验整理的标准落地流程安装 JDK 8 或 11配置 JAVA_HOME 环境变量。打开命令行执行java -version能输出版本号。安装 MySQL 5.7 或 8.0记住 root 密码。用 Navicat 或命令行执行source novel.sql导入数据库。修改 DBUtil.java 里的用户名和密码确保能连上 MySQL。下载 Tomcat 8.5 或 9.0解压到无中文无空格的路径比如D:\apache-tomcat-9.0.80。在 IDEA 中配置 Tomcat Server把项目添加到 Deployment 里Application context 设为/或/NovelWeb。启动 Tomcat浏览器访问http://localhost:8080/看到首页即成功。如果你用的是 Eclipse步骤不太一样Dynamic Web Project 需要把项目放进wtpwebapps目录或者在 Servers 视图里右键 Add and Remove 把项目添加进去。这里要特别说一个新手最容易卡住的点Tomcat 启动后访问 404。原因通常是项目的访问根路径不对。Tomcat 默认的访问路径由两部分组成http://localhost:8080 应用上下文路径。如果 IDE 里 Deployment 配的 Application context 是/NovelWeb那访问首页就得输http://localhost:8080/NovelWeb/。如果你把 context 设为/才可以直接访问http://localhost:8080/。5.2 坑一数据库驱动版本不匹配驱动类找不到现象Tomcat 启动时日志里报ClassNotFoundException: com.mysql.jdbc.Driver。原因项目的 lib 目录里放的是 MySQL 8.x 的驱动包驱动类名是com.mysql.cj.jdbc.Driver但 DBUtil.java 里写的还是 5.x 时代的旧类名。反过来也可能——MySQL 8 驱动被换成了 5.x 的版本。解决统一版本。MySQL 5.7 用mysql-connector-java-5.1.49.jar驱动类写com.mysql.jdbc.DriverMySQL 8.0 用mysql-connector-java-8.0.x.jar驱动类写com.mysql.cj.jdbc.Driver同时在 URL 上加serverTimezoneAsia/Shanghai。建议直接查jar tf看包里的实际类名别凭记忆写。5.3 坑二JSP 页面中文乱码三种情况三种解法现象页面上中文全部变成???或乱码分三种情况。原因和解决第一JSP 文件本身的编码问题。检查 JSP 第一行是否写全了% page contentTypetext/html;charsetUTF-8 pageEncodingUTF-8%并且文件要保存为 UTF-8 编码IDEA 右下角能看到 UTF-8 标志。第二请求参数乱码。POST 请求在过滤器里统一设置了 UTF-8 就没问题但如果过滤器WebFilter(/*)里的拦截路径写错了漏掉了某个子目录那个目录的请求就会乱码。第三响应输出乱码。resp.setContentType(text/html;charsetUTF-8)必须放在getWriter()之前调用否则响应头已经发出去了再设置就晚了。我遇到过最隐蔽的一种数据库表建表时用了DEFAULT CHARSETutf8但表里某列被单独指定成了latin1。解决办法是执行ALTER TABLE t_novel CONVERT TO CHARACTER SET utf8mb4;把整表转成 utf8mb4。5.4 坑三分页后数据重复或丢失问题出在排序字段现象小说列表翻几页之后有的书在上一页出现过有的书始终不出现。原因SQL 里写了ORDER BY click_count DESC但这个字段在多个小说之间可能相同。MySQL 的排序是“不稳定”的当排序字段有相同值时返回顺序在翻页过程中会变化。第一页查出 id1 的书点击量没变下一页排序时它又排到了第二页导致重复或漏掉。解决排序条件加一个兜底唯一字段改成ORDER BY click_count DESC, id DESC。这样即使点击量相同也会按 id 降序固定顺序分页就是稳定的。这是做分页查询时最容易忽略的细节也是面试官最爱问的“分页怎么保证数据不重不漏”的标准答案之一。5.5 坑四管理员页面暴露在前台没有任何权限拦截现象源码里明明有 admin 目录访问http://localhost:8080/admin/manage.jsp直接就能打开后台管理页面不用登录。原因很多课程设计源码只做了前台用户登录没有区分用户角色后台管理页面没有经过任何拦截。解决在 t_user 表加一个 role 字段默认 0 表示普通用户1 表示管理员用户登录进 Session 时把 role 也存进去。新增一个 AdminFilter检查当前用户的 role 是否等于 1不等于就跳到 403 页面。如果你拿到的源码没有这个能力自己加上这个 Filter 是答辩时的一个加分项——说明你有权限意识。6. 把 JSP 页面里的% %清出去Epilogue 级优化手法这个项目跑通之后下一步值得做的事情是把 JSP 里的 Java 代码脚本片段Scriptlet全部换成 EL 表达式和 JSTL 标签。这不是形式主义——答辩时老师翻开你的 JSP 文件如果满眼都是% if (...) { %第一印象就扣分了。更重要的是脚本片段和 HTML 混在一起一旦业务逻辑有变动改起来极其痛苦。先看一段典型的“反面教材”% ListNovel novelList (ListNovel) request.getAttribute(novelList); % % if (novelList ! null novelList.size() 0) { % ul % for (Novel novel : novelList) { % lia hrefchapter?novelId% novel.getId() %% novel.getTitle() %/a/li % } % /ul % } else { % p暂无数据/p % } %相同的逻辑用 JSTL 重写% taglib prefixc urihttp://java.sun.com/jsp/jstl/core % % taglib prefixfn urihttp://java.sun.com/jsp/jstl/functions % c:if test${not empty novelList} ul c:forEach items${novelList} varnovel li a href${pageContext.request.contextPath}/chapter?novelId${novel.id} ${fn:escapeXml(novel.title)} /a /li /c:forEach /ul /c:if c:if test${empty novelList} p暂无数据/p /c:if这段代码里的{pageContext.request.contextPath}是个易错点。直接用${novel.id}时EL 会调用getId()方法所以 JavaBean 里必须有对应的 getter。fn:escapeXml做输出转义防空字符串显示为 null。我的一个习惯是JSP 里不出现任何 Java import所有集合、判断、遍历都用 JSTL业务逻辑全部放到 Servlet 或者独立的 service 类里。这样做的好处是页面部分可以由懂 HTML 的人直接改不会碰坏逻辑。验证一个项目改得干不干净有个笨办法全局搜 JSP 目录看有没有%字符。如果没有说明职责分离做得好这个项目拿去答辩、给别人看源码观感会明显上一个台阶。希望你改完之后能体会到这种“清爽”的感觉——不再依赖 IDE 自动补全也能随手维护一个页面。这个基本功值回你折腾源码的这段时间。希望帮到你。本文还有配套的精品资源点击获取