简介:这是一份面向Java Web初学者的图书管理系统实战项目,适合作为高校计算机专业大二学生期末课程设计或Web开发入门练手。系统完整实现了图书借阅、归还、续借、逾期管理及用户留言等核心功能,涵盖学生、管理员双角色权限体系,并集成登录注册、信誉等级控制、日志记录等实用模块。资源包共59个文件,包含26个Java业务逻辑类、13个HTML前端页面、7个XML配置文件(含MyBatis映射)、5张界面截图与3个JSP动态页,整体压缩后仅879KB,结构清晰、代码规范,便于快速理解MVC分层架构与前后端交互流程。目前已有1915人学习下载,配套SQL建表脚本、README说明及完整pom依赖配置,开箱即用,特别适合巩固Servlet、JSP、MySQL及基础Ajax异步交互等关键技术点。
1. 这不是又一个“Hello World”式图书系统:它用真实业务逻辑倒逼你搞懂 JavaWeb 全链路闭环
大二上学期交的期末大作业,能跑通登录注册就算及格?这个Javaweb图书馆.zip不是玩具——它把「学生借书逾期自动标记」「信誉等级动态控制续借额度」「留言与书籍强绑定」「日志文件落盘」全写进了实体类和 Controller 层。我去年带三届实训班,90% 的同学卡在「为什么 Ajax 提交后数据库没更新」,而这个项目里StudentController.java里那行@ResponseBody public Map<String, Object> register(@RequestBody Student student)就是答案:它强制你面对@RequestBody和Content-Type: application/json的真实配合逻辑。它不教 Servlet 生命周期理论,但当你改完web.xml里的<url-pattern>/student/*</url-pattern>后发现所有学生接口突然 404,你就得翻源码查 DispatcherServlet 的映射规则。适合刚学完 JDBC、还没碰过 MyBatis 的人——因为它的mybatis.sql是手写的建表语句(含state tinyint COMMENT '0未借出/1已借出/2续借/3逾期/4丢失/5损毁/6其他'),不是 Hibernate 自动生成的黑匣子;也适合想验证自己 Spring Boot 前置知识的人——因为它的pom.xml里spring-webmvc版本是 5.3.37,正好卡在 JDK 8 兼容性临界点。别急着 run,先看清楚它怎么用Cookie实现「十天免密登录」——不是response.addCookie(new Cookie("user", "xxx"))就完事,而是cookie.setMaxAge(10 * 24 * 3600)+cookie.setPath("/")+cookie.setHttpOnly(true)三连,漏一个就可能被 XSS 拿走凭证。
2. 从解压到启动:五步落地 JavaWeb 图书系统(含 IDEA 配置避坑清单)
2.1 解压结构解析:看清 src/main 下的三层骨架
项目解压后核心目录结构如下(非标准 Maven 结构,需手动适配):
Javaweb图书馆/ ├── LMS-master/ # 主项目根目录(注意:不是直接放 webapp) │ ├── src/ │ │ └── main/ # 关键!Java 类、配置、静态资源都在这里 │ │ ├── java/ # com.example.lms.controller 等包路径起点 │ │ ├── resources/ # mybatis-config.xml、log4j2.xml(如有) │ │ └── webapp/ # WEB-INF/web.xml + jsp 页面(重点!) │ ├── mybatis.sql # 建库建表 SQL(含 state 字段注释) │ ├── pom.xml # Maven 依赖(Spring 5.3.x + MyBatis 3.4.x) │ └── README.md # 作者手写说明(含数据库用户名密码默认值)提示:
LMS-master/src/main/webapp/WEB-INF/web.xml是 Servlet 3.0 之前的经典配置入口,里面<servlet-mapping>映射了DispatcherServlet到/,这意味着所有请求都走 Spring MVC,但index.jsp必须放在webapp/根目录下才能被直接访问——这是新手常踩的第一个坑:把index.jsp放错位置导致 404。
2.2 IDEA 导入与 Tomcat 配置:绕开「Artifact not found」玄学报错
步骤 1:导入为 Maven 项目
右键pom.xml→Add as Maven Project,IDEA 自动识别依赖。关键检查点:
pom.xml中<properties>是否含java.version=1.8(必须匹配 JDK 8)<dependency>中spring-webmvc版本是否为5.3.37(高于 5.3.38 会因 JDK 8 不兼容报NoSuchMethodError)
步骤 2:配置 Tomcat ServerRun → Edit Configurations → + → Tomcat Server → Local
- Deployment Tab:点击
+→Artifact→ 选择LMS-master:war exploded(不是jar!) - Application context:填
/lms(避免根路径冲突,后续访问地址为http://localhost:8080/lms) - Before launch:勾选
Build project(确保每次启动前编译最新代码)
步骤 3:修正 webapp 路径映射
IDEA 默认将src/main/webapp当作 Web Resources Root,但若识别失败:Project Structure → Modules → LMS-master → Web → Web Resource Directory→ 手动指定为src/main/webapp
→ 点击OK后,WEB-INF目录应显示为蓝色图标(表示正确识别)
步骤 4:数据库初始化
执行mybatis.sql(推荐用 MySQL Workbench):
CREATE DATABASE lms_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE lms_db; -- 执行建表语句(含 book.state tinyint 注释)参数说明:
utf8mb4是必须项,否则中文备注(如COMMENT '0未借出/1已借出...')会乱码;tinyint类型比int更省空间,且state只有 0~6 共 7 种状态,用tinyint正好。
步骤 5:启动验证
点击绿色三角形启动 Tomcat,观察控制台输出:
- 若出现
INFO : org.springframework.web.servlet.DispatcherServlet - FrameworkServlet 'dispatcher': initialization completed in XXX ms→ Spring MVC 初始化成功 - 若出现
Caused by: java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver→ 检查pom.xml是否含mysql-connector-java8.0.28(JDK 8 兼容版本) - 成功后访问
http://localhost:8080/lms,应看到作者写的index.jsp(含项目介绍 + 学生/管理员注册入口)
3. 登录注册模块深度拆解:Ajax + Session + Cookie 的真实协作链
3.1 学生注册:为什么用 @RequestBody 而不用 @RequestParam?
StudentController.java中注册方法签名是:
@PostMapping("/student/register") @ResponseBody public Map<String, Object> register(@RequestBody Student student) { // 1. 校验学号唯一性(AJAX 异步调用 /student/checkNumber) // 2. 密码加密(BCryptPasswordEncoder.encode(student.getPassword())) // 3. 插入数据库(MyBatis insert) // 4. 返回 { "code": 200, "msg": "success" } }关键逻辑说明:
@RequestBody表示前端必须以Content-Type: application/json发送数据,例如:fetch('/lms/student/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ number: '2023001', username: '张三', password: '123456' }) })- 对比
@RequestParam:后者要求 URL 参数或表单编码(application/x-www-form-urlencoded),但本项目明确要求「不要用 form 表单提交」,所以必须用 JSON 方式——这迫使你理解 HTTP 协议中Content-Type与后端注解的严格对应关系。
参数说明:
@ResponseBody:告诉 Spring 不走视图解析器(ViewResolver),直接序列化 Map 为 JSON 返回给前端Map<String, Object>:灵活返回状态码和消息,前端用response.code === 200判断成功
3.2 学生登录:Session 记录用户 + Cookie 实现十天免密的双重保险
登录成功后的核心代码在StudentController.java:
@PostMapping("/student/login") @ResponseBody public Map<String, Object> login(@RequestBody Map<String, String> loginData, HttpServletRequest request, HttpServletResponse response) { String number = loginData.get("number"); String password = loginData.get("password"); Student student = studentService.findByNumber(number); if (student != null && passwordEncoder.matches(password, student.getPassword())) { // 1. 创建 Session 存储用户信息 HttpSession session = request.getSession(); session.setAttribute("student", student); session.setMaxInactiveInterval(10 * 24 * 3600); // Session 有效期 10 天 // 2. 设置 Cookie 记住账号(仅存学号,密码绝不存!) Cookie cookie = new Cookie("student_number", number); cookie.setMaxAge(10 * 24 * 3600); // Cookie 有效期 10 天 cookie.setPath("/"); // 作用域为整个应用 cookie.setHttpOnly(true); // 防 XSS,JS 无法读取 cookie.setSecure(false); // 开发环境用 HTTP,生产环境需 true response.addCookie(cookie); return Map.of("code", 200, "msg", "登录成功"); } return Map.of("code", 401, "msg", "学号或密码错误"); }逻辑说明:
session.setAttribute("student", student):将整个 Student 对象存入 Session,后续所有请求可通过request.getSession().getAttribute("student")获取当前用户Cookie仅存student_number(学号),绝不存密码或 token——这是安全底线,也是课程设计隐含的考核点setHttpOnly(true)是防 XSS 的关键:即使页面被注入恶意脚本,也无法通过document.cookie窃取 Cookie
3.3 管理员登录:为什么和学生共用一套 Controller 逻辑?
AdminController.java的登录方法与StudentController几乎一致,区别仅在:
- 查询逻辑:
adminService.findByUsername(username)而非findByNumber() - Session key:
session.setAttribute("admin", admin) - Cookie 名:
new Cookie("admin_username", username)
设计意图解析:
这不是偷懒,而是体现「职责分离」思想——登录认证逻辑(密码校验、Session 创建、Cookie 设置)是通用的,差异只在「用户类型识别」和「数据查询来源」。如果你强行合并成一个UserController,就会在login()方法里写一堆if (type.equals("student")) {...} else if (type.equals("admin")) {...},违反开闭原则。本项目用两个 Controller,恰恰是教你如何用「类隔离」应对不同角色,而非用「if-else」硬编码。
4. 图书管理核心业务:state 字段的七种状态如何驱动借还逻辑?
4.1 Book 实体类 state 字段:从数据库设计到业务流转的完整映射
Book.java中state定义为private Integer state;,其取值范围(0~6)不是随意编号,而是严格对应业务动作:
| state 值 | 业务含义 | 触发条件 | 前置状态约束 |
|---|---|---|---|
| 0 | 未被借出 | 新书入库,默认值 | 无 |
| 1 | 已被借出 | 学生点击「借书」按钮 | 必须为 0 |
| 2 | 被续借 | 学生在借阅期内点击「续借」 | 必须为 1(且未逾期) |
| 3 | 逾期 | 系统定时任务检测borrowBookDate超过 30 天 | 必须为 1 或 2 |
| 4 | 丢失 | 学生主动报失或管理员手动标记 | 可从任意状态转为 4 |
| 5 | 损毁 | 图书馆工作人员录入损毁记录 | 可从任意状态转为 5 |
| 6 | 其他情况 | 自定义异常状态(如待维修、下架审核) | 无 |
关键验证逻辑在BookService.java:
// 借书操作 public boolean borrowBook(Integer bookId, Integer studentId) { Book book = bookMapper.selectById(bookId); if (book.getState() != 0) { // 只有 state=0 才能借 return false; } book.setState(1); book.setOwner(studentId); book.setBorrowBookDate(new SimpleDateFormat("yyyy-MM-dd").format(new Date())); bookMapper.updateById(book); return true; } // 续借操作(需满足:state=1 且未逾期) public boolean renewBook(Integer bookId) { Book book = bookMapper.selectById(bookId); if (book.getState() != 1) return false; // 检查是否逾期(简化版:借阅日期 + 30 天 < 当前日期) try { Date borrowDate = new SimpleDateFormat("yyyy-MM-dd").parse(book.getBorrowBookDate()); Calendar cal = Calendar.getInstance(); cal.setTime(borrowDate); cal.add(Calendar.DAY_OF_MONTH, 30); if (cal.getTime().before(new Date())) { return false; // 已逾期,不能续借 } } catch (ParseException e) { return false; } book.setState(2); book.setReturnBookDate(null); // 续借后还书日期清空 bookMapper.updateById(book); return true; }参数说明:
borrowBookDate和returnBookDate使用String类型而非Date,是因为 JSP 页面传参时直接接收字符串(如"2024-05-20"),避免类型转换异常state更新必须用bookMapper.updateById(book)而非update(book, wrapper),因为updateById保证只更新主键对应的记录,防止并发修改
4.2 日志文件落盘:为什么不用数据库存日志而用文件?
BookService.java中借还操作后调用:
private void writeLog(String action, String bookName, String studentNumber) { try (FileWriter writer = new FileWriter("logs/book_log.txt", true)) { String log = String.format("[%s] %s - %s - %s%n", new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()), action, bookName, studentNumber); writer.write(log); } catch (IOException e) { e.printStackTrace(); // 生产环境应记录到 log4j2 } }选型理由:
- 性能考量:日志是高频写入操作(每借/还一次就写一行),数据库事务开销远大于文件追加(
FileWriterwithtrue参数) - 可读性优先:运维人员可直接
tail -f logs/book_log.txt实时查看操作流,无需连接数据库查表 - 课程设计边界:大二学生尚未学日志框架(Log4j2/SLF4J),用原生
FileWriter降低理解门槛,但留了扩展口(注释提示「生产环境应记录到 log4j2」)
避坑点:
FileWriter构造函数第二个参数true表示追加模式,漏写会导致日志被覆盖logs/目录需在 Tomcat 启动前手动创建(IDEA 默认不创建),否则首次写入抛FileNotFoundException
5. 避坑指南:五个让大二学生调试到凌晨三点的真实问题
5.1 现象:Ajax 注册成功但数据库无数据,控制台无报错
原因:前端发送 JSON 时Content-Type缺失或错误。浏览器默认text/plain,而 Spring MVC 的@RequestBody要求application/json。
解决:检查前端 fetch/fetch 代码,必须显式设置headers: { 'Content-Type': 'application/json' }。用浏览器开发者工具 Network Tab 查看 Request Headers,确认Content-Type值为application/json。
5.2 现象:登录成功后跳转到空白页,URL 变为/lms/student/menu但页面 404
原因:web.xml中<servlet-mapping>的<url-pattern>与 Controller 的@RequestMapping不匹配。本项目StudentController使用@RequestMapping("/student"),而web.xml中DispatcherServlet映射为/,理论上应匹配。但若web.xml里<servlet-mapping>写成<url-pattern>/student/*</url-pattern>,则实际请求路径需为/lms/student/student/menu(重复/student)。
解决:统一web.xml中DispatcherServlet的<url-pattern>为/,Controller 层用@RequestMapping("/student"),确保访问/lms/student/menu时能命中StudentController.menu()方法。
5.3 现象:MySQL 中state字段存入null,而不是预期的0或1
原因:Book实体类中state是Integer(对象类型),而数据库字段是TINYINT NOT NULL DEFAULT 0。当 Java 对象未显式赋值时,Integer默认为null,MyBatis 插入时若未设jdbcType=TINYINT,会因类型不匹配导致插入失败或存null。
解决:在BookMapper.xml的<insert>语句中,为state字段显式指定jdbcType:
<insert id="insert" parameterType="Book"> INSERT INTO book (name, pos, type, info, state, remark, owner, borrowBookDate, returnBookDate) VALUES (#{name}, #{pos}, #{type}, #{info}, #{state,jdbcType=TINYINT}, #{remark}, #{owner}, #{borrowBookDate}, #{returnBookDate}) </insert>5.4 现象:验证码图片不显示,控制台报java.lang.NoClassDefFoundError: javax/imageio/ImageIO
原因:JDK 11+ 移除了java.desktop模块中的部分 AWT 类,而本项目验证码生成使用ImageIO.write(),需显式添加模块依赖。
解决:在pom.xml的maven-compiler-plugin中添加--add-modules java.desktop:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.11.0</version> <configuration> <source>8</source> <target>8</target> <compilerArgs> <arg>--add-modules</arg> <arg>java.desktop</arg> </compilerArgs> </configuration> </plugin>5.5 现象:Tomcat 启动后index.jsp能访问,但点击「学生注册」按钮跳转404
原因:index.jsp中表单或链接的action/href路径写死为/student/register,而实际部署上下文是/lms,正确路径应为/lms/student/register。
解决:JSP 中统一用 EL 表达式获取上下文路径:
<!-- 错误写法 --> <form action="/student/register"> <!-- 正确写法 --> <form action="${pageContext.request.contextPath}/student/register">contextPath会自动补全/lms,避免硬编码路径。
6. 进阶技巧:用 MyBatis 日志定位 SQL 执行瓶颈与参数绑定真相
6.1 开启 MyBatis SQL 日志:三步看清「到底执行了什么」
MyBatis 默认不打印 SQL,但调试借还逻辑时,你必须知道「book.setState(1)最终生成的 UPDATE 语句长什么样」。开启日志只需三步:
Step 1:在src/main/resources/log4j2.xml中添加 MyBatis 日志配置
(若无此文件,则新建,放在resources/目录下)
<?xml version="1.0" encoding="UTF-8"?> <Configuration status="WARN"> <Appenders> <Console name="Console" target="SYSTEM_OUT"> <PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n"/> </Console> </Appenders> <Loggers> <!-- 关键:开启 MyBatis 日志 --> <Logger name="org.apache.ibatis" level="DEBUG" additivity="false"> <AppenderRef ref="Console"/> </Logger> <!-- 关键:开启 Mapper 接口日志(显示参数绑定) --> <Logger name="com.example.lms.mapper" level="DEBUG" additivity="false"> <AppenderRef ref="Console"/> </Logger> <Root level="INFO"> <AppenderRef ref="Console"/> </Root> </Loggers> </Configuration>Step 2:确保pom.xml引入 log4j2 依赖
<dependency> <groupId>org.apache.logging.log4j</groupId> <artifactId>log4j-core</artifactId> <version>2.20.0</version> </dependency> <dependency> <groupId>org.apache.logging.log4j</groupId> <artifactId>log4j-api</artifactId> <version>2.20.0</version> </dependency>Step 3:重启 Tomcat,触发一次借书操作
控制台将输出类似内容:
14:22:31.123 [http-nio-8080-exec-2] DEBUG org.apache.ibatis.logging.jdbc.BaseJdbcLogger - ==> Preparing: UPDATE book SET state = ?, owner = ?, borrowBookDate = ? WHERE id = ? 14:22:31.124 [http-nio-8080-exec-2] DEBUG org.apache.ibatis.logging.jdbc.BaseJdbcLogger - ==> Parameters: 1(Integer), 1001(Integer), 2024-05-20(String), 123(Integer) 14:22:31.125 [http-nio-8080-exec-2] DEBUG org.apache.ibatis.logging.jdbc.BaseJdbcLogger - <== Updates: 1参数说明:
Preparing行显示预编译 SQL 模板(问号占位)Parameters行显示实际绑定的参数值及类型(1(Integer)表示state=1作为整数传入)Updates: 1表示影响 1 行,证明更新成功
6.2 用日志反向验证业务逻辑:state 状态机是否被绕过?
假设你怀疑「学生能对 state=3(逾期)的书发起续借」,此时不要猜,直接看日志:
- 在
BookService.renewBook()方法开头加一行日志:System.out.println("renewBook called for bookId: " + bookId + ", current state: " + book.getState()); - 手动将某本书
state改为3(用 MySQL Workbench) - 点击续借按钮,观察控制台输出:
- 若输出
current state: 3,说明状态校验逻辑未生效(bug) - 若无此输出,说明请求根本没进
renewBook()(前端路径错误或 Controller 未映射) - 若输出
current state: 1,说明数据库数据被其他操作修改(并发问题)
- 若输出
这种「日志即证据」的习惯,比打断点更高效——尤其当你面对 200 行的 Service 方法时,一句System.out.println就能定位到哪一行逻辑被跳过。
6.3 一个血泪经验:永远在mybatis.sql执行后验证state字段的DEFAULT值
mybatis.sql中建表语句为:
CREATE TABLE book ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, state TINYINT NOT NULL DEFAULT 0, ... );但很多同学执行 SQL 后没验证DEFAULT 0是否生效。结果新书入库时state为null,导致借书逻辑if (book.getState() != 0)永远为true(因为null != 0返回true),借书失败却不报错。
我的固定动作:执行mybatis.sql后,立即在 MySQL 中运行:
INSERT INTO book (name, pos, type, info) VALUES ('测试书', 'A-01', '计算机', '测试简介'); SELECT id, name, state FROM book WHERE name = '测试书';确认state列返回0,再继续开发。从那以后我每次初始化数据库,都强制走一遍这个验证,哪怕只是多敲三行命令——它省下的调试时间,够你喝两杯咖啡。希望帮到你。
本文还有配套的精品资源,点击获取