☰
Java+Swing+MySQL高校教材管理系统:从数据库建模到库存事务联动
2026/10/14 9:47:50 网站建设 项目流程

简介:这是一套基于 Java Swing 与 MySQL 的高校教材管理系统完整项目,面向 Java 课程设计、毕业设计及需要学习桌面数据库开发的在校学生和开发者。系统内置管理员、教师、学生三种角色,完整覆盖出版社与教材类型管理、教材订购、入库登记、领用登记等业务流程,并实现了书号必须以 ISBN 开头后跟 10 位数字的格式校验规则。压缩包为 RAR 格式,共 357 个文件,其中包含 56 个 Java 源码、272 个编译后的 class 文件、11 个 XML 配置文件、1 个 SQL 数据库脚本及若干工程配置文件,整体仅 2.57MB,可在 Idea 或 Eclipse 中导入直接运行。目前已有 538 人学习浏览。借助源码与数据库脚本,读者可以清晰梳理 Swing 界面层、业务处理层与 MySQL 数据层之间的调用关系,理解事件监听、JDBC 增删改查和常用事务处理的具体写法;同时也可直接复用权限控制、书号校验和库存流转逻辑等设计,作为课程设计项目或二次开发的实用模板。

1. Java+Swing+MySQL高校教材管理系统:这门课设到底在做什么

每学期开学前两周,教材科那台电脑上几乎永远开着同一个 Excel:按班级核对教材计划、到货后发书、学生退书再手工改库存。用 Java+Swing+MySQL 做一套高校教材管理系统,就是把这张 Excel 的活搬到桌面上,把“教材信息—班级计划—入库—发放—退书—库存”串成一条完整链路。

这套系统适合两类人:一类是准备 Java 课程设计的高校学生,有 Swing 基础但没见过完整项目长什么样;另一类是初级开发,想练 JDBC 与桌面端 CRUD。它不碰 Spring 全家桶,核心就三件事:Swing 画界面、JDBC 读写 MySQL、把事务和库存逻辑写对。数据量不大、并发不高,恰好能把分层、事务、异常处理这些基本功练扎实。

我的建议很直接:别急着写界面。先理清业务流程,把表结构和分层定下来,界面只是把数据填进去的壳。真正的难点从来不是 JTable 怎么摆,而是发放时库存扣没扣对、退书后账能不能对上。

2. 教材管理系统的数据库建模:六张表怎么把业务流程串起来

凡是最后改到想哭的课设,八成是没建模就动手。常见翻车路径是:先画了一个主界面,然后发现缺班级信息、缺计划状态、缺退书追溯,表结构改三次,DAO 层代码跟着返工三次。先花半小时把表和关系定住,比什么都值。

这一章从教材科的真实业务流程出发,带出四个核心模块,再落到六张表的设计和建表 SQL。看完你能直接拿着 SQL 去建库,不需要再做二次设计。

2.1 教材科的业务流程与四个核心模块

教材科的工作节奏是跟着学期走的。开学前,教材科拿到下学期的开课计划,按班级生成教材需求:哪个班、哪个学期、用什么教材、需要多少本。然后汇总需求、联系供应商发货,到货后入库。开学那一两周集中发书,按班级把教材发到学生手里。学期中间会有零星退书、换书。学期末要统计每个教材发了多少、还剩多少。

这个过程落到系统里,就是四个核心模块。

教材信息管理负责维护教材的基本信息和实时库存,ISBN 是唯一标识,同一种教材不管哪个班级用,都只有一条记录。院系与班级维护是基础数据模块,界面上的所有下拉框数据都从这里来。教材计划管理是中间纽带,它记录“某班某学期需要某教材多少本”,没有这张表,后面的发放就是无源之水。入库、发放与退书是核心业务模块,它不只写流水,还要同步改库存。

很多人把课设做成“教材增删改查 + 一个发放记录表”,看起来功能齐全,实际上一发书库存不动、一退书总账对不上。评审老师最爱问的就是这个问题:库存数字是怎么变的?所以要先把模块边界和状态流转想清楚。

2.2 表结构设计:主表、计划表、流水表怎么分

六张表分成三类。第一类是基础数据表:textbook、department、class_info,负责存“有什么教材、有哪些院系和班级”。第二类是关系计划表:plan,负责存“哪个班在哪个学期用什么教材、要多少本、发到什么状态”。第三类是流水表:distribution 和 return_record,负责存每次发放和退书的明细。

表名类型核心字段作用
textbook基础数据isbn, book_name, stock教材信息与实时库存
department基础数据dept_name院系列表
class_info基础数据dept_id, class_name, student_count班级信息
plan计划表class_id, textbook_id, semester, quantity, status班级教材计划与状态
distribution流水表plan_id, class_id, textbook_id, quantity每次发放留痕
return_record流水表distribution_id, textbook_id, class_id, quantity退书追溯

这个设计里有两个容易被忽略的点。一是 plan 表必须有 status 字段,它让界面能区分“还没发”和“已发放”,发放按钮的业务校验就靠它,重复发放才能被挡住。二是 return_record 不直接挂 plan,而是挂 distribution。原因是退书必须能追溯到某一次发放,一次计划可能被拆成多次发放,也可能退了两次,挂 distribution 才能把账对清楚。

我的习惯是课设里保留物理外键。外键在真实生产环境经常被拿掉,因为高并发下会影响写入性能,但课设阶段数据量小,保留外键能让表关系在答辩时一张图讲明白,也避免删数据时把关联记录误删。把外键留着,等于给数据上了保险。

2.3 建表 SQL 与初始化数据

下面这份 SQL 直接复制到 MySQL 里执行就能用。库名我用 textbook_db,你按自己习惯改。

CREATE DATABASE textbook_db DEFAULT CHARACTER SET utf8mb4; USE textbook_db; -- 教材表:教材基本信息与实时库存 CREATE TABLE textbook ( id INT PRIMARY KEY AUTO_INCREMENT, isbn VARCHAR(20) NOT NULL UNIQUE, book_name VARCHAR(100) NOT NULL, author VARCHAR(50), publisher VARCHAR(80), price DECIMAL(8,2), stock INT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 院系表 CREATE TABLE department ( id INT PRIMARY KEY AUTO_INCREMENT, dept_name VARCHAR(50) NOT NULL UNIQUE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 班级表 CREATE TABLE class_info ( id INT PRIMARY KEY AUTO_INCREMENT, dept_id INT NOT NULL, class_name VARCHAR(50) NOT NULL, student_count INT NOT NULL DEFAULT 0, UNIQUE KEY uk_dept_class (dept_id, class_name), CONSTRAINT fk_class_dept FOREIGN KEY (dept_id) REFERENCES department(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 教材计划表:一个班级一个学期用哪些教材 CREATE TABLE plan ( id INT PRIMARY KEY AUTO_INCREMENT, class_id INT NOT NULL, textbook_id INT NOT NULL, semester VARCHAR(20) NOT NULL COMMENT '如 2025-2026-1', quantity INT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0-未发放 1-已发放', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_plan (class_id, textbook_id, semester), CONSTRAINT fk_plan_class FOREIGN KEY (class_id) REFERENCES class_info(id), CONSTRAINT fk_plan_textbook FOREIGN KEY (textbook_id) REFERENCES textbook(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 发放流水表:每次发书都会写一条 CREATE TABLE distribution ( id INT PRIMARY KEY AUTO_INCREMENT, plan_id INT NOT NULL, class_id INT NOT NULL, textbook_id INT NOT NULL, quantity INT NOT NULL, operator VARCHAR(30), distribute_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_dist_plan FOREIGN KEY (plan_id) REFERENCES plan(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 退书流水表:退书必须可追溯到某次发放 CREATE TABLE return_record ( id INT PRIMARY KEY AUTO_INCREMENT, distribution_id INT NOT NULL, textbook_id INT NOT NULL, class_id INT NOT NULL, quantity INT NOT NULL, operator VARCHAR(30), return_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_return_dist FOREIGN KEY (distribution_id) REFERENCES distribution(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

几个参数说清楚。ENGINE 必须用 InnoDB,因为发放和退书要走事务回滚,MyISAM 不支持事务,选错后面所有业务逻辑都会崩。charset 用 utf8mb4,虽然这里不会存 emoji,但 utf8mb4 是 utf8 的超集,能避免个别生僻字在旧版 utf8 下存不进去的玄学问题。

semester 字段建议写成“2025-2026-1”这种可排序格式,不要写“2025春季”,否则按学期筛选时字符串比较会乱。uk_plan 唯一键是防重复计划的最后防线,Service 层可以先用 SELECT 判断,但并发下只有唯一键能真正拦住。

初始化数据也一并给到,方便后面跑界面:

INSERT INTO department (dept_name) VALUES ('计算机学院'), ('自动化学院'); INSERT INTO class_info (dept_id, class_name, student_count) VALUES (1, '计科2201', 38), (1, '软工2201', 42), (2, '自控2201', 35); INSERT INTO textbook (isbn, book_name, author, publisher, price, stock) VALUES ('978-7-000-00001-1', 'Java程序设计', '示例作者', '示例出版社', 69.00, 80), ('978-7-000-00002-8', '数据库系统概论', '示例作者', '示例出版社', 55.00, 60), ('978-7-000-00003-5', '计算机网络', '示例作者', '示例出版社', 49.50, 100);

图书管理员手动录入教材时经常敲错 ISBN,所以入库流程里我会让用户扫 ISBN,扫不到再手输,这是后话。先把数据铺好,下一步就是写代码。

3. 分层架构与 JDBC 封装:从实体类到 JTable 的最小闭环

表建好了,接下来搭代码骨架。很多课设把所有代码塞进一个几百行的 MainFrame 里,窗口类又当界面又当 DAO 又当业务层,最后改一个查询按钮能牵连出七八处报错。这一章按三层结构走:entity 管数据、dao 管 SQL、service 管业务和事务,ui 只管画界面和绑事件。

3.1 包结构划分:ui、service、dao、entity 各管什么

项目根目录下按包分四个区,再加一个 util 放公共工具:

com.demo.textbook ├── entity // 实体类,与表字段一一对应 ├── dao // 数据访问,只写 SQL 和 JDBC ├── service // 业务逻辑,事务在这里控制 ├── ui // Swing 窗口与事件 └── util // 数据库连接工具

分层的规矩只有三条。ui 层不写 SQL,所有数据都通过 service 拿,这样换界面不影响逻辑。dao 层只做单表增删改查,不写 if else 业务判断,拿到什么返回什么。事务放在 service 层,因为一个业务操作往往要更新两张表以上,只有 service 层能决定什么时候 commit、什么时候 rollback。

依赖方面只需要一个 mysql-connector-java 的 jar 包,版本选和本机 MySQL 匹配的即可。用 IDE 新建项目时导入 jar,或者用 Maven 加一条 dependency,两种方式都行,课设阶段不必纠结构建工具。记得连的是 MySQL,驱动包别下成 MariaDB 的。

3.2 实体类与 JDBC 工具类:先把连接参数写对

实体类字段和表列名对应,命名用驼峰,表里用下划线,靠 ResultSet 映射时手动对应。以教材表为例:

package com.demo.textbook.entity; import java.math.BigDecimal; public class Textbook { private Integer id; private String isbn; private String bookName; private String author; private String publisher; private BigDecimal price; private Integer stock; // 无参构造、getter、setter 省略 }

BigDecimal 对应 DECIMAL 类型,别用 Double 存价格,否则单价 69.00 在计算时可能出现 69.00000001 的浮点误差,答辩时被问到会很尴尬。Integer 对应 INT,注意数据库里 id 用自增,实体里保持 Integer 可空,避免插入前就生成一个无意义的值。

连接工具是全局唯一的 JDBC 入口:

package com.demo.textbook.util; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class DbUtil { private static final String URL = "jdbc:mysql://localhost:3306/textbook_db" + "?useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai"; private static final String USER = "root"; private static final String PASSWORD = "你的密码"; public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }

注意 URL 里三个参数缺一不可。characterEncoding=utf8 保证中文正常写入读出,不设它在某些 MySQL 版本下中文会变问号。serverTimezone=Asia/Shanghai 保证日期时间不错位,不设它可能差 8 小时。useSSL=false 是本地开发的标准配置,省去证书警告。新版驱动已经不用写 Class.forName 注册了,JDBC 4.0 起驱动会自动加载,网上老教程里的写法可以忽略。

3.3 教材列表 JTable:第一条数据的完整链路

从数据库到界面,先走通“查询教材列表”这一个场景,后面所有模块都照这个套路复制。DAO 层写 SQL:

package com.demo.textbook.dao; import com.demo.textbook.entity.Textbook; import com.demo.textbook.util.DbUtil; import java.sql.*; import java.util.ArrayList; import java.util.List; public class TextbookDao { public List<Textbook> findAll() { String sql = "SELECT id, isbn, book_name, author, publisher, price, stock " + "FROM textbook ORDER BY id DESC"; List<Textbook> list = new ArrayList<>(); try (Connection conn = DbUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { while (rs.next()) { Textbook t = new Textbook(); t.setId(rs.getInt("id")); t.setIsbn(rs.getString("isbn")); t.setBookName(rs.getString("book_name")); t.setAuthor(rs.getString("author")); t.setPublisher(rs.getString("publisher")); t.setPrice(rs.getBigDecimal("price")); t.setStock(rs.getInt("stock")); list.add(t); } } catch (SQLException e) { throw new RuntimeException("查询教材列表失败", e); } return list; } }

这里用了 try-with-resources,Connection、PreparedStatement、ResultSet 三个资源自动关闭,这是防止连接泄漏的最有效手段。SQL 里字段显式列出来,不用 SELECT *,因为接口返回的实体字段要和表列名一一对上,写清楚好维护。查询异常直接包成 RuntimeException 往外抛,让上层统一弹窗提示。

接着是主窗口,JTable 用 DefaultTableModel 作为数据模型:

package com.demo.textbook.ui; import com.demo.textbook.entity.Textbook; import com.demo.textbook.service.TextbookService; import javax.swing.*; import javax.swing.table.DefaultTableModel; import java.util.List; public class MainFrame extends JFrame { private final TextbookService textbookService = new TextbookService(); private final DefaultTableModel tableModel = new DefaultTableModel( new Object[]{"ID", "ISBN", "书名", "作者", "出版社", "单价", "库存"}, 0); private JTable table; public MainFrame() { setTitle("高校教材管理系统"); setSize(900, 600); setDefaultCloseOperation(EXIT_ON_CLOSE); initUI(); loadTextbookData(); setLocationRelativeTo(null); } private void initUI() { table = new JTable(tableModel); table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); add(new JScrollPane(table), java.awt.BorderLayout.CENTER); } private void loadTextbookData() { tableModel.setRowCount(0); List<Textbook> list = textbookService.findAll(); for (Textbook t : list) { tableModel.addRow(new Object[]{ t.getId(), t.getIsbn(), t.getBookName(), t.getAuthor(), t.getPublisher(), t.getPrice(), t.getStock() }); } } }

这个模式的要点是:tableModel 是常驻对象,刷新时先 setRowCount(0) 清空,再重新 addRow。这样表格的列结构、列宽、选中状态都不会被破坏。刷新动作在数据变更后统一调用 loadTextbookData(),谁改完数据都走同一个入口,不会出现“数据库改了但界面不更新”的问题。

到这里,教材查询的最小闭环已经通了:窗口打开 → service 调 dao → dao 查库 → 结果逐行填进 model → JTable 显示。后面的教材新增、发放、退书全是这个链路加按钮、加参数。

4. 核心业务实现:入库、计划、发放、退书的事务与库存联动

这一章是整套系统的价值所在。单表增删改查人人会写,但“入库加库存、发放扣库存、退书回库存”这三个动作必须在一个事务里完成,否则账就对不上。我按业务流程的顺序来讲,从入库开始,到计划,再到发放和退书,最后落到统计报表。

4.1 教材入库:按 ISBN 判断新增还是补库存

入库有两种情况:一种是从没进过的新教材,要 INSERT 一条记录并设初始库存;另一种是已有教材补货,比如开学后发现某本教材不够,追加采购,这时要 UPDATE 把 stock 累加。判断标准只有一个:ISBN 在表里存不存在。

public void saveOrUpdateStock(String isbn, String bookName, String author, String publisher, BigDecimal price, int qty) { Connection conn = null; try { conn = DbUtil.getConnection(); conn.setAutoCommit(false); Integer id = findIdByIsbn(conn, isbn); if (id == null) { insertTextbook(conn, isbn, bookName, author, publisher, price, qty); } else { addStock(conn, id, qty); } conn.commit(); } catch (SQLException e) { rollbackQuietly(conn); throw new RuntimeException("入库失败,已回滚", e); } finally { closeQuietly(conn); } } private Integer findIdByIsbn(Connection conn, String isbn) throws SQLException { String sql = "SELECT id FROM textbook WHERE isbn = ?"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, isbn); try (ResultSet rs = ps.executeQuery()) { return rs.next() ? rs.getInt(1) : null; } } } private void addStock(Connection conn, int textbookId, int qty) throws SQLException { String sql = "UPDATE textbook SET stock = stock + ? WHERE id = ?"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, qty); ps.setInt(2, textbookId); ps.executeUpdate(); } }

这里有三点值得说明。为什么用 conn.setAutoCommit(false) 再手动 commit:入库涉及 SELECT 判断和 INSERT/UPDATE 两步,如果中间任何一步失败,之前做的修改必须全部撤销,否则会出现“插入了教材但库存没写进去”的脏数据。为什么 SELECT 和 UPDATE 用同一个 conn:事务必须建立在同一个连接上,如果用 service 里每次调 dao 都 new Connection,事务就失效了。为什么传参是 qty 而不是 newStock:SQL 里写 stock = stock + ?,让数据库自己算,避免先查再算的竞态问题。

rollbackQuietly 和 closeQuietly 是两个工具方法,无非是 try-catch 包住 rollback 和 close,不让清理动作本身再抛异常打断主流程。入库界面上的“数量”输入框,建议设置只能输入正整数,这个校验放界面层做,省得非法数据一路传到 SQL 里。

4.2 生成教材计划:班级、学期与数量的三重校验

计划表是发放的依据。界面上通常是一组联动下拉框:选院系,班级下拉框过滤出该院系的班级;选教材;输入学期和数量。点“添加”后写入 plan 表,status 默认 0。

public void addPlan(int classId, int textbookId, String semester, int quantity) { String insert = "INSERT INTO plan(class_id, textbook_id, semester, quantity, status) " + "VALUES(?,?,?,?,0)"; try (Connection conn = DbUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(insert)) { ps.setInt(1, classId); ps.setInt(2, textbookId); ps.setString(3, semester); ps.setInt(4, quantity); ps.executeUpdate(); } catch (SQLException e) { throw new RuntimeException("该班级本学期已存在这本教材的计划,请直接修改数量", e); } }

三重校验缺一不可。第一重,界面上判断班级、教材、学期都不能为空,学期格式用正则校验成“2025-2026-1”的样子。第二重,Service 层在 INSERT 前先用 uk_plan 对应的三个字段查一遍,存在就提示修改数量而不是新增。第三重,数据库唯一键 uk_plan 兜底,界面上两个用户同时点提交时,只有数据库能拦住第二次插入。捕获 SQLException 时把唯一键冲突翻译成用户能看懂的话,别把 SQL 的异常堆栈直接弹到界面上。

数量一般不让学生手输,默认取 class_info.student_count,因为一个学生一套教材,这个默认值能减少误操作。如果实际需求不是一人一套,比如实验课教材两人一套,再允许手工改。

4.3 发放与退书:一个事务里完成库存和流水联动

发放是整套系统里最容易出 bug 的地方。一次发放要动三张表:扣 textbook 的库存、改 plan 的状态、写 distribution 流水。任何一步失败都要整体回滚。下面这个方法是发放的核心逻辑:

public void deliver(int planId, String operator) { Connection conn = null; try { conn = DbUtil.getConnection(); conn.setAutoCommit(false); // 1. 锁定计划行,取出教材、数量、状态 String sqlPlan = "SELECT textbook_id, quantity, status FROM plan WHERE id = ? FOR UPDATE"; int textbookId, quantity, status; try (PreparedStatement ps = conn.prepareStatement(sqlPlan)) { ps.setInt(1, planId); try (ResultSet rs = ps.executeQuery()) { if (!rs.next()) { throw new SQLException("计划不存在"); } textbookId = rs.getInt("textbook_id"); quantity = rs.getInt("quantity"); status = rs.getInt("status"); } } if (status == 1) { throw new SQLException("该计划已发放,不能重复发放"); } // 2. 校验库存是否足够 int stock = queryStock(conn, textbookId); if (stock < quantity) { throw new SQLException("库存不足,当前库存 " + stock + ",需要 " + quantity); } // 3. 扣减库存 updateStock(conn, textbookId, -quantity); // 4. 更新计划状态为已发放 updatePlanStatus(conn, planId, 1); // 5. 写入发放流水 insertDistribution(conn, planId, textbookId, quantity, operator); conn.commit(); } catch (SQLException e) { rollbackQuietly(conn); throw new RuntimeException("发放失败:" + e.getMessage(), e); } finally { closeQuietly(conn); } } private void updateStock(Connection conn, int textbookId, int delta) throws SQLException { String sql = "UPDATE textbook SET stock = stock + ? WHERE id = ?"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, delta); ps.setInt(2, textbookId); ps.executeUpdate(); } }

SELECT ... FOR UPDATE 是行级锁,多个人同时点发放时,第二个事务要等第一个提交才能读这条计划,这就在数据库层面挡住了并发重复发放。课设阶段虽然并发量低,但这个写法等于在答辩时告诉老师你考虑过并发场景,加分项。

校验库存放在扣减之前,用的是 SELECT 查出来的 stock。如果担心查到扣之间被改,可以在同一个事务里用 SELECT stock FROM textbook WHERE id = ? FOR UPDATE 把库存行也锁住。课设里一般不需要,但写上去也没坏处。

退书是发放的逆操作,逻辑镜像:按 distribution_id 查出原发放记录的教材和数量,校验退书数量不能大于原发放数量,然后 UPDATE textbook SET stock = stock + 退书数量,再 INSERT 一条 return_record。同样要包在事务里。退书不动 plan 的 status,因为班级已经领过书了,计划状态不需要回退。退书数量允许小于发放数量,比如一个班退 5 本,记录里退还 5 本,库存加 5 本。

这里有一个常见的思路误区:不要在 textbook 表里加一个“已发放总数”字段。已发放数量在需要时用 SELECT SUM(quantity) FROM distribution GROUP BY textbook_id 去算,库存是唯一需要实时维护的数字。冗余一个发放总数,每次发放要改两个字段,一旦字段值不一致,账就永远对不上了。

4.4 统计报表:一条 GROUP BY 撑起汇总页

教材科期末要交的报表,无非是按教材看发了多少、涉及几个班、还剩多少。这条 SQL 就能出结果:

SELECT t.book_name, t.publisher, COUNT(DISTINCT d.class_id) AS class_count, SUM(d.quantity) AS total_qty FROM distribution d JOIN textbook t ON d.textbook_id = t.id GROUP BY t.id, t.book_name, t.publisher ORDER BY total_qty DESC;

GROUP BY 后面带 t.id 再带 t.book_name 和 t.publisher,是 MySQL 的 ONLY_FULL_GROUP_BY 模式要求。如果你只 GROUP BY t.book_name,而 SELECT 里还出现别的列,MySQL 5.7 以上会直接报错。这是新手写报表 SQL 最常见的报错点。

界面展示和之前的教材列表一样,把 ResultSet 的每一行转成 Object[] 加进 DefaultTableModel 就行。如果你想再加一个“剩余库存”列,把 textbook.stock 也查出来放进去,报表页就完整了。

统计页建议只支持查询,不做编辑,报表数据都是从流水表实时算出来的,天然准确。不要为了“好看”把统计结果缓存到一张新表里,缓存逻辑一旦出错,报表和实际流水对不上,这个坑踩进去很难出来。

5. 避坑记录:Swing 卡死、中文乱码、连接泄漏与日期时区

代码写到能跑之后,调试时间其实比写代码时间长。这一章把我自己踩过和帮别人排查过的坑集中列出来,每一条都是“现象 → 原因 → 解决”,按发生率从高到低排。

5.1 点查询窗口卡死:耗时操作堵住了 EDT

现象:点“查询”按钮后,整个窗口白屏、鼠标转圈,等好几秒才恢复,期间窗口不能拖动、不能最小化。

原因:Swing 的界面刷新运行在 EDT 单线程上,查询教材列表的 JDBC 操作直接在按钮监听器里同步执行,把界面线程堵死了。连接慢、SQL 慢、数据量大,卡的时间就越长。

解决:耗时操作放进 SwingWorker 的后台线程,执行完再回到 EDT 刷新界面:

new SwingWorker<List<Textbook>, Void>() { @Override protected List<Textbook> doInBackground() { return textbookService.findAll(); } @Override protected void done() { try { List<Textbook> list = get(); refreshTable(list); } catch (Exception e) { JOptionPane.showMessageDialog(MainFrame.this, "查询失败:" + e.getMessage()); } } }.execute();

关键点在 done 方法里调 get(),如果 doInBackground 抛了异常,异常会在 get() 时重新抛出,所以 catch 要包在 get() 外面。界面刷新 actionPerformed 里只调 execute(),不要调 get(),否则又会卡住。这个模式对所有耗时操作统一适用:查询、导入导出、报表生成,全走 SwingWorker。

5.2 界面中文全是问号:字符集三处不一致

现象:控制台打印数据库数据是正常中文,但 Swing 界面和 JOptionPane 弹窗里全是问号;或者反过来,库里的中文是乱码。

原因:字符集在三个地方不一致。源文件编译编码、MySQL 连接字符集、表和数据本身的字符集,任何一个对不上就出问题。最常见的是 IDE 默认 GBK 编译,代码里的中文字符串按 GBK 编码,而连接参数 characterEncoding=utf8,两边对不上。

解决:IDE 里把项目编码统一成 UTF-8,一般都在设置里的 File Encodings 里改,源文件全选转码一次。JDBC URL 带 characterEncoding=utf8,建表语句用 DEFAULT CHARSET=utf8mb4。如果已经写入的数据是问号,那说明数据在入库时就坏了,改参数后要重新插入,不能靠显示层挽回。这个坑的排查顺序是:先看数据库里存的对不对,再看到底是连接问题还是编译问题。

5.3 第二天连不上数据库:连接泄漏与超时

现象:程序刚启动时一切正常,用一两个小时后点任何按钮都报 Communications link failure,重启程序又好了。

原因:一是连接泄漏。某个 DAO 方法里 new 了 Connection 但没 close,连接数被耗尽,数据库拒绝新连接。二是 MySQL 的 wait_timeout 默认 8 小时,空闲连接被服务端断开,而程序里持有的旧连接没感知,继续用就报错。

解决:DAO 层全部用 try-with-resources 自动关闭,事务方法在 finally 里 close。排查泄漏最直接的办法是重启后连续操作每个功能,然后查 MySQL 的进程列表,看连接数是否只增不减:

SHOW STATUS LIKE 'Threads_connected';

每次操作后连接数稳定回到基线,说明没有泄漏。如果课设的功能模块比较多,可以考虑用连接池,常见做法是引入 HikariCP 或 Druid,把连接管理交给池子。这个属于加分项,不是必选,但能治本。

5.4 增删改后表格不刷新:忘了通知 TableModel

现象:数据库里数据确实变了,但 JTable 还是显示老数据,重启程序才看到新记录。

原因:JTable 显示的数据完全由 TableModel 决定,执行了 INSERT/UPDATE 之后没有把新数据同步进 model。最常见的写法错误是重新 new 了一个 DefaultTableModel 并 setModel,而不是在原有 model 上操作,这样视图确实更新了,但后续刷新逻辑全乱。

解决:统一用一个刷新方法,先 setRowCount(0) 清空,再循环 addRow 或直接 fireTableDataChanged。我在前面的 MainFrame 里就是这么做的,所有按钮在完成写操作后都调用 loadTextbookData()。注意新增和删除后不要沿用旧的 List,重新从 service 查一遍,保证内存数据和数据库一致。

5.5 发放时间差 8 小时:时区参数没对齐

现象:distribution 表的 distribute_time 显示的时间比本地时间晚 8 小时,或者读出来到界面又早 8 小时。

原因:JDBC 驱动和 MySQL 服务端时区不一致。旧版驱动默认按 UTC 解析时间,而 MySQL 存的是本地时间,读出来就偏了 8 小时。反之如果 MySQL 时区设置有问题,写入也会偏移。

解决:JDBC URL 加 serverTimezone=Asia/Shanghai,建库时 MySQL 服务端起就配置好时区。Java 侧用 LocalDateTime 接收,不要用 java.util.Date 和 SimpleDateFormat。显示时用 DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") 格式化,格式统一,不容易再出差错。顺带一提,数据库的 DATETIME 存的是无时区的本地时间,TIMESTAMP 才会受时区影响,这里用 DATETIME 是合适的。

6. 把课设从「能跑」做到「能讲」:演示脚本与答辩前的自测清单

程序能跑只是及格线,答辩时能把业务逻辑讲清楚才是高分的关键。这一章送你一套演示脚本和自测清单,照着走一遍,基本能把所有功能的亮点都Show出来,也把常见翻车点提前排掉。

6.1 三分钟连贯演示脚本

演示最忌讳点开一个界面讲一个界面,东一榔头西一棒子。我常用的脚本是一条业务线走到底:先进入教材计划模块,选“计科2201”、学期填“2025-2026-1”、选《Java程序设计》,数量用默认的 38,添加计划。然后切换到库存管理,录入同一本教材的 ISBN 和数量 100,完成入库,这时候界面上的库存从 80 变成 180,一定要让评审看到数字变化。

接着回到计划列表,选中刚才那条计划点“发放”,提示发放成功,再看教材列表,库存从 180 变成 142,这个 38 就是刚才计划的数量。最后做一次退书,退 2 本,库存回到 144,再看统计报表,按发放量排序,《Java程序设计》排第一。整条线下来,入库、计划、发放、退书、统计、库存联动全部覆盖,时间刚好三分钟。

6.2 用 SwingWorker 导出 CSV

很多课设会要求有数据导出,不导出也能答辩,但导出是展示异常处理和线程用法的好素材。用 SwingWorker 把列表写成 CSV 文件,后台跑导出不卡界面:

new SwingWorker<Void, Void>() { @Override protected Void doInBackground() throws Exception { try (java.io.FileWriter fw = new java.io.FileWriter("教材库存.csv", true)) { fw.write("ISBN,书名,库存\n"); for (Textbook t : textbookService.findAll()) { fw.write(t.getIsbn() + "," + t.getBookName() + "," + t.getStock() + "\n"); } } return null; } @Override protected void done() { JOptionPane.showMessageDialog(MainFrame.this, "导出完成"); } }.execute();

注意 CSV 里如果书名叫含逗号,要用引号包起来,这里为了演示简洁没有处理,你自己做的时候加一个转义即可。导出完成后的 done 方法里弹窗,就是一个最直观的“后台任务完成回主线程”的演示。

6.3 答辩前自测清单

检查项预期结果
库存不足时发放弹出“库存不足”提示,库存和流水都不变
重复发放同一计划提示已发放,拒绝执行
重复添加同一班级同一学期同一教材提示已存在,不新增
退书后查库存库存增加,退书流水有记录
连续操作所有功能后执行连接数查询连接数不持续增长

这套清单总耗时不超过十分钟。最后说一句我自己的教训:早期做类似项目时,我也是先画界面再补表结构,结果界面改了三版,DAO 层重写了两次。后来养成的习惯是先花半天把表结构和状态流转画清楚,再落到代码,反而省下后面两天的返工时间。希望帮到你。

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

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

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

立即咨询