简介:基于JSP的图书管理系统毕业设计文档(docx格式,438KB),适合高校计算机相关专业学生、毕业设计开发者以及需要了解B/S架构图书管理系统的技术人员。文档以系统设计与实现为主线,完整呈现了从需求分析、系统架构到功能模块的实现思路,覆盖管理员、教师、读者三类权限划分,以及图书查询、借阅归还、库存管理等核心功能。基于JSP与MySQL的开发方案,使读者能快速理解动态网页技术如何与数据库交互,并掌握系统权限设计、业务逻辑处理等关键知识点。文档还提及图书分类检索、借阅状态查询、逾期提醒、预约等功能,体现出系统设计的完整性与实用性。资源包内包含1个Word文档,文件结构清晰,按封面、摘要、目录、正文等毕业设计标准章节组织,并详细说明开发背景、开发环境(MyEclipse、JSP、MySQL)、系统模块划分等内容,可直接用于课题参考、文档结构借鉴或二次开发设计。已有284人学习浏览,是一份值得参考的课程设计与毕业设计辅助资料。
1. 基于jsp的图书管理系统:为什么这个“老”技术路线还值得认真做一遍
每年到毕业设计选题季,总有人犹豫要不要碰 JSP 图书管理系统,觉得 Spring Boot 才是主流,JSP 看起来像上个时代的产物。我和这类项目打交道这些年,一个真实感受是:如果你做的是中小规模的业务系统、课程设计或毕设,JSP + Servlet + JavaBean 的 Model2 架构不仅完全够用,而且它把“请求怎么进来、页面怎么出去、数据怎么落库”这条链路拆得非常清晰,学会它之后再切任何 MVC 框架都有降维打击的感觉。这个项目能解决的不只是图书借还那点功能,更是一套 JavaWeb 基础能力的完整闭环:技术选型、数据库设计、Session 控制、事务处理和部署排错。适合正在做 jsp 入门训练的人、选题还没定的 jsp 相关毕设人群,以及要接手老系统维护的从业者。
2. 先定架构再写代码:Model2 的职责边界与数据库表设计
2.1 JSP + Servlet + JavaBean 的 Model2 到底怎么划分职责
Model2 是 JavaWeb 官方推荐的 MVC 落地形态,核心规则一句话:JSP 里不写业务逻辑,Servlet 里不写 SQL,JavaBean 负责数据和业务规则。具体到本项目,JSP 页面只负责把数据渲染出来,比如 book_list.jsp 只做循环输出;Servlet 拿到请求后做参数校验,然后调用 JavaBean(也就是 Service + DAO 那层)拿到结果,再决定转发到哪个 JSP 页面。这样拆的好处是,页面改版不会影响业务逻辑,换数据库驱动也不会动到页面代码。
很多初学者写 JSP 项目的第一个误区,是不自觉地回到 Model1 模式,在 jsp 页面里直接写死 JDBC 代码,页面里 <% %> 嵌套着数据库查询,一个页面三四百行。这种写法的确跑得通,但图书管理系统的功能一多,比如加上读者管理、借阅历史、逾期罚款,改一个页面很可能把另一个功能带崩。按 jsp modeled2 思想实现用户注册功能就是最典型的练习:register.jsp 收集表单,RegisterServlet 接收请求并校验,UserDAO 负责查重和插入,成功失败各跳一个结果页面。看似多写了好几个类,但每个类都能单独测试,出了问题也知道去哪个文件找。
Model2 里还有一层经常被忽略:Servlet 不应该直接操作 HttpSession 里的对象去做业务判断。比如判断当前用户能否借书,应该是 BorrowService.checkCanBorrow(user, book) 返回一个结果对象,Servlet 只负责把这个结果放进 request 或 session,再转发页面。把业务规则写在 Servlet 里会导致两个 Servlet 都要借书时把规则抄两遍,之后修改逾期限制条件就会漏改。
2.2 图书管理系统的表结构设计:四张表如何把业务闭环串起来
图书管理系统的核心业务是“谁在什么时候借了哪本书,什么时候还,有没有超期”。按这个线索设计表,最少需要四张核心表:用户表、图书表、借阅表,再加一个分类表用于图书分类统计。分类表不是必需的,但加上它之后,图书列表页做分类筛选和统计报表会省大量工作。
用户表(user)包含 id、username、password、real_name、role、phone、create_time 这七个字段。role 区分管理员和普通读者,管理员能进图书管理后台,读者只能查书和借还。password 字段要注意:不能明文存,至少做一次加盐哈希。图书表(book)包含 id、isbn、book_name、author、publisher、category_id、location、total_count、avail_count。total_count 是馆藏总量,avail_count 是当前可借数量,每次借书减一、还书加一。为什么不通过统计 borrow 表去实时算可借数量?因为图书列表页需要高频展示可借数,如果每次都 COUNT 一次,在数据量上来后查询会明显变慢,冗余一个字段更直接。
借阅表(borrow)是整张设计的中心,字段包括 id、book_id、user_id、borrow_date、due_date、return_date、status、fine。borrow_date 是借出日期,due_date 是应还日期,return_date 是实际归还日期,status 用 0/1/2 表示借出中、已按时还、逾期归还,fine 是逾期产生的罚款金额。所有时间字段都用 date 类型,不要用 varchar,否则后面算逾期天数时你还得先做字符串解析。建表 SQL 示例:
CREATE TABLE `user` ( `id` INT NOT NULL AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL UNIQUE, `password` VARCHAR(128) NOT NULL, `real_name` VARCHAR(50) DEFAULT NULL, `role` TINYINT DEFAULT 1 COMMENT '0-管理员 1-读者', `phone` VARCHAR(20) DEFAULT NULL, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `book` ( `id` INT NOT NULL AUTO_INCREMENT, `isbn` VARCHAR(20) DEFAULT NULL, `book_name` VARCHAR(200) NOT NULL, `author` VARCHAR(100) DEFAULT NULL, `publisher` VARCHAR(100) DEFAULT NULL, `category_id` INT DEFAULT NULL, `location` VARCHAR(50) DEFAULT NULL COMMENT '馆藏位置', `total_count` INT DEFAULT 0, `avail_count` INT DEFAULT 0, PRIMARY KEY (`id`), KEY `idx_book_name` (`book_name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `borrow` ( `id` INT NOT NULL AUTO_INCREMENT, `book_id` INT NOT NULL, `user_id` INT NOT NULL, `borrow_date` DATE NOT NULL, `due_date` DATE NOT NULL, `return_date` DATE DEFAULT NULL, `status` TINYINT DEFAULT 0 COMMENT '0-借出 1-已还 2-逾期归还', `fine` DECIMAL(6,2) DEFAULT 0.00, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`), KEY `idx_book_id` (`book_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;关于外键:设计上 user_id 和 book_id 是逻辑关联,但我建议不要建物理外键约束。物理外键在删除图书或用户时会引来一堆约束冲突,而且 Tomcat 多线程环境下外键检查会带来额外开销。业务层保证一致性就够了,这个取舍是很多生产项目验证过的习惯。
2.3 项目目录结构与请求路径约定:先想好再动手
用 IDEA 新建 jsp 项目时,不要选普通的 Java 模块然后手动加 web 目录,直接选 Jakarta EE 或 Java Enterprise 分类下的 Web Application 模板,IDEA 会自动生成 webapp 目录和 WEB-INF/web.xml。传统 jsp 项目打包 war 之后,最终目录结构大概长这样:
src/main/java/com/library/ ├── controller/ # Servlet ├── service/ # 业务逻辑 ├── dao/ # 数据库访问 ├── model/ # 实体类 └── util/ # 工具类(DBUtil、MD5Util) src/main/webapp/ ├── WEB-INF/ │ ├── web.xml │ └── jsp/ # 页面前端控制器统一从 servlet 转发进来 ├── static/ # css、js、images └── index.jspJSP 页面尽量放在 WEB-INF 目录下,因为外部浏览器直接访问 WEB-INF 下的文件会被 Tomcat 拒绝,这样能强制页面必须经过 Servlet 转发才能打开,避免用户绕过登录拦截器直接访问某个页面。访问路径上建议按资源 + 动作命名:/login、/book/list、/book/add、/book/update、/borrow/add、/borrow/return。每个 Servlet 用 doGet 和 doPost 区分页面跳转和数据提交,不要搞 /bookAdd 和 /bookAddSubmit 这种双 Servlet 方案。
这里还要做一件事:把所有 Servlet 的映射路径统一以后通过注解 @WebServlet 配置,不要再在 web.xml 里写一长串 servlet-mapping。注解方式少写很多配置,IDEA 编译期也能校验映射是否有冲突。项目小的时候可能感觉不到区别,等图书、用户、借阅、统计这些模块加起来超过十个 Servlet 时,web.xml 里找映射就是灾难了。
3. 从登录到借书:核心模块的实现与参数调优
3.1 登录、注册与 Session 控制:每个请求都要过的关卡
登录模块是整个系统的第一道门,它的实现质量决定后面所有功能的体验。按 Model2 思路,登录流程拆成三层:login.jsp 表单收集用户名密码,LoginServlet 接收并校验,UserDAO 查库比对密码,成功就创建 Session 并转发到首页,失败就回登录页带错误信息。密码的比对不要直接查数据库里的哈希值做相等判断,而是把用户输入的密码加同样的盐、做同样的哈希,再去和库里存的哈希比对。这样即使数据库泄露,原始密码也不会直接暴露。
LoginServlet 的代码核心框架如下:
@WebServlet("/login") public class LoginServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("UTF-8"); String username = request.getParameter("username"); String password = request.getParameter("password"); User user = new UserService().login(username, password); if (user == null) { request.setAttribute("error", "用户名或密码错误"); request.getRequestDispatcher("/WEB-INF/jsp/login.jsp") .forward(request, response); return; } HttpSession session = request.getSession(); session.setAttribute("user", user); session.setMaxInactiveInterval(60 * 30); // 30分钟超时 response.sendRedirect(request.getContextPath() + "/index"); } }逻辑说明:request.setCharacterEncoding("UTF-8") 必须在 getParameter 之前调用,否则中文用户名会乱码。用户校验失败时用 forward 返回登录页,这样地址栏还是 /login,刷新不会重复提交表单;登录成功用 sendRedirect 重定向到首页,顺便让浏览器重新发起一次 GET 请求。session.setMaxInactiveInterval(30分钟) 是安全上的必要设置,图书管理系统里如果用户借完书不退出,浏览器一直开着,Session 就一直有效,存在被他人冒用的风险。
用户个人信息展示页面也是这套逻辑的简单延伸,登录后从 session 里取出 user 对象,在 user_info.jsp 显示用户名、真实姓名、当前借阅数量。要注意 session 里存的是 JavaBean 对象,不是散装的用户名和密码字符串,否则 JSP 里要多个 session 属性拼一个完整用户,管理起来很不优雅。
注册功能按 jsp modeled2 思想拆分:RegisterServlet 接收表单,先调用 UserService.checkUsername(username) 判断是否已存在,再调用 UserDAO.insert(user) 落库。事务上有一个小坑:用户名查重和插入是两步,并发下可能两个请求同时通过查重然后都插入成功,所以 user 表的 username 字段必须建 UNIQUE 约束,数据库兜底,应用层只是提示友好。
3.2 图书的增删改查:DAO 层的写法决定你后期维护的心情
图书管理是系统的核心模块,也是八成以上的代码量所在。先写一个 DBUtil 封装数据库连接的获取与关闭,每个 DAO 方法都通过它拿连接、执行 SQL、返回结果。这里要严格使用 PreparedStatement,不管 SQL 里有没有用户输入,统一用它,一是避免 SQL 注入,二是预编译语句在 MySQL 端有执行计划缓存,反复执行时比 Statement 快。
DBUtil 的关键代码如下:
public class DBUtil { private static String url; private static String user; private static String password; static { try (InputStream in = DBUtil.class.getClassLoader() .getResourceAsStream("jdbc.properties")) { Properties props = new Properties(); props.load(in); Class.forName(props.getProperty("driver")); url = props.getProperty("url"); user = props.getProperty("username"); password = props.getProperty("password"); } catch (Exception e) { throw new ExceptionInInitializerError("数据库配置加载失败"); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(url, user, password); } }逻辑说明:静态代码块在类加载时执行一次,读取 jdbc.properties 并注册 JDBC 驱动。把连接参数放配置文件而不是写死在代码里,是维护阶段最重要的决定,后面部署到别的机器时不用重新编译 war 包,只需要改配置文件。这里没有引入连接池,毕设和课设访问量不大,DriverManager 每次新建连接可以接受;如果你要放到有持续压力的环境,换成 Druid 连接池也就多改四五行代码。
图书新增和查询的 DAO 写法有一个容易翻车的地方:增删改用 executeUpdate,查询用 executeQuery,两者不能混。很多新手在写更新操作时误用 executeQuery,结果拿不到结果集,程序直接抛异常。还有一个高频问题是资源释放,Connection、PreparedStatement、ResultSet 三个都要关,按 ResultSet→PreparedStatement→Connection 的顺序反向关闭。Java 7 之后的 try-with-resources 写法可以直接解决问题:
public List<Book> searchBooks(String keyword, int offset, int limit) throws SQLException { String sql = "SELECT * FROM book WHERE book_name LIKE ? LIMIT ?, ?"; List<Book> books = new ArrayList<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, "%" + keyword + "%"); ps.setInt(2, offset); ps.setInt(3, limit); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { Book book = new Book(); book.setId(rs.getInt("id")); book.setBookName(rs.getString("book_name")); book.setAuthor(rs.getString("author")); book.setAvailCount(rs.getInt("avail_count")); books.add(book); } } } return books; }逻辑说明:LIKE 模糊搜索用通配符拼在参数值里,而不是拼在 SQL 语句模板里,这样预处理语句仍然有效。LIMIT ?, ? 两个参数,offset 是起始行,limit 是每页条数,这就是给列表页加分页功能要用的。参数的设置顺序必须和 SQL 中 ? 出现顺序完全一致,一个常见的低级错误是把 offset 和 limit 的顺序填反,导致第二页开始数据错乱。用 try-with-resources 之后,连接和语句块结束后自动关闭,不用再写 finally 里一长串判断非空关闭的模板代码。
图书删除有一点要提醒:有借阅记录的图书不能物理删除,否则 borrow 表里的历史记录会变成孤儿数据,查借阅历史时 join 图书表就查不出书名。常见做法是加一个 status 字段,0 正常 1 下架,列表默认只查 status=0。这个设计一开始可能觉得多余,做到借阅统计那一步你会感谢当初留了这个字段。
3.3 借书与还书:事务一致性在这里体现
借书不是往 borrow 表里 insert 一条记录就完事,它同时涉及两个操作:插入借阅记录、减少图书的可借库存。这两个操作必须在一个数据库事务里,要么都成功,要么都失败。如果在插入借阅记录后、更新库存前程序抛了异常,就会出现借阅记录存在但库存没减的脏数据。BorrowService 里用如下方式处理:
public boolean borrowBook(int userId, int bookId) { String sqlBorrow = "INSERT INTO borrow (book_id, user_id, borrow_date, due_date, status) VALUES (?, ?, CURRENT_DATE, DATE_ADD(CURRENT_DATE, INTERVAL 30 DAY), 0)"; String sqlReduce = "UPDATE book SET avail_count = avail_count - 1 WHERE id = ? AND avail_count > 0"; Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); try (PreparedStatement ps1 = conn.prepareStatement(sqlBorrow)) { ps1.setInt(1, bookId); ps1.setInt(2, userId); ps1.executeUpdate(); } try (PreparedStatement ps2 = conn.prepareStatement(sqlReduce)) { ps2.setInt(1, bookId); int rows = ps2.executeUpdate(); if (rows == 0) { conn.rollback(); return false; } } conn.commit(); return true; } catch (Exception e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ex) { log.error("回滚失败", ex); } } log.error("借书失败", e); return false; } finally { if (conn != null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException e) { log.error("关闭连接失败", e); } } } }逻辑说明:setAutoCommit(false) 关闭自动提交后,后续所有 SQL 都在同一个事务里,只有执行到 commit 才真正生效。更新库存那条 SQL 里用 avail_count > 0 作为条件,数据库在更新行数上做了并发控制,两个用户同时借最后一本书时,只有一个 update 能返回行数 1,另一个返回 0 触发回滚,这比先 SELECT 查库存再决定借不借可靠得多。due_date 直接由数据库计算为当前日期加 30 天,避免应用层和数据库时间不一致。
还书流程和借书对称:更新 borrow 表的 return_date 为当前日期,根据是否超过 due_date 计算罚款,再把 book 表的 avail_count 加回来。罚款金额按天计算,每本书每天 0.5 元,在 SQL 里用 DATEDIFF 完成天数计算,而不是在 Java 里用两个 Date 对象手动相除再取整,后者遇到闰年时间差会有玄学错误。还书操作同样要包事务,更新归还状态和加回库存必须保持一致。
4. 把项目跑起来:Tomcat 部署、war 打包与路径规划
4.1 IDEA 中配置 Tomcat 与两种部署方式:直接部署和 war 部署
开发阶段,IDEA 里最省事的方式是配置本地 Tomcat 直接部署。进入 Run/Debug Configurations,新增 Tomcat Server Local 配置,在 Deployment 标签页把项目以 artifact 方式添加进去,Application context 填 /library,然后就能直接点运行。这种方式开发调试方便,改完页面代码 JSP 文件会自动重新加载,适合日常写代码的阶段。
但交付和部署阶段,传统 jsp 项目打包 war 才是正确姿势。IDEA 里的操作路径是 File → Project Structure → Artifacts,选择 Web Application: Archive,IDEA 会自动分析项目依赖,把当前项目打成 war 包。构建完成后从 Build → Build Artifacts 菜单执行构建,war 文件就输出到 out/artifacts 目录下。这一步是在给系统一个完整的交付形态:war 包可以在任何装了 Java 和 Tomcat 的机器上部署,不再依赖 IDE。
war 包的命名直接影响访问路径。如果 war 包叫 library.war,部署到 Tomcat 的 webapps 目录后,访问地址就是 http://localhost:8080/library/。如果你希望浏览器直接通过 http://localhost:8080 访问系统,把 war 包改名为 ROOT.war 再拷贝到 webapps 目录,或者提前在 Tomcat 的 conf/server.xml 里添加一个 Context 配置指定路径。开发时用 /library 路径,前后端代码里所有跳转都要带这个上下文路径,这就显露出一个经典的坑:代码里写了跳转 /book/list,实际部署到某个环境后路径变成了 /library/book/list,于是 404。
解决方案是:所有页面跳转和 Servlet 重定向,一律用 request.getContextPath() 拼接路径,例如 response.sendRedirect(request.getContextPath() + "/index"),页面里的表单 action 同样用 ${pageContext.request.contextPath} 开头。这样无论部署路径叫什么,代码都能自适应。
4.2 传统 JSP 项目的连接参数外置:改配置不再重新打包
传统 JSP 项目打包 war 后,lib 目录和 classes 目录都封在里面,如果 JDBC 连接参数写在 DBUtil 的 Java 代码里,部署后要换数据库服务器就得重新打包、重新部署,这在自己电脑上无感,但到了正式环境就是一次不小的运维操作。
解决办法是让配置文件随依赖进入 war 包,但保持可外部覆盖。具体做法:src/main/resources 目录下放 jdbc.properties,这个目录下的文件在 war 打包后会出现在 WEB-INF/classes 里。部署时不要直接编辑 war 包里的文件,而是把配置文件复制到 Tomcat 的 lib 目录同级或 classpath 可覆盖的位置,或者在启动参数里指定外部配置路径。更简单的做法:把 jdbc.properties 放在 Tomcat/conf 目录下,然后在启动参数或环境变量里把该目录加入 classpath。这样修改密码和地址,只需要改外部文件后重启 Tomcat,不需要动 war 包。
jdbc.properties 的内容一般包含五项:
driver=com.mysql.cj.jdbc.Driver url=jdbc:mysql://localhost:3306/library?useUnicode=true&characterEncoding=utf8mb4&useSSL=false&serverTimezone=Asia/Shanghai username=root password=123456 initialSize=5 maxActive=20参数说明:url 里的 characterEncoding=utf8mb4 是中文乱码问题的关键,MySQL 8 以上还要带 serverTimezone 参数,否则 JDBC 驱动会因为本地时区和 MySQL 时区不一致报 timezone 相关的异常。useSSL=false 是因为本地内网环境不需要 SSL 握手,加了 SSL 反而每次连接都有握手开销。username 和 password 不要用 root 账号做应用连接,创建一个专门账号并只授权 library 库,这是生产环境的基本操作,但教学项目里十有八九都忽略。
4.3 Nginx 能不能解析 JSP:动静分离的职责划分
这个问题在部署阶段几乎一定会碰到:有人把 JSP 项目放到 Nginx 的 html 目录下,然后访问时发现页面变成了一堆 JSP 源码。原因很简单,Nginx 本来就是静态 Web 服务器,它不认识 JSP 这种动态页面标记语言,更不能执行 Java 代码。JSP 是给 Tomcat 解析的,Tomcat 通过 Jasper 引擎把 JSP 编译成 Servlet,再执行后输出 HTML。所以 Nginx 在 JSP 项目里的正确定位是反向代理和静态资源服务器,而不是执行引擎。
常见的部署结构是:Nginx 监听 80 端口,对外分发请求;.jsp 结尾的请求通过 proxy_pass 转发给本机 8080 端口的 Tomcat;css、js、png 等静态资源直接在 Nginx 本地磁盘上找,不经过 Tomcat。这个配置写法如下:
location /library/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location ~* \.(css|js|png|jpg|gif|ico)$ { root /opt/library/static; expires 7d; }参数说明:proxy_set_header 的三个参数决定 Tomcat 日志里记录的客户端 IP 是否正确,不设置 X-Forwarded-For 的话,所有请求在 Tomcat 看来都来自 Nginx 的 IP,做 IP 限制或访问日志分析时数据就废了。静态资源的 expires 7d 是告诉浏览器可以缓存 7 天,图片和样式文件不用每次请求都回源。要注意一个细节:location /library/ 里的 proxy_pass 如果带 URI 部分,比如 proxy_pass http://127.0.0.1:8080/,Nginx 转发时会用替换后的路径转发,容易把路径搞乱,保持这种不带末尾斜杠的写法最稳妥。
如果你是在 Windows 上本地联调,也可以直接用 Tomcat 的 8080 端口访问,完全不引入 Nginx。但了解这套动静分离结构仍然有价值,因为正式部署时几乎绕不开它。
5. 避坑与排查:JSP 图书管理系统最常踩的五个翻车现场
5.1 页面中文全部变成问号:三处编码不一致
现象:登录页和图书列表页的所有中文显示为问号,数据库里存进去的中文也是问号。原因:JSP 文件保存编码、Tomcat 解析 JSP 的编码、MySQL 表字符集这三者至少有一处不是 UTF-8。解决:先把所有 JSP 文件顶部加上 page 指令指定 contentType="text/html; charset=UTF-8" 和 pageEncoding="UTF-8",再把 web.xml 里配置一个 CharacterEncodingFilter 过滤器强制把所有请求和响应都设为 UTF-8,最后检查数据库建表用的是 utf8mb4 而不是 latin1。这三处都改了以后重启 Tomcat 再看,绝大多数乱码问题都能消失。如果还有乱码,检查一下 Tomcat 的 server.xml 里 Connector 是否配置了 URIEncoding="UTF-8",这个配置负责处理 URL 地址栏参数里的中文。
5.2 页面 404 且路径里有两种可能性
现象:明明资源存在,访问 /book/list 却报 404,地址栏手动加项目名 /library/book/list 又能访问。原因:代码里所有跳转路径没有拼接上下文路径,IDEA 配置的 Application context 和 war 包实际部署路径不一致。解决:全项目搜索所有 sendRedirect、forward、表单 action、a 标签 href,强制统一用 request.getContextPath() 或 JSP 的 ${pageContext.request.contextPath} 前缀。这里没有捷径,只能逐处排查,因为我见过有人只改了 Servlet 里的重定向,忘了改 JSP 页面里的 50 个 a 标签,结果首页能进,点任何功能按钮全部 404。
5.3 页面报错 Connection is not available
现象:系统运行一段时间后,操作图书列表或登录时突然报错,检查 Tomcat 日志看到 Connection is not available, request timed out。原因:数据库连接没有正确释放。DBUtil 里的 getConnection 每次从 DriverManager 拿新连接,用完没关闭,MySQL 默认的 max_connections 被耗尽,后续请求全部排队等待。解决:检查所有 DAO 方法,确保 Connection、PreparedStatement、ResultSet 全部在 finally 块中关闭,直接用 try-with-resources 重构。如果是连接池环境,确认每个 borrow 方法里的事务结束后连接确实归还。这种问题在开发机上不明显,因为开发机通常只有你一个人在用,一旦多几个人同时点页面就开始报错。
5.4 日期计算差一天或完全没有逾期判断
现象:借书 30 天后还书,系统不判定为逾期;或者还书时间显示比实际时间差一天。原因:借书时把 java.util.Date 直接通过 setDate 存进数据库,而 JDBC 的 setDate 只接受 java.sql.Date,两者转换时丢失了时分秒信息。更隐蔽的问题是计算逾期天数时用了 System.currentTimeMillis() 求差再除以 86400000,遇到夏令时或时区偏移会得到错误的天数。解决:在 Java 代码里统一使用 LocalDate 处理日期,存库时用 java.sql.Date.valueOf(localDate),查出来再用 rs.getDate("due_date").toLocalDate() 还原。判断逾期用 LocalDate.now().isAfter(dueDate) 而不是把日期转成毫秒做数学计算。
5.5 Spring Boot 思维套在传统 JSP 项目上,目录结构对不上
现象:有人做过 Spring Boot 项目后回头写传统 JSP,发现配置文件写好但 Tomcat 启动后找不到页面,JSP 也没有被编译。原因:传统 JSP 项目的 JSP 文件必须放在 webapp 目录下由 Tomcat 直接管理,Spring Boot 里常见的 src/main/resources/templates 目录下放 JSP 是跑不起来的。解决:回到传统 JSP 的目录约定,webapp/WEB-INF/jsp 放页面,web.xml 或注解方式注册 Servlet。如果确实想在 Spring Boot 里集成 JSP,需要在 application.properties 里配置视图解析器前缀前缀为 /WEB-INF/jsp/ 且必须打成 war 包而不是内嵌 Tomcat 的 jar 方式运行,这个限制是 Spring Boot 官方文档里都写明的,不要硬踩。
6. 让系统从“能跑”到“能用”:分页、防注入与一小时验证清单
图书列表页最终一定要加分页,因为一本本把结果全部渲染到页面上,数据到 200 条以后页面就开始明显卡顿。分页参数上,pageSize 设 10 或 20 都行,关键是查询接口里同时返回总条数,前端才能算出总页数。我的习惯是每次分页请求把当前页数、每页条数和搜索关键词都放在 URL 参数里,这样刷新页面后还能保持原来的查询条件,这个体验细节很影响使用时的心情。
安全方面,所有的 SQL 操作必须走 PreparedStatement,这不是可选项。有一个练习项目里见过用字符串拼接 SQL 做搜索的,在书名框里输入一个单引号,整个 SQL 语句就炸了,这就是典型的 SQL 注入的引子。PreparedStatement 的预编译机制天然规避了这个问题,但前提是你不要把参数拼进 SQL 模板里。
最后是一份一小时验证清单,新部署完系统后逐项执行一遍:注册一个新账号、用新账号登录、查看自己的 jsp 个人信息展示页面、修改密码后重新登录、管理员新增图书、修改图书信息、下架一本图书、读者借一本书、库存对应减一、归还这本书、制造一本超期图书确认罚款计算正确。这套流程走完,系统的核心链路就算验证通过。
做这个项目时我印象最深的一个教训是:借书事务里忘了处理库存不足的回滚,当时觉得查一下库存再插入借阅记录就够了,结果并发测试时两台机器同时借同一本书,库存变成了负数。后来才明白数据库事务里的行级条件更新才是正解,这个认知后来帮我避免了不少线上问题。希望这些经验能帮你在做图书管理系统时少走一些弯路,也祝你顺利跑通这个经典的 JavaWeb 毕业设计项目。
本文还有配套的精品资源,点击获取