1. 项目概述:从零构建一个扎实的Java图书管理系统
最近在整理自己的项目仓库,翻出了这个几年前写的图书管理系统。它不是什么惊世骇俗的架构,也没有用到当下最时髦的微服务或云原生,但恰恰是这样一个“经典”的课程设计级项目,涵盖了从需求分析、数据库设计、后端逻辑到前端交互的完整闭环,是检验和巩固Java SE及Java Web核心技能的绝佳试金石。很多新手朋友在学完Java基础语法和JDBC后,常常会陷入“接下来该做什么”的迷茫,这个项目就是一个很好的答案。它模拟了一个小型图书馆的核心业务流程,包括图书的增删改查、读者的注册借阅、以及管理员的后台管理。今天,我就把这个项目的完整实现思路、关键代码以及我踩过的那些坑,毫无保留地分享出来,希望能给正在学习Java的你提供一个清晰、可落地的实战参考。
2. 系统核心需求与架构设计
2.1 业务需求拆解:我们到底要做一个什么系统?
在动手写第一行代码之前,我们必须把业务逻辑理清楚。一个最基本的图书管理系统,至少要满足三类用户(角色)的需求:
- 读者:核心诉求是“找书”和“借书”。他们需要能查询图书信息(按书名、作者、分类等),查看自己的借阅记录,以及进行图书的借阅与归还操作。
- 图书管理员:这是系统的核心操作者。他们需要对图书和读者信息进行全面的管理,包括:
- 图书管理:新增图书入库、下架旧书、更新图书信息(如补货、修正信息)。
- 读者管理:审核注册、注销读者账户、管理读者信息。
- 借阅管理:处理所有借阅和归还请求,处理逾期、丢失等异常情况。
- 系统管理员:权限更高,负责管理“图书管理员”账户,进行系统层面的配置与数据维护。
基于以上,我们可以抽象出几个核心的业务实体:Book(图书)、Reader(读者)、BorrowRecord(借阅记录)、Admin(管理员)。它们之间的关系是:一个Reader可以有多条BorrowRecord,一条BorrowRecord对应一本Book和一个Reader。Admin独立于借阅流程之外,负责用户和图书的元数据管理。
2.2 技术栈选型与架构模式:为什么是这些技术?
确定了做什么,接下来就要决定怎么做。技术选型直接决定了开发效率和项目的可维护性。
- 后端语言:Java。这是项目的根基。我们主要使用Java SE的标准库,以及用于数据库连接的JDBC。选择Java是因为其严谨的面向对象特性、强大的生态和在企业级应用中的普遍性,非常适合用来理解业务逻辑的封装与分层。
- 数据库:MySQL。关系型数据库是这类管理系统的标准选择,它能清晰地表达图书、读者、借阅记录之间的关联关系。相比SQLite,MySQL更贴近生产环境;相比NoSQL,它的结构化查询对于复杂的统计报表(如借阅排行榜、逾期分析)更友好。
- 数据访问层:原生JDBC。虽然MyBatis、Hibernate等ORM框架能极大简化开发,但在学习阶段,我强烈建议从原生JDBC开始。亲手编写SQL语句、处理
ResultSet、管理连接池,能让你真正理解Java程序是如何与数据库对话的,这是后续学习任何框架的坚实基础。本项目将演示如何封装一个简单的DBUtil工具类来管理数据库连接和关闭资源,这是避免内存泄漏的关键。 - 架构模式:三层架构。这是经典且有效的分层方式。
- 表示层:负责与用户交互。为了聚焦后端逻辑,本项目采用控制台界面实现。通过
Scanner获取用户输入,打印菜单和结果。这能让我们抛开复杂的前端技术,专注于业务逻辑和数据处理。当然,你可以很容易地将其替换为Swing GUI、JavaFX或一个Spring Boot Web接口。 - 业务逻辑层:系统的“大脑”。包含
BookService、ReaderService、BorrowService等类,负责处理具体的业务规则,例如“借书前检查读者是否已达借阅上限”、“还书时计算是否逾期并产生罚款”。 - 数据访问层:系统的“手和脚”。包含
BookDao、ReaderDao等类,封装所有对数据库的增删改查操作,为业务逻辑层提供纯净的数据服务。
- 表示层:负责与用户交互。为了聚焦后端逻辑,本项目采用控制台界面实现。通过
注意:很多教学项目会把所有代码都写在
main方法里,这是极其不利于理解和维护的。严格的三层架构分离,是迈向“工程化”开发的第一步。
3. 数据库设计与实现:一切的基础
3.1 数据表结构设计
数据库设计是项目的基石,设计得好,后续编码事半功倍。以下是核心表结构:
1. 图书表book
CREATE TABLE `book` ( `id` INT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', `isbn` VARCHAR(20) UNIQUE NOT NULL COMMENT '国际标准书号', `name` VARCHAR(100) NOT NULL COMMENT '书名', `author` VARCHAR(50) NOT NULL COMMENT '作者', `publisher` VARCHAR(50) COMMENT '出版社', `publish_date` DATE COMMENT '出版日期', `price` DECIMAL(10, 2) COMMENT '价格', `category` VARCHAR(30) COMMENT '分类', `stock` INT DEFAULT 0 COMMENT '库存数量(总册数)', `available_stock` INT DEFAULT 0 COMMENT '可借阅数量', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图书信息表';设计要点:
isbn设为唯一索引,这是图书的唯一标识,避免重复录入。- 将库存拆分为
stock(总册数)和available_stock(可借数)。当一本书被借出时,available_stock减1,归还时加1。stock只有在购入新书或旧书报废时才改变。这种设计便于快速查询可借状态,且逻辑清晰。 category字段预留,为后续按分类检索做准备。
2. 读者表reader
CREATE TABLE `reader` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `card_number` VARCHAR(20) UNIQUE NOT NULL COMMENT '借书证号', `name` VARCHAR(50) NOT NULL, `password` VARCHAR(100) NOT NULL COMMENT '加密后的密码', `gender` CHAR(1) COMMENT '性别:M/F', `phone` VARCHAR(20), `max_borrow_limit` INT DEFAULT 5 COMMENT '最大借阅册数', `status` TINYINT DEFAULT 1 COMMENT '状态:1-正常,0-冻结', `register_time` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='读者信息表';设计要点:
password字段长度要足够,以便存储经过哈希加密(如BCrypt)后的密码字符串,切勿明文存储。max_borrow_limit实现了业务规则,方便后续调整不同读者的借阅权限。status字段用于账户状态管理,如冻结违规读者。
3. 借阅记录表borrow_record
CREATE TABLE `borrow_record` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `book_id` INT NOT NULL COMMENT '图书ID', `reader_id` INT NOT NULL COMMENT '读者ID', `borrow_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '借出时间', `due_time` DATETIME NOT NULL COMMENT '应还时间', `actual_return_time` DATETIME COMMENT '实际归还时间', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-借出未还,2-已归还,3-逾期未还,4-丢失', `fine_amount` DECIMAL(10, 2) DEFAULT 0.00 COMMENT '罚款金额', FOREIGN KEY (`book_id`) REFERENCES `book`(`id`), FOREIGN KEY (`reader_id`) REFERENCES `reader`(`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='借阅记录表';设计要点:
- 这是系统的核心事务表。通过
book_id和reader_id关联图书和读者。 due_time(应还时间)是关键,用于计算是否逾期。通常根据借阅规则(如30天)在借书时自动计算填入。status字段跟踪借阅生命周期。actual_return_time为空表示未还。- 建立了外键约束,保证了数据的一致性:不能借不存在的书,也不能为不存在的读者创建记录。
3.2 数据库连接工具类封装
直接在每个方法里打开和关闭Connection、Statement、ResultSet会导致代码冗余且容易忘记关闭,引发资源泄漏。我们必须封装一个工具类。
// DBUtil.java import java.io.InputStream; import java.sql.*; import java.util.Properties; public class DBUtil { private static String url; private static String user; private static String password; // 静态代码块,在类加载时读取配置文件 static { try (InputStream is = DBUtil.class.getClassLoader().getResourceAsStream("db.properties")) { Properties props = new Properties(); props.load(is); url = props.getProperty("jdbc.url"); user = props.getProperty("jdbc.user"); password = props.getProperty("jdbc.password"); // 加载驱动(MySQL 6.0+ 可以省略,但写上更稳妥) Class.forName(props.getProperty("jdbc.driver")); } catch (Exception e) { throw new RuntimeException("初始化数据库连接池失败", e); } } // 获取数据库连接 public static Connection getConnection() throws SQLException { return DriverManager.getConnection(url, user, password); } // 关闭资源(重载方法,应对不同场景) public static void close(Connection conn, Statement stmt, ResultSet rs) { close(rs); close(stmt); close(conn); } public static void close(Connection conn, Statement stmt) { close(stmt); close(conn); } private static void close(AutoCloseable ac) { if (ac != null) { try { ac.close(); } catch (Exception e) { // 日志记录,生产环境不要直接打印 e.printStackTrace(); } } } }对应的db.properties配置文件:
jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/library_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false jdbc.user=root jdbc.password=your_password实操心得:这里我强烈建议使用
try-with-resources语法(Java 7+),它可以自动关闭实现了AutoCloseable接口的资源,代码更简洁安全。但在Dao层的方法内部,我们仍然需要调用这个close方法,因为Connection等资源是在方法内部获取的,需要在方法结束前关闭。另外,serverTimezone参数对于避免时区导致的日期时间问题至关重要。
4. 核心业务逻辑层实现详解
三层架构中,业务逻辑层是体现系统“智慧”的地方。它调用数据访问层获取数据,并按照业务规则进行处理。
4.1 图书管理服务实现
我们以BookService为例,看看如何将数据访问和业务规则结合。
// BookService.java public class BookService { private BookDao bookDao = new BookDao(); // 新增图书 public boolean addBook(Book book) { // 业务校验:ISBN不能重复 if (bookDao.findByIsbn(book.getIsbn()) != null) { System.out.println("错误:ISBN已存在!"); return false; } // 库存逻辑:新书入库,总库存和可借库存同时增加 book.setAvailableStock(book.getStock()); return bookDao.insert(book) > 0; } // 借出一本书 public boolean borrowBook(int bookId, int readerId) { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 // 1. 检查图书是否存在且可借 Book book = bookDao.findById(bookId, conn); if (book == null || book.getAvailableStock() <= 0) { System.out.println("图书不存在或已无库存可借"); return false; } // 2. 检查读者状态和借阅上限(这里需要ReaderService,暂简化) // ... // 3. 更新图书可借库存 book.setAvailableStock(book.getAvailableStock() - 1); if (bookDao.update(book, conn) <= 0) { conn.rollback(); return false; } // 4. 创建借阅记录(需要BorrowRecordDao) BorrowRecord record = new BorrowRecord(); record.setBookId(bookId); record.setReaderId(readerId); record.setDueTime(LocalDateTime.now().plusDays(30)); // 假设借期30天 // borrowRecordDao.insert(record, conn); conn.commit(); // 提交事务 return true; } catch (SQLException e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } e.printStackTrace(); return false; } finally { DBUtil.close(conn, null, null); } } // 多条件查询图书 public List<Book> searchBooks(String name, String author, String category) { // 构建动态SQL查询条件 StringBuilder sql = new StringBuilder("SELECT * FROM book WHERE 1=1 "); List<Object> params = new ArrayList<>(); if (name != null && !name.trim().isEmpty()) { sql.append(" AND name LIKE ? "); params.add("%" + name + "%"); } if (author != null && !author.trim().isEmpty()) { sql.append(" AND author LIKE ? "); params.add("%" + author + "%"); } if (category != null && !category.trim().isEmpty()) { sql.append(" AND category = ? "); params.add(category); } return bookDao.queryByCondition(sql.toString(), params.toArray()); } }关键点解析:
- 事务管理:
borrowBook方法是一个典型的事务场景。借书涉及更新图书库存和插入借阅记录,这两个操作必须同时成功或失败。我们通过conn.setAutoCommit(false)、conn.commit()和conn.rollback()手动管理事务。这是保证数据一致性的核心。 - 业务校验:在
addBook中,我们先检查ISBN是否重复,这是业务规则,不属于数据库唯一约束的简单替代,因为我们需要给用户友好的提示。 - 动态查询:
searchBooks展示了如何根据前端传入的不定条件构建SQL。使用WHERE 1=1是一个小技巧,可以方便地拼接AND条件,避免判断第一个条件时是否要加WHERE的麻烦。
4.2 数据访问层模式:DAO与POJO
业务逻辑层操作的是Book、Reader这样的Java对象,而数据访问层负责将这些对象与数据库表映射。我们采用经典的DAO模式。
首先定义实体类(POJO):
// Book.java public class Book { private Integer id; private String isbn; private String name; private String author; private String publisher; private LocalDate publishDate; // 使用Java 8的日期API private BigDecimal price; private String category; private Integer stock; private Integer availableStock; private LocalDateTime createTime; // 省略getter/setter和toString方法 }然后是对应的BookDao:
// BookDao.java public class BookDao { // 插入数据 public int insert(Book book) { String sql = "INSERT INTO book(isbn, name, author, publisher, publish_date, price, category, stock, available_stock) VALUES(?,?,?,?,?,?,?,?,?)"; // 使用try-with-resources,简化资源管理 try (Connection conn = DBUtil.getConnection(); PreparedStatement pstmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { pstmt.setString(1, book.getIsbn()); pstmt.setString(2, book.getName()); // ... 设置其他参数 pstmt.setInt(8, book.getStock()); pstmt.setInt(9, book.getAvailableStock()); int affectedRows = pstmt.executeUpdate(); // 获取自增主键 if (affectedRows > 0) { try (ResultSet rs = pstmt.getGeneratedKeys()) { if (rs.next()) { book.setId(rs.getInt(1)); } } } return affectedRows; } catch (SQLException e) { e.printStackTrace(); return 0; } } // 根据ID查询 public Book findById(int id, Connection conn) throws SQLException { String sql = "SELECT * FROM book WHERE id = ?"; // 注意:这里传入的conn由Service层的事务管理,不能在此关闭 PreparedStatement pstmt = conn.prepareStatement(sql); pstmt.setInt(1, id); ResultSet rs = pstmt.executeQuery(); if (rs.next()) { return convertResultSetToBook(rs); } DBUtil.close(null, pstmt, rs); // 只关闭Statement和ResultSet return null; } // 将ResultSet转换为Book对象 private Book convertResultSetToBook(ResultSet rs) throws SQLException { Book book = new Book(); book.setId(rs.getInt("id")); book.setIsbn(rs.getString("isbn")); // ... 设置其他属性 // 处理可能为NULL的日期字段 Date sqlDate = rs.getDate("publish_date"); book.setPublishDate(sqlDate != null ? sqlDate.toLocalDate() : null); Timestamp timestamp = rs.getTimestamp("create_time"); book.setCreateTime(timestamp != null ? timestamp.toLocalDateTime() : null); return book; } // 条件查询 public List<Book> queryByCondition(String sql, Object... params) { List<Book> list = new ArrayList<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement pstmt = conn.prepareStatement(sql)) { for (int i = 0; i < params.length; i++) { pstmt.setObject(i + 1, params[i]); } ResultSet rs = pstmt.executeQuery(); while (rs.next()) { list.add(convertResultSetToBook(rs)); } } catch (SQLException e) { e.printStackTrace(); } return list; } }踩坑记录:
findById方法有一个重载版本接收外部传入的Connection。这是因为在事务方法中(如borrowBook),多个数据库操作必须使用同一个连接,否则它们就不在同一个事务里了。这是新手极易忽略的一点。同时,对于这类方法,资源(Connection)的关闭责任在调用方(Service层),Dao层只负责关闭自己创建的Statement和ResultSet。
5. 控制台表示层与用户交互
由于没有图形界面,我们需要设计清晰的菜单和交互流程来驱动整个系统。
// LibraryConsoleApp.java import java.util.Scanner; public class LibraryConsoleApp { private static Scanner scanner = new Scanner(System.in); private static BookService bookService = new BookService(); private static ReaderService readerService = new ReaderService(); // 假设当前登录的用户 private static Reader currentReader = null; private static Admin currentAdmin = null; public static void main(String[] args) { while (true) { if (currentReader == null && currentAdmin == null) { showMainMenu(); } else if (currentReader != null) { showReaderMenu(); } else { showAdminMenu(); } } } private static void showMainMenu() { System.out.println("\n===== 图书管理系统 ====="); System.out.println("1. 读者登录"); System.out.println("2. 读者注册"); System.out.println("3. 管理员登录"); System.out.println("0. 退出系统"); System.out.print("请选择: "); int choice = scanner.nextInt(); scanner.nextLine(); // 消耗换行符 switch (choice) { case 1: readerLogin(); break; case 2: readerRegister(); break; case 3: adminLogin(); break; case 0: System.out.println("感谢使用,再见!"); System.exit(0); default: System.out.println("输入无效,请重新选择。"); } } private static void showReaderMenu() { System.out.println("\n===== 读者功能菜单 ====="); System.out.println("欢迎," + currentReader.getName()); System.out.println("1. 查询图书"); System.out.println("2. 借阅图书"); System.out.println("3. 查看我的借阅记录"); System.out.println("4. 归还图书"); System.out.println("5. 修改个人信息"); System.out.println("0. 退出登录"); System.out.print("请选择: "); // ... 处理读者功能选择 } private static void readerLogin() { System.out.print("请输入借书证号: "); String cardNumber = scanner.nextLine(); System.out.print("请输入密码: "); String password = scanner.nextLine(); // 实际应加密后比对 Reader reader = readerService.login(cardNumber, password); if (reader != null && reader.getStatus() == 1) { currentReader = reader; System.out.println("登录成功!"); } else { System.out.println("登录失败,证号或密码错误,或账户已被冻结。"); } } private static void searchBooks() { System.out.println("\n--- 图书查询 ---"); System.out.print("请输入书名(直接回车跳过): "); String name = scanner.nextLine(); System.out.print("请输入作者(直接回车跳过): "); String author = scanner.nextLine(); System.out.print("请输入分类(直接回车跳过): "); String category = scanner.nextLine(); List<Book> books = bookService.searchBooks( name.isEmpty() ? null : name, author.isEmpty() ? null : author, category.isEmpty() ? null : category ); if (books.isEmpty()) { System.out.println("未找到相关图书。"); } else { System.out.printf("%-5s %-15s %-30s %-15s %-10s %-10s\n", "ID", "ISBN", "书名", "作者", "库存", "可借"); for (Book b : books) { System.out.printf("%-5d %-15s %-30s %-15s %-10d %-10d\n", b.getId(), b.getIsbn(), b.getName(), b.getAuthor(), b.getStock(), b.getAvailableStock()); } } } // ... 其他菜单方法 }交互设计要点:
- 状态管理:通过
currentReader和currentAdmin两个变量来记录登录状态,从而动态显示不同的菜单。 - 输入处理:使用
Scanner时,混合使用nextInt()和nextLine()要格外小心。nextInt()只读取数字,不读取行尾的换行符,紧接着的nextLine()会立刻读到空行。所以在nextInt()后通常需要跟一个nextLine()来“消耗”掉这个换行符。 - 用户友好:查询功能允许用户跳过某些条件,这通过判断输入是否为空字符串来实现,并传递给Service层处理为
null。
6. 项目进阶与优化思考
一个基础版本完成后,我们可以从多个维度对其进行扩展和优化,这能极大提升项目的技术深度。
6.1 引入密码加密存储
明文存储密码是严重的安全漏洞。我们必须对密码进行哈希加密。
// 使用BCrypt加密(需要引入org.mindrot:jbcrypt依赖) import org.mindrot.jbcrypt.BCrypt; public class PasswordUtil { // 生成加密后的密码 public static String hashPassword(String plainPassword) { // gensalt()会自动生成盐,并混入哈希结果中 return BCrypt.hashpw(plainPassword, BCrypt.gensalt()); } // 验证密码 public static boolean checkPassword(String plainPassword, String hashedPassword) { return BCrypt.checkpw(plainPassword, hashedPassword); } }在用户注册时,调用hashPassword存储密文;在登录验证时,调用checkPassword进行比对。BCrypt是当前公认的安全的密码哈希算法,它自动加盐并可以调整计算成本以对抗暴力破解。
6.2 实现分页查询
当图书数据量很大时,一次性查询所有数据效率低下且体验不好。需要在Dao层实现分页。
// 在BookDao中添加分页查询方法 public List<Book> queryByConditionWithPage(String baseSql, List<Object> params, int pageNum, int pageSize) { List<Book> list = new ArrayList<>(); String sql = baseSql + " LIMIT ?, ?"; // 计算偏移量 int offset = (pageNum - 1) * pageSize; params.add(offset); params.add(pageSize); try (Connection conn = DBUtil.getConnection(); PreparedStatement pstmt = conn.prepareStatement(sql)) { for (int i = 0; i < params.size(); i++) { pstmt.setObject(i + 1, params.get(i)); } ResultSet rs = pstmt.executeQuery(); while (rs.next()) { list.add(convertResultSetToBook(rs)); } } catch (SQLException e) { e.printStackTrace(); } return list; }同时,还需要一个查询总记录数的方法来计算总页数。Service层将接收pageNum和pageSize参数,并返回一个包含List<Book>和总页数信息的PageResult对象。
6.3 日志记录与异常处理
使用try-catch打印e.printStackTrace()仅适用于调试。生产环境需要引入日志框架,如SLF4J + Logback。
import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class BookService { private static final Logger LOGGER = LoggerFactory.getLogger(BookService.class); private BookDao bookDao = new BookDao(); public boolean addBook(Book book) { try { if (bookDao.findByIsbn(book.getIsbn()) != null) { LOGGER.warn("尝试添加重复ISBN的图书: {}", book.getIsbn()); return false; } boolean success = bookDao.insert(book) > 0; if (success) { LOGGER.info("成功添加图书: {} (ISBN: {})", book.getName(), book.getIsbn()); } return success; } catch (Exception e) { LOGGER.error("添加图书时发生异常,图书信息: {}", book, e); return false; } } }配置好logback.xml后,日志可以输出到控制台和文件,并区分INFO、WARN、ERROR等级别,便于问题追踪和系统监控。
6.4 从控制台到Web的迁移
如果你想将这个系统升级为B/S架构,技术栈可以平滑演进:
- 后端:引入Spring Boot框架。将现有的
BookService、BookDao等类转换为Spring的@Service、@Repository组件。利用Spring的依赖注入和事务管理(@Transactional)替代手写的事务代码,更加简洁优雅。 - 数据访问:引入MyBatis或Spring Data JPA。它们能极大简化JDBC的模板代码。你只需要定义Mapper接口或JpaRepository接口,并编写XML映射文件或使用注解,框架会自动生成实现。但务必理解其底层仍是JDBC。
- 前端:可以使用简单的JSP、Thymeleaf模板,或者前后端分离,用Vue/React等框架构建独立前端项目,通过RESTful API与后端交互。
- 接口设计:为
BookController设计RESTful接口,如:GET /api/books查询图书列表POST /api/books新增图书PUT /api/books/{id}更新图书DELETE /api/books/{id}删除图书
这个迁移过程,正是从一个“课程设计”项目迈向“企业级”应用的关键一步。
7. 常见问题与调试技巧实录
在开发过程中,我遇到了不少典型问题,这里总结出来,希望能帮你绕过这些坑。
问题1:数据库连接失败,报错“Public Key Retrieval is not allowed”或时区错误。
- 排查:这是MySQL Connector/J的常见问题。检查JDBC连接URL。
- 解决:在
db.properties的url后添加参数。对于时区问题加&serverTimezone=Asia/Shanghai;对于新版本MySQL身份验证问题,可能需要加&allowPublicKeyRetrieval=true(注意安全风险)或更新驱动版本。
问题2:执行插入操作后,获取不到自增主键ID。
- 排查:检查
PreparedStatement的创建方式。 - 解决:创建Statement时,需要传入
Statement.RETURN_GENERATED_KEYS标志。PreparedStatement pstmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS); int affectedRows = pstmt.executeUpdate(); if (affectedRows > 0) { try (ResultSet rs = pstmt.getGeneratedKeys()) { if (rs.next()) { int id = rs.getInt(1); // 获取生成的主键 book.setId(id); } } }
问题3:查询结果中,日期时间字段显示不正确或为null。
- 排查:Java对象中使用的是
LocalDateTime,而数据库是DATETIME或TIMESTAMP。ResultSet的getTimestamp方法可以正确转换。 - 解决:在
convertResultSetToBook方法中,使用rs.getTimestamp(“create_time”),并判断是否为null后再转换为LocalDateTime。同时确保数据库连接URL中设置了正确的服务器时区。
问题4:事务不起作用,部分操作失败后数据没有回滚。
- 排查:
- 检查数据库引擎是否为InnoDB(支持事务),MyISAM不支持。
- 检查Service层多个Dao操作是否使用的是同一个
Connection对象。 - 检查是否在操作前正确设置了
conn.setAutoCommit(false),并在最后执行了conn.commit()。 - 检查是否在catch块中执行了
conn.rollback()。
- 解决:确保满足以上所有条件。一个可靠的做法是定义一个“事务模板”方法,或者直接使用Spring的
@Transactional注解。
问题5:控制台菜单循环打印错乱,或输入后没反应。
- 排查:通常是
Scanner的用法问题。nextInt()、nextDouble()等不会读取分隔符(如换行符)。 - 解决:在读取数字后,多加一行
scanner.nextLine()来消耗掉输入缓冲区中的换行符。或者,统一使用nextLine()读取所有输入,然后对于需要数字的地方,使用Integer.parseInt()进行转换,这样更可控。
这个图书管理系统项目,就像一把钥匙,帮你打开了Java企业应用开发的大门。从JDBC到三层架构,从事务管理到异常处理,每一个环节都是后端开发的基本功。我建议你不要止步于此,尝试着去实现上面提到的每一个进阶功能,甚至自己增加一些新特性,比如邮件提醒逾期、图书热度排行榜等。真正的成长,就藏在把这些想法一行行变成代码的过程里。完整的项目代码我已经整理好,你可以根据这个思路去搭建和调试,遇到问题,欢迎随时交流。