简介:一套面向 Java 课程实训的图书馆管理系统项目包,适合高校学生或培训班学员在课程设计、期末项目中参考。系统覆盖图书信息管理、读者信息管理、借阅归还、图书查询与统计分析等模块,重点展示数据库设计、JDBC 数据操作、Swing 界面开发、MVC 分层及异常安全处理等知识点,有助于将面向对象与软件工程观念落到完整项目。
压缩包共 1988 个文件,约 6MB,主要包含 30 个 Java 源文件、128 个 class 字节码、676 个 XMI 模型文件,以及 XML/launch 等配置和工程描述文件,能看出从 UML 建模到代码实现的完整结构。已有 229 人学习下载。
项目中可参考图书表、读者表、借阅记录表等库表设计,学习预编译 SQL 的增删改查写法,了解登录、注册、借阅、归还等 Swing 页面布局与业务校验、异常处理方式,对 Java 实训、毕业设计或自学练手都有参考价值。
1. 图书馆管理系统是什么:为什么每个Java实训任务都绕不开它
实训答辩时,十个选题里有三四个是图书馆管理系统,不是老师没创意,而是这个系统能很好地考察Java基础、面向对象、集合或数据库操作,甚至还能往上挂Swing界面或Servlet。很多同学以为它是"增删改查"的堆砌,真正动手才发现:借书、还书、续借、逾期、库存扣减,每个业务细节都够你调一晚上。这篇笔记我按自己当年把控制台版改成JDBC版的思路,把需求拆法、代码组织、参数设置和容易翻车的地方一次讲清楚。适合实训刚起步、想少走弯路的同学,也适合已经写了半截、卡在某个功能上的熟手。
2. 从零搭起图书馆管理系统的骨架:控制台版到Web版的架构选型
2.1 先搞清需求边界:借书流程才是系统的核心
拿到题目第一步不是写代码,而是把功能列表砍到能在一个实训周期内完成。常见的做法是分三个模块:读者管理、图书管理、借阅管理。读者管注册、查询和押金;图书管入库、下架和库存;借阅管借书、还书、续借、逾期记录。我一般会先画一遍借书到还书的完整链路:读者来找书,查到书存在且可借,登记借出,库存减一,还书时检查是否超期,超期记罚款,库存加一。这个链路一旦画出来,后面写代码只需要跟着流程拆方法。
功能边界上要明确哪些是加分项,哪些是必需项。图书检索的模糊匹配、分页显示是加分项;借阅记录的历史列表是必需项;预约、排行榜是纯加分项。实训答辩时间有限,先保证必需项稳定,再加分项,不要让排序、图表这些吃掉你调核心bug的时间。很多同学一上来想的是"我要做一个多完整的系统",结果连登录和借书都没调通,编码体验直接从兴奋变成怀疑人生。
2.2 控制台版和Web版的取舍:别一上来就选Spring全家桶
图书馆管理系统有两种主流做法:控制台应用和Web应用。控制台版的优势是代码逻辑一目了然,适合Java基础实训,重点考类和集合;Web版会引入Tomcat、JSP或Servlet,甚至框架,适合JavaEE实训。我的建议是看实训任务说明里有没有明确要求图形界面或浏览器访问,没有的话优先做控制台版,因为你能把精力放在业务逻辑和代码结构上,而不是和前端页面纠缠。
如果你已经选了控制台版,就别再纠结要不要加Spring Boot。实训任务的核心是"用Java语言实现系统",不是"跑通一个脚手架"。我用过最稳的组合是:JDK 8 + 纯Java SE + JDBC(或集合存储),MVC三层结构手动搭。这样每一层代码都是自己写的,答辩时老师问"你这层是干什么的"你能答得理直气壮。反过来,一上来引入Spring Boot,虽然开发快,但很多同学说不清自动配置和依赖注入的原理,现场表现反而扣分。
2.3 最小可跑的Java工程结构:包名划分与文件组织
工程结构建议按功能分包,而不是按层分包。按层分包是controller、service、dao各放一包,适合大项目;实训级系统按功能分包更直观,比如reader包放读者相关类,book包放图书相关类,borrow包放借阅相关类,common包放工具类。这样答辩时你指着包说"这是读者模块",老师能快速定位。
LibrarySystem/ ├── src/ │ ├── com/library/ │ │ ├── Main.java # 程序入口,控制台菜单 │ │ ├── reader/ │ │ │ ├── Reader.java # 读者实体 │ │ │ ├── ReaderDao.java # 读者数据访问 │ │ │ └── ReaderService.java # 读者业务逻辑 │ │ ├── book/ │ │ │ ├── Book.java │ │ │ ├── BookDao.java │ │ │ └── BookService.java │ │ ├── borrow/ │ │ │ ├── BorrowRecord.java │ │ │ ├── BorrowDao.java │ │ │ └── BorrowService.java │ │ └── common/ │ │ └── DBUtil.java # 数据库连接或数据存储工具 │ └── resources/ │ └── db.properties # 数据库配置(后续章节使用) └── lib/ └── mysql-connector-java.jar # JDBC驱动这个结构的核心是:实体类只存数据,不写逻辑;Dao负责数据存取;Service负责业务判断。Main只做菜单循环和接收用户输入。
// Main.java 的菜单骨架 import java.util.Scanner; public class Main { public static void main(String[] args) { Scanner scanner = new Scanner(System.in); while (true) { System.out.println("1. 图书管理"); System.out.println("2. 读者管理"); System.out.println("3. 借书"); System.out.println("4. 还书"); System.out.println("0. 退出"); System.out.print("请选择:"); int choice = scanner.nextInt(); switch (choice) { case 1 -> bookMenu(); case 2 -> readerMenu(); case 3 -> borrowBook(); case 4 -> returnBook(); case 0 -> { System.out.println("已退出"); return; } default -> System.out.println("无效选择"); } } } }这段代码有几个关键参数值得琢磨:Scanner.nextInt()只读取整数,如果用户输入了字母,程序会抛InputMismatchException,所以实训系统一般会改成先读一行字符串再解析。case 1 ->这种箭头语法是JDK 14引入的,如果你用JDK 8,得写成case 1: ... break;,这是最容易出现的兼容性问题。菜单循环这里,我把每个功能拆成单独方法,保证其他模块能独立开发和测试。
2.4 从控制台到Web的演进路径:先保留业务层
如果你后面要升级成Web版,记住一个原则:业务逻辑和界面显示完全分离。控制台版里ReaderService和BorrowService这些类,Web版可以直接复用,只需要把Main里的菜单换成Servlet接收请求。所以从一开始写代码时,就不要在Service里写System.out.println来输出提示,而是返回结果对象,由界面层决定怎么展示。比如borrowBook方法返回一个BorrowResult,里面包含成功与否、错误消息、逾期天数,控制台就直接打印,Web版就转成JSON。
我见过一个同学的控制台版代码,所有逻辑全写在main方法里,一千多行,最后想改成Web版只能重写。正确的习惯是:哪怕只有一个类文件,也把业务方法抽出,入口只负责调用。这只是多敲几行代码,但后续的改动会轻松得多。这种分层思路在答辩时也是加分项,老师一眼就能看出你有工程意识。
3. 核心功能实现:图书借阅流程的Java代码与数据设计
3.1 数据模型设计:实体类字段怎么定才不返工
图书馆管理系统的实体类通常有三个:Book、Reader、BorrowRecord。字段不能只按数据库表抄,还要考虑业务判断需要什么。以Book为例,库存字段stock和总藏书量total要分开,因为每本书可能有多本副本,借出去一本,库存减一,但总藏量不变。如果只用一个数量字段,还书时就不知道是"这本书本来就只有一本"还是"多本书中的一本被还回来了"。
// Book.java 图书实体 public class Book { private Integer id; // 图书ID private String title; // 书名 private String author; // 作者 private String isbn; // ISBN号 private int total; // 总藏书量 private int stock; // 当前可借数量 private String location; // 馆藏位置 private Date createTime; // 入库时间 // 省略getter/setter,实训时建议用IDE自动生成 // 注意:不要手动写toString拼字符串,容易漏字段,用Objects.toStringHelper或IDE生成 }关键参数说明:id用Integer而不是int,因为数据库主键可能为null;createTime用java.util.Date,但如果你用JDK 8,建议改用java.time.LocalDateTime,日期计算更方便。isbn用String,因为ISBN可能以0开头,用数字类型会丢失前导零。这些细节是老师比较爱问的,答出"ISBN用字符串是因为不参与数值运算"会显得你考虑过数据类型边界。
Reader实体里一般包含id、name、studentNo(学号证明)、phone、maxBorrowNum(最大借书数量,默认5)、borrowedCount(当前已借数量)。为什么要有borrowedCount字段?因为借书时需要判断该读者是否已达上限,这个判断如果每次去查借阅记录表统计也行,但冗余字段能减少查询量,这就是典型的空间换时间。BorrowRecord要有id、readerId、bookId、borrowDate、dueDate(应还日期)、returnDate(实际还书日期,未还为null)、status(0借出,1已还,2逾期已还),其中dueDate一定要单独存,不能靠borrowDate + N天每次现算,因为逾期规则可能变化。
3.2 借书逻辑怎么写在业务层:状态校验、库存扣减、事务边界
借书是核心业务,我把它拆成四步:校验读者、校验图书、创建记录、更新数据。每一步都要有明确的失败返回,不要静默失败。比如读者不存在、读者借书已达上限、图书不存在、图书库存为零,这四种情况要分别给用户不同的提示,而不是统一返回"借书失败"。
// BorrowService.java 借书业务逻辑 public BorrowResult borrowBook(int readerId, int bookId) { // 1. 校验读者 Reader reader = readerDao.findById(readerId); if (reader == null) { return BorrowResult.fail("读者不存在"); } if (reader.getBorrowedCount() >= reader.getMaxBorrowNum()) { return BorrowResult.fail("该读者借书数量已达上限:" + reader.getMaxBorrowNum() + " 本"); } // 2. 校验图书 Book book = bookDao.findById(bookId); if (book == null) { return BorrowResult.fail("图书不存在"); } if (book.getStock() <= 0) { return BorrowResult.fail("图书库存不足,当前可借数量为0"); } // 3. 创建借阅记录 BorrowRecord record = new BorrowRecord(); record.setReaderId(readerId); record.setBookId(bookId); record.setBorrowDate(LocalDate.now()); record.setDueDate(LocalDate.now().plusDays(30)); // 默认借期30天 record.setStatus(0); // 4. 更新数据(注意顺序:先减库存,再存记录) book.setStock(book.getStock() - 1); reader.setBorrowedCount(reader.getBorrowedCount() + 1); // 5. 持久化 boolean updateBook = bookDao.update(book); boolean updateReader = readerDao.update(reader); boolean insertRecord = borrowDao.insert(record); // 6. 判断整体成功 if (updateBook && updateReader && insertRecord) { bookDao.commit(); // 如果使用JDBC事务 return BorrowResult.success("借书成功,应还日期:" + record.getDueDate()); } else { bookDao.rollback(); return BorrowResult.fail("借书操作失败,已回滚"); } }这段代码演示了业务层的核心逻辑,有三个需要说明的参数和设计点。dueDate用LocalDate.now().plusDays(30)计算,而不是往Date上加减,因为java.util.Date直接加减会引入毫秒精度问题,很容易在时间比较时出错。借书前把校验全部做完,任何一个校验失败都不执行数据更新,这是避免脏数据的基本要求。另外,数据更新的顺序我写成先改图书和读者状态,再插入记录,因为如果先插记录再更新库存,一旦更新失败,就会出现有记录但库存没减的不一致状态。
关于事务边界,如果用的是ArrayList这种内存存储,不存在事务的概念,代码里那几行commit/rollback要注释掉。如果用的是JDBC,则要保证updateBook、updateReader、insertRecord在同一个Connection中执行,并且在这个连接上手动关闭自动提交,然后统一提交或回滚。很多同学会犯的错是每次Dao方法都新建一个连接,三个操作各自独立提交,库存减了但记录没插入,数据就乱了。
3.3 还书和续借的边界情况:逾期费、重复借阅、提前承担
还书逻辑和借书对称,但多一个逾期判断。还书时先查借阅记录,记录必须存在且状态为借出,否则属于非法操作。然后计算returnDate和dueDate的差,如果实际还书日期晚于应还日期,需要生成罚款记录。罚款金额怎么设置?常见做法是每本书每天0.1元,直接在返回结果里带上金额,由控制台或界面显示,而不是在数据库里创建一张罚款表,实训系统不需要这么复杂。
// BorrowService.java 还书业务逻辑 public BorrowResult returnBook(int borrowRecordId, LocalDate returnDate) { // 1. 查找借阅记录 BorrowRecord record = borrowDao.findById(borrowRecordId); if (record == null) { return BorrowResult.fail("借阅记录不存在"); } if (record.getStatus() == 1) { return BorrowResult.fail("该记录已经归还,不能重复操作"); } // 2. 计算逾期 LocalDate dueDate = record.getDueDate(); long overdueDays = ChronoUnit.DAYS.between(dueDate, returnDate); double fine = 0.0; if (overdueDays > 0) { fine = overdueDays * 0.1; // 每天0.1元 record.setStatus(2); // 逾期已还 } else { record.setStatus(1); // 正常已还 } record.setReturnDate(returnDate); // 3. 恢复图书库存 Book book = bookDao.findById(record.getBookId()); book.setStock(book.getStock() + 1); // 4. 恢复读者借阅数量 Reader reader = readerDao.findById(record.getReaderId()); reader.setBorrowedCount(reader.getBorrowedCount() - 1); // 5. 持久化并返回结果 boolean result = borrowDao.update(record) & bookDao.update(book) & readerDao.update(reader); if (result) { return BorrowResult.success("还书成功" + (fine > 0 ? ",逾期" + overdueDays + "天,罚款" + String.format("%.2f", fine) + "元" : "")); } else { return BorrowResult.fail("还书操作失败"); } }这里最容易被忽视的是ChronoUnit.DAYS.between(dueDate, returnDate)的时间方向判断。如果写成between(returnDate, dueDate),正常还书的overdueDays会变成负数,后续的if (overdueDays > 0)永远不会进入,逾期判断形同虚设。另外returnDate参数的设计也有讲究:控制台界面先让用户输入是否今天归还,默认是LocalDate.now(),但如果你允许补录历史日期,那returnDate就不能在Service里写死,必须由界面层传入。
重复借阅指的是同一读者还没归还同一本书又来借一次。严格来说应该校验"该读者是否有这本在借的书",字段上加一个(readerId, bookId, status=0)的唯一索引查询。实训系统里如果不想加索引,也可以在借书前遍历该读者的借阅记录,有未还的相同bookId就返回失败。这个场景在答辩时老师一定会问,提前做掉能省去很多尴尬。
4. JDBC还是集合?图书馆管理系统的数据层两种写法对比
4.1 内存数据存储:用ArrayList模拟数据库的优缺点
如果实训任务没有明确要求数据库,很多同学会用集合存储。最省事的方案是每个Dao内部有一个List<Book>、List<Reader>、List<BorrowRecord>,程序启动时预置几条数据,运行时的增删改查操作集合。这种做法的优点是代码简单、不需要安装数据库、关闭程序数据就没了也不会被老师说偷懒。缺点也很明显:数据无法持久化,重启后所有数据丢失;并发访问需要自己加锁;查询需要遍历,没有索引。对于只运行一次的控制台演示,完全够用。
// BookDaoMemory.java 内存版图书数据访问 public class BookDaoMemory implements BookDao { private static final List<Book> BOOK_DB = new ArrayList<>(); private static int nextId = 1; static { // 预置三本测试图书 Book b1 = new Book(); b1.setId(nextId++); b1.setTitle("Java核心技术"); b1.setAuthor("张三"); b1.setTotal(5); b1.setStock(5); BOOK_DB.add(b1); } @Override public Book findById(int id) { for (Book b : BOOK_DB) { if (b.getId() == id) { return b; } } return null; } @Override public boolean update(Book book) { for (int i = 0; i < BOOK_DB.size(); i++) { if (BOOK_DB.get(i).getId().equals(book.getId())) { BOOK_DB.set(i, book); return true; } } return false; } }注意nextId是static变量,保证每次insert时ID自增。findById用循环遍历,是O(n)复杂度,数据量小没问题。接口化在这里体现价值:BookDaoMemory和后面的BookDaoJdbc实现同一个BookDao接口,业务层代码完全不用改,只换实现类就能切换存储方式。
内存版的坑在于update方法里使用的是整个对象的覆盖,如果数据库表里只有部分字段更新,比如只改location,你传入的Book对象里其他字段必须是查出来再修改后的完整对象,否则会把已有的字段置空。我在实训里看到很多同学直接在业务层new一个只有ID和库存的新对象传给update,结果其他字段全丢了。正确的做法是先findById拿到完整对象,再修改值,最后调用update。
4.2 JDBC连接MySQL:驱动加载、连接参数、PreparedStatement防注入
一旦用到数据库,数据层就要换成JDBC。JDBC的标准流程是:加载驱动类、获取连接、创建语句、执行SQL、处理结果集、关闭资源。很多人会在这步被数据库连接配置搞得心态崩掉,所以先看工具类的最小实现。
// DBUtil.java JDBC连接工具类 import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/library_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8"; private static final String USER = "你的数据库用户名"; private static final String PASSWORD = "你的数据库密码"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new RuntimeException("找不到MySQL驱动包,请确认lib目录下已有mysql-connector-java.jar", e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }这段代码值得逐行说明的坑很多。jdbc:mysql://localhost:3306/library_db是连接地址,3306是MySQL默认端口,如果本机改过端口要对应修改。useSSL=false是必须加的,本地调试不启用SSL能省去一堆证书报错。serverTimezone=Asia/Shanghai用于指定时区,不然连接时可能报"Server returns invalid timezone"。characterEncoding=utf8保证中文正常存取,这一项没设的话,控制台传进中文书名会出现Incorrect string value错误。
驱动类的完整写法是com.mysql.cj.jdbc.Driver,这是MySQL 8.0后的驱动名,如果用的老驱动包,写com.mysql.jdbc.Driver。Class.forName的作用是让驱动注册到DriverManager,JDBC 4.0以后其实可以省略这行,但实训时建议保留,因为有些环境配置不到位,这行会报ClassNotFoundException,能帮你尽早发现驱动没放对位置。
4.3 数据访问层的增删改查:Statement和PreparedStatement的差别
在Dao的JDBC实现里,所有需要拼接用户输入的SQL,都必须用PreparedStatement,这样写能避免SQL注入。图书馆管理系统里最典型的风险是图书搜索:用户输入书名的一部分,如果直接用字符串拼接SQL,用户输入个1' or '1'='1就能把全表数据捞出来。虽然实训系统不涉及真实数据,但答辩老师问到"安全考虑"时,这是最好答的亮点。
// BookDaoJdbc.java JDBC版图书查询 import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; import java.util.List; public class BookDaoJdbc implements BookDao { @Override public List<Book> searchByTitle(String keyword) { String sql = "SELECT * FROM book WHERE title LIKE ?"; List<Book> books = new ArrayList<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, "%" + keyword + "%"); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { Book b = new Book(); b.setId(rs.getInt("id")); b.setTitle(rs.getString("title")); b.setStock(rs.getInt("stock")); books.add(b); } } } catch (SQLException e) { e.printStackTrace(); } return books; } }这段代码用了try-with-resources语法,Java 7以后推荐写法,它会自动关闭Connection、PreparedStatement、ResultSet,不用写finally里一长串的close()判断。注意LIKE查询时参数需要自己拼%,setString只负责把参数变成字符串,不负责通配符。ps.setString(1, "%" + keyword + "%")里两个百分号的位置很重要,写漏一个就搜不到数据。
如果要用Statement直接拼SQL,只能是表名、字段名这类不能参数化的地方,但实训系统里基本不需要,所以统一用PreparedStatement就好。还有一点,try-with-resources的Connection是所有语句共用的,在借书业务里三个Dao操作必须使用同一个conn,那就要在业务层创建连接并传给Dao,不能各自在Dao内部再去getConnection()。常见的做法是在BorrowService的borrowBook方法里先拿到一个conn,然后bookDao.update(conn, book)这种方式传参,而不是依赖Dao内部默认连接。
4.4 数据初始化:建表SQL与测试数据的准备
JDBC方案需要在MySQL里先建好库表。一般不需要用工具,直接执行一条建库语句,再创建三张表。我最常用的是下面的SQL,字段全部和实体类对应,主键自增,外键约束在实训里可以不写,靠Java代码保证一致性,这样初始化时不容易报错。
CREATE DATABASE IF NOT EXISTS library_db DEFAULT CHARACTER SET utf8mb4; USE library_db; CREATE TABLE IF NOT EXISTS book ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, author VARCHAR(100), isbn VARCHAR(20), total INT DEFAULT 1, stock INT DEFAULT 1, location VARCHAR(50), create_time DATETIME ); CREATE TABLE IF NOT EXISTS reader ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, student_no VARCHAR(30), max_borrow_num INT DEFAULT 5, borrowed_count INT DEFAULT 0 ); CREATE TABLE IF NOT EXISTS borrow_record ( id INT PRIMARY KEY AUTO_INCREMENT, reader_id INT NOT NULL, book_id INT NOT NULL, borrow_date DATE, due_date DATE, return_date DATE, status INT DEFAULT 0 );建表时优先用utf8mb4字符集,它支持表情符号存储,也能兼容普通中文。title字段加NOT NULL,因为图书检索总是按书名列条件。due_date是借书时就算出来的日期,return_date允许为空,空值代表未还。如果后续想加唯一约束防止重复借阅,再加一行UNIQUE KEY uk_reader_book (reader_id, book_id, status),但注意status为2的已还记录也会占用唯一索引,实际应该用UNIQUE KEY (reader_id, book_id)然后业务层保证只能存在一条未还记录,这个学有余力再做。
5. 图书馆管理系统常见问题排查:五个必踩的坑与解决方案
5.1 中文乱码:控制台输出正常但数据库存的是问号
现象:从控制台输入书名"活着",数据库里存的是"??"。原因:控制台输入流和数据库连接的字符集不一致。解决:Scanner对象需要指定UTF-8输入,不然在Windows控制台会读入GBK。常见做法是把Scanner创建改成new Scanner(System.in, "UTF-8"),同时确保JDBC连接URL里带上characterEncoding=utf8,并且表字符集是utf8mb4。如果改完仍然乱码,看看IDE的全局编码设置,很多集成环境的文件编码默认是GBK,这影响的是源码里的中文字符串,不是控制台输入。
5.2 连接MySQL数据库失败:Communications link failure
现象:运行时报Communications link failure,前几步建表、加载驱动都正常。原因:MySQL服务没有启动,或者连接地址写错,或者端口被占用。解决:先确认MySQL服务是否在运行,Windows下在服务管理里查看MySQL服务状态;再确认连接地址里的localhost是否被解析成IPv6,本地调试时把localhost换成127.0.0.1能绕过这类问题。另一个常见原因是驱动JAR包没放进lib目录,或者项目没引用该JAR,报错信息里如果含ClassNotFoundException,检查编译期和运行期的classpath是否一致。
5.3 借书时库存变成负数:线程安全问题
现象:连续执行两次借书,都提示成功,但图书的stock变成-1。原因:借书时的"检查库存大于0"和"减库存"不是原子操作,两个请求可能同时通过校验。解决:最直接的办法是给borrowBook方法加synchronized关键字,控制台版是单线程,不会触发;如果是Web版或模拟并发测试就会暴露。数据库层面可以在UPDATE book SET stock = stock - 1 WHERE id = ? AND stock > 0,让数据库来保证限制,Java代码里再把update方法的返回受影响行数为0判断为失败。
5.4 日期计算错误:应还日期是前一天或后一天
现象:借书时应还日期显示7月1日,但实际算出来是6月30日。原因:java.util.Date的getTime()返回毫秒数,直接除以一天的毫秒数会因时区或夏令时产生偏差。解决:统一使用java.time.LocalDate,如borrowDate.plusDays(N),它不包含时区信息,计算的是日历日。如果用了Date,一定不要自己毫秒加减,用Calendar或Instant。另一个场景是ChronoUnit.DAYS.between()参数顺序写反,会出现负数天数,从而导致逾期判断失效。
5.5 程序一启动就报NoSuchMethodError或UnsupportedClassVersionError
现象:项目在电脑上能跑,换一台电脑或换一个JDK版本后直接找不到主类。原因:编译用的JDK版本高于运行环境版本,或编译输出的目录被IDE隐藏。解决:统一设置项目JDK版本为8或11,并用File -> Project Structure检查"Project SDK"和"Language Level"一致。如果还是报Unsupported major.minor version,说明class文件编译版本过高,用低版本JDK重新编译一遍即可。另外控制台菜单里的case 1 ->箭头语法,如果在JDK 8环境编译,会直接语法报错,这是很常见的翻车点。
6. 给实训任务加分:从能用升级到能答辩的四个技巧
6.1 用外部配置管理数据库连接
把连接信息写死在DBUtil里能跑通实训,但答辩时如果老师说"换个环境怎么办"就卡壳。写一个resources/db.properties文件,用Properties类读取,这算是工程化的最低门槛。配置项包括jdbc.url、jdbc.user、jdbc.password,读取逻辑写在DBUtil的静态代码块里。这样还能避免密码硬编码在Java源码中,虽然只是把字符串挪了个位置,但体现了配置与代码分离的意识。
6.2 给关键操作加操作日志
图书馆管理系统的日志不一定要用Log4j,可以在Service层用System.out.printf统一打印"谁在什么时间操作了什么"就够了。更简单的做法是在借书成功时把Reader.name、Book.title、borrowDate、dueDate输出到logs/borrow.txt文件里,用FileWriter追加写。这能让答辩演示时给老师看"这些记录是可追溯的",比口头解释有说服力。注意日志文件要按天命名,写入时用true参数表示追加模式,不然每次运行覆盖历史记录。
6.3 用JUnit覆盖核心借阅流程
很多实训任务不要求测试,但你写一个BorrowServiceTest类,用JUnit断言,老师会明显觉得你的代码更靠谱。测试重点放在借书成功、库存不足、读者数量超限、逾期还书罚款计算这四个场景。内存版Dao的好处是测试不需要数据库,直接构造数据即可。测试类可以放在test目录,如果实训项目没有配置JUnit依赖,也可以写一个main方法里跑断言的手动测试类,大结论是"我可以证明借阅流程是对的"。
6.4 防重复提交和并发借阅的兜底
哪怕控制台版用不上,在Service层加一个"读者未还同一本书"校验也花不了几行。借书前先查borrow_record里有没有reader_id和book_id相同的未还记录,有就返回"请先还书"。这样既完善业务,也预防了用户手抖连按两次回车造成的重复借阅。并发问题在控制台演示时不会暴露,但你只要提一句"我考虑了加锁和数据库条件更新的方案",就能和"只会增删改查"的普通作业拉开一个档次。
我从实训第一天就吃过亏:花两天时间先做了个很漂亮的Swing界面,结果核心借书逻辑全是bug,最后拆掉界面重写。后来的习惯是先跑通控制台版业务,再考虑界面和加分项。这套顺序也推荐给你——功能先完整,再谈花活。希望这篇笔记能帮你把图书馆管理系统稳稳做下来,答辩时心里有底。
本文还有配套的精品资源,点击获取