JavaWeb图书管理系统毕业设计:Servlet鉴权与借阅库存事务实现
2026/9/17 16:14:09 网站建设 项目流程

简介:围绕 Java Web 图书管理系统撰写的毕业设计论文文档,面向计算机相关专业应届生、需要完成毕设选题与论文写作的本科或专科学生,也可供 Java Web 入门者参考。文档完整覆盖前言、系统开发环境、需求分析、可行性分析、项目设计目标与原则、系统流程分析、架构设计、数据库实体与表设计、系统实现及系统测试等章节,技术选型涉及 Java、MySQL、B/S 结构与 JSP,并按登录、管理员、前台首页、用户功能等模块展开实现说明,能帮助读者梳理毕设论文的目录结构、需求描述与数据库设计思路。资源包内共 1 个 docx 文件,约 3.47MB,内容为单篇论文正文,便于直接查阅、修改与排版参考。目前已有 93 人学习,适合作为图书管理系统类毕设的选题参考、开题素材与论文撰写范本,对不熟悉论文结构的同学也能据此了解各章节篇幅安排与撰写重点。

1. 一份 JavaWeb 图书管理系统的毕业设计,卡住人的从来不是页面

选到这个题目的人,第一反应往往是去搜一套现成的前端模板,把后台管理界面换个配色,觉得这样就能交差。真正坐到答辩席上,或者被导师要求现场改一个功能时,问题全都出在另外几个地方:tushujieyue表里的sfsh字段到底是干什么的、借阅时库存怎么扣、为什么管理员登进去还能看到用户菜单。这套毕业设计用的技术栈是 JSP + Servlet + JavaBean + MySQL,B/S 结构,浏览器端只负责提交表单和渲染,业务逻辑和权限判断全部放在服务器端完成。

系统角色分成管理员、普通用户和前台首页三个入口。管理员管图书分类、热门图书、入库、借阅、归还、论坛和系统管理,用户只看到借阅、归还和自己的收藏。模块看着很多,底层其实是两条主线:一条是账号与权限,一条是图书从入库到借出、再回到库存的状态流转。把这两条线的数据库表和接口理顺,剩下的页面基本都是重复的增删改查。

这套东西适合正在做计算机方向毕业设计、需要一份能跑能讲的项目的人,也适合想借这个题目把 Servlet 生命周期、Session、过滤器这些基础补回来的初学者。下面按技术栈选型、数据库设计、核心代码实现、部署排错的顺序往下拆。

2. JSP + Servlet + MySQL 的 B/S 分层与工程骨架落地

2.1 为什么这套技术栈在毕设场景里仍然是稳妥选择

JSP 本质上是服务端脚本,容器把它编译成 Servlet 再执行,所以它天然能拿到 Java 的全部类库。这套系统里它承担的是视图层:接收请求参数、循环渲染图书列表、根据 Session 里的角色决定菜单显示哪些项。JavaBean 负责在页面和数据库之间搬运数据,典型的就是一个Tushu实体类配一整套getXxx/setXxx。Servlet 控制流程,判断该查库还是该转发。三者拼起来就是最朴素的 MVC。

B/S 结构的实际好处在于部署:客户端不需要装任何东西,一台装了浏览器的机器就能用,升级只改服务器端。对毕业设计来说,这个特点在答辩演示环节特别值钱——导师用自己电脑打开地址就能看到,不用配环境。

常见的替代方案是 SpringBoot + MyBatis + Vue。功能上没差别,但答辩时被问「一个请求进来经过几个组件、Session 在哪一步写入」这种问题,用原生 Servlet 的人答得出来,用自动配置框架的人常常答不上来。如果时间只有两三周,我不建议换栈,把 JSP 这条线走透更划算。

2.2 从 web.xml 到目录结构:一个能跑起来的工程骨架

先在 IDE 里建一个 Dynamic Web Project,源码目录和网页目录分开,Servlet 全部放在com.library.servlet包下,避免后期找不到类。

library-system/ ├── src/ │ └── com/library/ │ ├── dao/ # 数据库访问,每个表一个 Dao │ ├── entity/ # 实体类,字段与表字段一一对应 │ ├── servlet/ # 控制器,处理表单提交 │ ├── filter/ # 登录鉴权、编码过滤 │ └── util/ # DBUtil 连接工具类 └── WebContent/ # IDEA 下叫 web ├── WEB-INF/ │ ├── web.xml │ └── lib/ # 放 mysql-connector、jstl 的 jar ├── admin/ # 管理员页面 ├── user/ # 普通用户页面 ├── login.jsp └── index.jsp

老项目习惯在web.xml里逐个注册 Servlet 和过滤器,照抄的时候注意<url-pattern>前要带斜杠:

<servlet> <servlet-name>LoginServlet</servlet-name> <servlet-class>com.library.servlet.LoginServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>LoginServlet</servlet-name> <url-pattern>/login</url-pattern> </servlet-mapping> <filter> <filter-name>AuthFilter</filter-name> <filter-class>com.library.filter.AuthFilter</filter-class> </filter> <filter-mapping> <filter-name>AuthFilter</filter-name> <url-pattern>/admin/*</url-pattern> </filter-mapping>

Servlet 3.0 以后更省事的写法是直接在类上加@WebServlet("/login")@WebFilter("/admin/*"),不用改配置文件。两种方式不能对同一个类重复用,否则容器启动时会因为重复映射报错。

2.3 开发环境与依赖版本怎么定

版本错配是这套项目最容易翻车的地方,下表是我一般会锁定的组合:

组件推荐版本说明
JDK1.8高于 8 时老版本 Tomcat 编译 JSP 会报错
Tomcat8.5 或 9.09.0 起包名是javax.servlet,与 SpringBoot 2.x 之后的jakarta不同
MySQL5.7 或 8.08.0 必须配时区参数
mysql-connector-java5.1.47 / 8.0.33驱动类名和 URL 参数要跟 MySQL 版本对应
JSTL1.2页面里用<c:forEach>遍历图书列表

MySQL 8.0 的 JDBC URL 要写成这样,否则启动时报时区错误:

jdbc:mysql://localhost:3306/library?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false

驱动类名也从com.mysql.jdbc.Driver换成了com.mysql.cj.jdbc.Driver。用 5.1 的驱动去连 8.0 的库,症状是连接能建立但查询某些字段乱码,排查半天找不到原因。

提示:把 jar 包放到WEB-INF/lib下,而不是只在 IDE 的 Build Path 里加。IDE 里加只保证编译通过,打包后 Tomcat 找不到类会抛ClassNotFoundException

3. 数据库表设计:拼音命名、字段类型与 E-R 到建表的映射

3.1 实体拆分与表命名约定

这份设计里所有表名和字段名都用了拼音全拼,rementushutushujieyuetushuguihaitushuruku,账号表叫allusers。这种命名在正式项目里会被骂,但在毕设里反而有好处:答辩时口头描述和代码里看到的是同一个词,不会出现「老师说的图书表到底是 book 还是 tushu」这种对不上的情况。

实体拆分上对应四类对象。allusers存账号,用cx字段区分角色,管理员和普通用户共用一张表,登录后靠cx的值决定跳哪个目录,这是最省事的做法,也解释了为什么管理员登录后可能看到用户菜单——判断条件写错了。rementushu是图书主表,包含分类、图片路径、作者、出版社、发行日期、借阅价格、数量和详情。tushuruku记录入库流水,tushujieyuetushuguihai分别记录借出和归还。

3.2 字段类型取舍

原始设计里几乎所有字段都是varchar(50),包括数量、价格、借阅天数。这样写能跑,但排序和统计会出问题:shuliang存成字符串后,按数量排序会得到 1、10、2 这种字典序结果。下面是我会做的调整:

字段原设计建议类型理由
shuliangvarchar(50)int(11)需要做库存扣减和比较运算
jieyuejiagevarchar(50)decimal(10,2)金额计算不能有浮点误差
jieyuetianshuvarchar(50)int(11)用于计算应还日期
zongjiagevarchar(50)decimal(10,2)单价乘天数,必须可累加
addtimevarchar(50)datetime时间范围查询和排序
tupianvarchar(50)varchar(255)存相对路径,50 位放不下

sfshshhf这两个字段最容易被人忽略。sfsh是是否审核,shhf是审核回复,管理员在借阅管理里点通过或驳回时改的就是这两个值。如果不加这两个字段,借阅申请提交后就直接生效,没有审核环节,业务上说不通。

3.3 建表 SQL 与约束

按调整后的类型重写核心两张表,字符集统一用utf8mb4,避免书名里出现生僻字或符号时插入失败:

CREATE TABLE `allusers` ( `id` int(11) NOT NULL AUTO_INCREMENT, `username` varchar(50) DEFAULT NULL COMMENT '登录账号', `pwd` varchar(50) DEFAULT NULL COMMENT '密码,毕设不做加密', `cx` varchar(50) DEFAULT NULL COMMENT '角色:管理员/用户', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `tushujieyue` ( `id` int(11) NOT NULL AUTO_INCREMENT, `jieyuebianhao` varchar(50) DEFAULT NULL COMMENT '借阅编号,业务主键', `tushumingcheng` varchar(50) DEFAULT NULL, `shuliang` int(11) DEFAULT 1, `jieyuetianshu` int(11) DEFAULT 30, `jieyuejiage` decimal(10,2) DEFAULT 0.00, `zongjiage` decimal(10,2) DEFAULT 0.00, `jieyueriqi` datetime DEFAULT NULL, `yonghuming` varchar(50) DEFAULT NULL COMMENT '借阅人', `sfsh` varchar(50) DEFAULT '待审核' COMMENT '是否审核', `shhf` varchar(50) DEFAULT NULL COMMENT '审核回复', PRIMARY KEY (`id`), KEY `idx_yonghuming` (`yonghuming`), KEY `idx_sfsh` (`sfsh`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

uk_username保证账号不重复,这个约束比在 Servlet 里手动查一次再插入可靠得多,并发提交时也不会漏。idx_yonghumingidx_sfsh是给「我的借阅」列表和管理员审核列表准备的,不带索引时数据量上千后页面会明显变慢。zongjiage我是按单价乘数量算出来落库,而不是每次查询时现算,好处是历史订单不受图书调价影响。

注意:addtime保留原设计的话,插入时记得用new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date())转成字符串,改成datetime后就可以交给 SQL 的NOW()

4. 登录鉴权与借阅归还链路的 Servlet 实现

4.1 登录:从表单提交到 Session 落地

登录是所有模块的入口,也是权限分流的唯一位置。核心逻辑是查到记录后把用户对象塞进 Session,再按cx决定跳转目录:

@WebServlet("/login") public class LoginServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); String username = req.getParameter("username"); String pwd = req.getParameter("pwd"); // 用 PreparedStatement,禁止字符串拼接 SQL String sql = "SELECT id, username, pwd, cx FROM allusers WHERE username = ? AND pwd = ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, username); ps.setString(2, pwd); ResultSet rs = ps.executeQuery(); if (rs.next()) { HttpSession session = req.getSession(); session.setAttribute("uid", rs.getInt("id")); session.setAttribute("username", rs.getString("username")); session.setAttribute("cx", rs.getString("cx")); // 角色 // 按角色分流,管理员进 /admin,用户进 /user String target = "管理员".equals(rs.getString("cx")) ? "/admin/index.jsp" : "/user/index.jsp"; resp.sendRedirect(req.getContextPath() + target); } else { req.setAttribute("msg", "用户名或密码不正确"); req.getRequestDispatcher("/login.jsp").forward(req, resp); } } catch (SQLException e) { throw new ServletException("登录查询失败", e); } } }

几个参数别写错。req.setCharacterEncoding("UTF-8")必须在第一次取参数之前调用,写在getParameter之后就完全无效。密码这里直接明文比对,毕设阶段够用,真要严谨一点就在 Dao 层加一次 MD5 再做比较。sendRedirect会让浏览器重新发一次请求,地址栏会变;如果想保留地址,用forward。登录成功后必须用重定向,否则用户按 F5 会重复提交表单。

4.2 用 Filter 统一拦截未登录请求

如果只在每个 JSP 顶部写if (session.getAttribute("username") == null),漏写一个页面就是漏洞。正确做法是把判断收敛到过滤器里:

@WebFilter(urlPatterns = {"/admin/*", "/user/*"}) public class AuthFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; HttpSession session = req.getSession(false); // 不主动创建新会话 if (session == null || session.getAttribute("username") == null) { resp.sendRedirect(req.getContextPath() + "/login.jsp"); return; // 中断,不放行到目标资源 } // 角色校验:非管理员的账号访问 /admin 一律打回 String uri = req.getRequestURI(); if (uri.contains("/admin/") && !"管理员".equals(session.getAttribute("cx"))) { resp.sendRedirect(req.getContextPath() + "/user/index.jsp"); return; } chain.doFilter(request, response); } }

getSession(false)getSession(true)的区别是关键:前者在会话不存在时返回 null,后者会顺手建一个新会话,导致过滤器永远判断为「已登录」。chain.doFilter之前的所有return都表示拦截,这也是为什么 4.1 里那次重定向之后不能继续往下走。

4.3 借阅与归还:库存扣减和状态流转

借阅这一步同时动两张表,必须放在一个事务里,否则插入了借阅记录、扣库存失败,用户就白借了一本:

public boolean borrow(String bianhao, int tushuId, String yonghuming, int days, BigDecimal price) throws SQLException { String checkSql = "SELECT shuliang FROM rementushu WHERE id = ? FOR UPDATE"; String insertSql = "INSERT INTO tushujieyue(jieyuebianhao, tushumingcheng, shuliang, " + "jieyuetianshu, zongjiage, jieyueriqi, yonghuming, sfsh) " + "VALUES(?, ?, 1, ?, ?, NOW(), ?, '待审核')"; String updateSql = "UPDATE rementushu SET shuliang = shuliang - 1 WHERE id = ? AND shuliang > 0"; Connection conn = DBUtil.getConnection(); try { conn.setAutoCommit(false); // 开启事务 try (PreparedStatement ps1 = conn.prepareStatement(checkSql)) { ps1.setInt(1, tushuId); ResultSet rs = ps1.executeQuery(); if (!rs.next() || rs.getInt("shuliang") <= 0) { conn.rollback(); return false; // 无库存,直接回滚退出 } } try (PreparedStatement ps2 = conn.prepareStatement(insertSql)) { ps2.setString(1, bianhao); ps2.setString(2, yonghuming); ps2.setInt(3, days); ps2.setBigDecimal(4, price.multiply(BigDecimal.valueOf(days))); ps2.executeUpdate(); } try (PreparedStatement ps3 = conn.prepareStatement(updateSql)) { ps3.setInt(1, tushuId); if (ps3.executeUpdate() == 0) { conn.rollback(); return false; } } conn.commit(); return true; } catch (SQLException e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); conn.close(); } }

SELECT ... FOR UPDATE会在这一行上加排他锁,防止两个人同时借最后一本时都读到shuliang = 1UPDATE ... AND shuliang > 0是第二道保险,即便并发绕过了前面的检查,这条语句的影响行数也会是 0,直接触发回滚。归还的逻辑反过来:往tushuguihai插一条记录,把tushujieyue里对应记录的sfsh改成「已归还」,再给rementushushuliang加一。

4.4 JDBC 工具类与 SQL 注入

把连接获取封装成一个静态方法,五个 Dao 共用,改数据库密码时只动一处:

public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/library" + "?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false"; private static final String USER = "root"; private static final String PASS = "123456"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); // 8.0 驱动 } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError("MySQL 驱动未加载"); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASS); } }

所有查询条件都必须走setString/setInt,不要写成"WHERE username = '" + username + "'"。后者只要输入' or '1'='1就能绕过登录,是图书管理系统里最常见的低级漏洞,答辩时被问到会很难看。

5. 本地部署与联调排错:404、乱码、事务不生效怎么定位

5.1 部署后 404 的三层排查

第一层看访问路径对不对。项目部署后的地址是http://localhost:8080/项目名/login项目名是 IDEA 里 Artifact 的 Context Path,不是文件夹名。第二层看WEB-INF/lib里有没有mysql-connector-javajstl的 jar,Tomcat 只在WEB-INF/lib和 Tomcat 自己的lib里找类。第三层看控制台有没有ClassNotFoundExceptionNoClassDefFoundError,有的话基本就是第二层的问题。我在 IDEA 里一般会先在 Run/Debug Configurations 的 Deployment 页确认 Artifact 已经添加,再检查 Output Layout 里依赖有没有被展开到WEB-INF/lib

5.2 中文乱码的三处设置

乱码要分请求和响应两边处理。请求侧在 Filter 里统一设request.setCharacterEncoding("UTF-8"),放在过滤器链的最前面。响应侧在每个 JSP 页面头部写<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>,或者统一定义response.setContentType("text/html;charset=UTF-8")。数据库侧确认建表时用的是utf8mb4,JDBC URL 里带了characterEncoding=utf8。三处缺一处,就可能出现表单提交后书名变问号、或者页面显示正常但入库变乱码的情况。

5.3 用日志和 EXPLAIN 定位借阅失败

借阅操作返回失败但没报错时,先确认事务里哪一步影响行数为 0。把ps3.executeUpdate()的返回值打到日志里,是 0 就说明shuliang > 0条件不成立,也就是库存本来就不够。如果整段 SQL 执行很慢,把语句拿出来单独跑一次EXPLAIN

EXPLAIN SELECT shuliang FROM rementushu WHERE id = 3\G

type列是不是consteq_refrows列是不是 1。如果显示ALL,说明主键没有命中的条件,检查id传进来的是不是字符串——JSP 表单提交的都是字符串,忘了Integer.parseInt就会出现隐式类型转换,索引失效。

提示:调试事务相关的问题时,把 MySQL 的general_log打开,能直接看到 JDBC 实际发过去的 SQL 长什么样,比在 Java 代码里打日志快得多。

最后一个技巧:毕设答辩前把管理员、用户、前台三个入口各走一遍完整流程——管理员加一本库存为 1 的书,用户借走,用另一个浏览器窗口再借一次,看是不是真的被拦住。这一步能验证FOR UPDATE加锁和shuliang > 0条件是同时在起作用的,比看代码可靠。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询