Java图书馆管理系统实训:事务SQL与借还数据一致性

发布时间:2026/10/23 14:05:04
Java图书馆管理系统实训:事务SQL与借还数据一致性
简介这套图书馆管理系统工程包面向高校Java课程实训与课程设计入门围绕图书与读者信息维护、借阅归还、查询统计等模块展开覆盖数据库建模、JDBC数据访问、Swing/JavaFX界面搭建以及MVC分层等训练点适合正在完成Java实训任务、需要参考完整项目结构的学生。压缩包约6MB共1988个文件主体是Eclipse工作空间产物676个xmi、28个index、27个launch与75个prefs构成IDE工程与运行配置30个java源码与128个class编译结果对应核心业务实现27个xml承担配置描述另含mdf、ldf两个SQL Server数据库文件及properties、txt说明。已有230人学习。读者可据此还原图书表、读者表、借阅记录表的字段设计与表间关系对照可借判断、超期校验、权限控制与异常处理的实现方式并借助运行日志定位执行过程理解一个可运行的小型管理系统骨架。1. 一份 Java 图书馆管理系统实训包真正值钱的是那几条事务 SQL每年实训周搜「图书馆管理系统」的人能挤满搜索结果第一页但真正下载下来、解开压缩包之后多半是把 SQL 直接写死在按钮事件里的半成品界面点得动数据对不上。这份 Java 实训任务包真正值钱的地方不在窗口有多好看而在于它把图书、读者、借阅这三条线用一个真正能跑通的事务串了起来——借书时扣库存和写流水在同一个连接里提交还书时才结算超期罚款。它适合两类人一是两三天内要交课程设计、手里得有个能当场演示借书、还书、罚款的东西二是界面已经写完了但被问一句「两个人同时借最后一本会怎样」就答不上来的。下面按表怎么建、DAO 怎么把连接和事务统一管理、规则放哪一层、界面怎么不卡、现场怎么不翻车这条线往下推每一步都落到能直接抄的代码和参数上。2. 表结构先定死图书、读者、借阅三张表怎么撑住借还闭环2.1 先砍功能把「能演示闭环」当第一优先级实训项目最常见的失败不是写不出来是摊子铺太大。常见的评分点其实就那么几个图书增删改查、读者增删改查、借书、还书、查询借阅记录、超期罚款。推荐位、评论、借阅排行榜、数据大屏这些属于装饰做完了不加分做一半反而拖垮主流程。我一般会先把角色收敛成两个管理员和读者。管理员能管书、管人、办借还读者只能查书和自己的借阅记录。这样权限判断就退化成一个登录后的role字段不需要引入任何框架。分层也照最朴素的三层来ui放 Swing 面板service放业务规则dao放 SQLentity放实体util放数据库工具和日期工具。这个结构看着土但答辩时老师问「你的业务逻辑写在哪」你能一句话指到service包比什么都强。真正需要提前想清楚的是「借阅状态」怎么表示。很多包子里同时有status字段和return_date字段一个说已还、一个还是空两边一不同步库存就永久性地少一本。这里的做法是干脆不要statusreturn_date IS NULL就是未还非空就是已还状态只有一个来源从根上消灭不一致。2.2 三张表的字段与约束表关键字段类型说明bookidBIGINT 自增主键借阅流水只存 idisbnVARCHAR(20)唯一索引防重复录入title/authorVARCHAR(120) / VARCHAR(60)书名、作者书名建普通索引total_copiesINT馆藏总数录入后一般不改avail_copiesINT可借数借减还增是并发核心readeridBIGINT 自增主键card_noVARCHAR(20)借书证号唯一索引max_borrowINT最大在借数默认 5statusTINYINT1 正常 / 0 冻结冻结后不允许借borrow_recordbook_id/reader_idBIGINT两个外键列只建索引不建物理外键borrow_date/due_dateDATE借出日与应还日应还日 借出日 借期return_dateDATE NULLNULL 表示未还这是唯一状态来源fineDECIMAL(8,2)还书时结算的罚款未还时为 0avail_copies和total_copies分成两个字段是为了让「扣库存」变成一条带条件的 UPDATE而不是先 SELECT 再判断——这一点在第 3 章会展开。fine用 DECIMAL 而不是 DOUBLE是因为金额一旦用浮点0.1 0.2 这类误差会在对账时让你怀疑人生血泪经验。至于外键实训环境里我不建物理外键。原因很实际外键会让删书的顺序变得很讲究演示时点一下「删除图书」直接抛约束异常很难看。替代方案是在service层删书前查一次是否有未还记录有就提示「该书还有 3 本未归还不能删除」。约束交给应用层索引交给数据库。2.3 建库脚本与索引取舍CREATE DATABASE IF NOT EXISTS libdb DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE libdb; CREATE TABLE book ( id BIGINT NOT NULL AUTO_INCREMENT, isbn VARCHAR(20) NOT NULL, title VARCHAR(120) NOT NULL, author VARCHAR(60) NOT NULL DEFAULT , category VARCHAR(30) NOT NULL DEFAULT 未分类, price DECIMAL(8,2) NOT NULL DEFAULT 0.00, total_copies INT NOT NULL DEFAULT 1, avail_copies INT NOT NULL DEFAULT 1, PRIMARY KEY (id), UNIQUE KEY uk_isbn (isbn), KEY idx_title (title) ) ENGINEInnoDB; CREATE TABLE reader ( id BIGINT NOT NULL AUTO_INCREMENT, card_no VARCHAR(20) NOT NULL, name VARCHAR(30) NOT NULL, phone VARCHAR(20) NOT NULL DEFAULT , max_borrow INT NOT NULL DEFAULT 5, status TINYINT NOT NULL DEFAULT 1, PRIMARY KEY (id), UNIQUE KEY uk_card (card_no) ) ENGINEInnoDB; CREATE TABLE borrow_record ( id BIGINT NOT NULL AUTO_INCREMENT, book_id BIGINT NOT NULL, reader_id BIGINT NOT NULL, borrow_date DATE NOT NULL, due_date DATE NOT NULL, return_date DATE NULL, fine DECIMAL(8,2) NOT NULL DEFAULT 0.00, PRIMARY KEY (id), KEY idx_reader_open (reader_id, return_date), KEY idx_book_open (book_id, return_date) ) ENGINEInnoDB;字符集统一写utf8mb4别用utf8——后者在 MySQL 里其实是三字节的阉割版书名里出现生僻字或者书名带表情符号就会截断报错而这个错在演示当天出现概率极高。两个联合索引idx_reader_open (reader_id, return_date)和idx_book_open (book_id, return_date)是为两个高频查询准备的一是「查某读者当前未还的几本」二是「查某本书当前被谁借着」。return_date IS NULL这种条件在联合索引里前缀能命中reader_id后面的IS NULL走索引扫描数据量上千条时肉眼可见地快。idx_title这个普通索引要打个问号如果模糊查询写成LIKE %java%前导通配符会让索引彻底失效建了也白建。它的价值只在于LIKE java%这种前缀匹配或者排序场景。我在实训包里保留它是因为答辩时被问到「你建索引的依据是什么」能顺势讲清楚最左前缀和失效场景比索引本身更有用。提示建表脚本单独存成一个sql/schema.sql文件初始化时用客户端跑一遍不要塞进 Java 代码里用Statement拼。演示现场重装数据库的概率比你想象的高。3. JDBC 数据访问层把连接、事务和异常统一收口到一个模板里3.1 接口先行DAO 层为什么值得多写一个 interface实训项目里BookDao通常只有一个实现写接口看起来是纯浪费。但它换来两样东西一是所有 SQL 集中在一个文件里改字段不用满项目搜字符串二是答辩时「如果换成 SQL Server 怎么办」这种问题有标准答案——再写一个实现类就行service一行不用动。接口只声明业务语义不暴露 JDBC 类型public interface BookDao { OptionalBook findById(long id); ListBook search(String keyword, int offset, int limit); int countByKeyword(String keyword); // 分页总数单独查 int decreaseAvailable(long bookId); // 返回受影响行数0 表示没库存 int increaseAvailable(long bookId); // 归还时 1带上限保护 long insert(Book book); int deleteById(long id); }decreaseAvailable和increaseAvailable返回int而不是void这是刻意的调用方必须检查返回值。返回 0 就说明条件没命中要么书不存在要么已经被借空。把「够不够」的判断下沉到 SQL 的WHERE里是这个项目里最值钱的一个习惯。3.2 配置外置与一个事务模板连接串不要硬编码。放一份db.properties在src/main/resources下jdbc.urljdbc:mysql://127.0.0.1:3306/libdb?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue jdbc.userlib jdbc.passwordlib123characterEncodingutf8保证中文入库不乱码serverTimezoneAsia/Shanghai不写的话某些驱动版本读 DATE 会整体偏移一天罚款直接多算一天useSSLfalse只是免掉本地连接时的证书警告正式环境别这么干。这三个参数是排查阶段最常见的三个坑先在这里堵住。工具类提供一个事务模板把所有setAutoCommit(false)、commit、rollback、close都收在一处public final class Db { private static final String URL; private static final String USER; private static final String PWD; static { Properties p new Properties(); // 用类加载器读打成 jar 后依然能找到 try (InputStream in Db.class.getClassLoader().getResourceAsStream(db.properties)) { if (in null) throw new IllegalStateException(db.properties 不在 classpath 里); p.load(in); } catch (IOException e) { throw new ExceptionInInitializerError(e); } URL p.getProperty(jdbc.url); USER p.getProperty(jdbc.user); PWD p.getProperty(jdbc.password); } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PWD); } /** 事务模板work 正常返回则提交抛任何异常则回滚 */ public static T T inTx(SqlWorkT work) { Connection conn null; try { conn getConnection(); conn.setAutoCommit(false); T result work.doIn(conn); conn.commit(); return result; } catch (SQLException e) { rollbackQuietly(conn); throw new DataAccessException(数据库操作失败, e); } catch (RuntimeException e) { rollbackQuietly(conn); throw e; // 业务异常原样往上抛界面好提示 } finally { closeQuietly(conn); } } private static void rollbackQuietly(Connection conn) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ignore) { } } } private static void closeQuietly(Connection conn) { if (conn ! null) { try { conn.close(); } catch (SQLException ignore) { } } } }inTx的泛型参数SqlWorkT就是一个只声明T doIn(Connection conn) throws SQLException的函数式接口。这样写的好处是业务代码里看不到一句commit但每一个借还操作都天然在事务里。如果以后想换连接池只改getConnection()一行返回dataSource.getConnection()即可其余代码完全不动。要注意finally里只关连接、不管提交。close()对已经提交的连接是幂等的但如果commit()抛了异常rollback已经在 catch 里走过了不会出现半提交。3.3 借书、还书两条 SQL 必须同生共死借书要做两件事扣库存、写流水。这两件事分开提交就会出现「库存少了但查不到借阅记录」的诡异数据而且这种数据一旦产生界面上根本看不出来只能靠对账 SQL 发现。所以整个方法体必须包在inTx里public long borrow(long readerId, long bookId, LocalDate today, int loanDays) { return Db.inTx(conn - { // 1) 条件更新扣库存够不够交给数据库判断避免先查后改的竞态 try (PreparedStatement ps conn.prepareStatement( UPDATE book SET avail_copies avail_copies - 1 WHERE id ? AND avail_copies 0)) { ps.setLong(1, bookId); if (ps.executeUpdate() 0) { throw new BizException(这本书已经全部借出或图书不存在); } } // 2) 写借阅流水应还日 借出日 借期 try (PreparedStatement ps conn.prepareStatement( INSERT INTO borrow_record(book_id, reader_id, borrow_date, due_date, fine) VALUES(?,?,?,?,0.00), Statement.RETURN_GENERATED_KEYS)) { ps.setLong(1, bookId); ps.setLong(2, readerId); ps.setObject(3, today); // JDBC 4.2 支持 LocalDate 直传 ps.setObject(4, today.plusDays(loanDays)); ps.executeUpdate(); try (ResultSet keys ps.getGeneratedKeys()) { keys.next(); return keys.getLong(1); // 返回流水号界面可用于跳转 } } }); }关键点在第一条 SQL 的WHERE avail_copies 0。假设不做这个条件而是先SELECT avail_copies判断大于 0 再去 UPDATE那么两个人同时点「借阅」时两个线程可能都读到 1都判断通过最后库存变成 -1。加上条件之后第二条 UPDATE 会因为avail_copies已经是 0 而匹配不到行executeUpdate()返回 0直接抛业务异常回滚。整个过程依赖 InnoDB 的行锁不需要在 Java 里加任何synchronized。还书稍复杂一点需要防止同一条记录被还两次用户手抖点两下或者界面没禁用按钮public BigDecimal giveBack(long recordId, LocalDate today) { return Db.inTx(conn - { long bookId; LocalDate due; // 1) 锁住这条流水同时确认它确实还没还 try (PreparedStatement ps conn.prepareStatement( SELECT book_id, due_date FROM borrow_record WHERE id ? AND return_date IS NULL FOR UPDATE)) { ps.setLong(1, recordId); try (ResultSet rs ps.executeQuery()) { if (!rs.next()) throw new BizException(该借阅记录不存在或已归还); bookId rs.getLong(book_id); due rs.getObject(due_date, LocalDate.class); } } // 2) 结算罚款逾期天数 × 日罚金封顶 BigDecimal fine FineCalc.of(due, today); // 3) 回写流水 try (PreparedStatement ps conn.prepareStatement( UPDATE borrow_record SET return_date ?, fine ? WHERE id ?)) { ps.setObject(1, today); ps.setBigDecimal(2, fine); ps.setLong(3, recordId); ps.executeUpdate(); } // 4) 库存 1用 total_copies 兜底防脏数据把可借数刷爆 try (PreparedStatement ps conn.prepareStatement( UPDATE book SET avail_copies avail_copies 1 WHERE id ? AND avail_copies total_copies)) { ps.setLong(1, bookId); ps.executeUpdate(); } return fine; }); }FOR UPDATE在这里不是可有可无的装饰。没有它两个线程可能同时读到return_date IS NULL都通过校验然后各自 UPDATE 一次库存加 2罚款也算两遍。加上行锁第二个线程会阻塞到第一个提交之后它读到的return_date已经非空直接走「已归还」分支。第 4 步的avail_copies total_copies是给自己留的后悔药。正常流程下不会触发但手工改过数据库、或者早期版本留下脏数据时这个条件能防止可借数超过馆藏数——那种数据在界面上显示成「可借 8 / 共 5 本」一眼假。4. 规则与界面借阅校验、超期罚款、分页模糊查询怎么落地4.1 借阅前置校验四条规则全部沉到 Service校验写在按钮的actionPerformed里是这个项目最常见的结构性问题同一套规则在「借书」按钮写一遍在「续借」再写一遍改一次漏一处。正确的位置是BorrowService.borrow()的第一行用一条 SQL 把需要的上下文一次取全public void checkBorrowable(long readerId, long bookId, LocalDate today) { String sql SELECT r.status, r.max_borrow, (SELECT COUNT(*) FROM borrow_record b WHERE b.reader_id r.id AND b.return_date IS NULL) AS holding, (SELECT COUNT(*) FROM borrow_record b WHERE b.reader_id r.id AND b.return_date IS NULL AND b.due_date ?) AS overdue, (SELECT COUNT(*) FROM borrow_record b WHERE b.reader_id r.id AND b.book_id ? AND b.return_date IS NULL) AS same_book FROM reader r WHERE r.id ?; // ... 执行后依次判断 // 1) status ! 1 - 该读者已被冻结无法借阅 // 2) holding maxBorrow - 在借数量已达上限 N 本 // 3) overdue 0 - 有 N 本已超期未还请先归还 // 4) sameBook 0 - 同一本书不允许重复借阅 }四条规则的顺序是有讲究的冻结优先于数量数量优先于逾期逾期优先于重复借。因为用户最需要知道的是「最该处理的那件事」先告诉他「你被冻结了」比告诉他「你借满了」有用。这三个子查询看起来有点重但在千级数据量下毫秒级返回比拆成三条 SQL 再在 Java 里拼装清晰得多。真到了性能瓶颈再换成一次GROUP BY聚合也不迟。4.2 超期天数与罚款日期类型统一到 LocalDate日期是这个项目里玄学最多的地方。java.util.Date、java.sql.Date、Timestamp、LocalDate四种类型混着用时区一掺和逾期天数能差出一天。我的习惯是全链路只用LocalDate实体类字段用LocalDateDAO 用ps.setObject()和rs.getObject(col, LocalDate.class)Swing 里用LocalDate.parse()解析输入框。中间不留任何Date的转换点。罚款计算单独抽成一个静态类参数全部外置成常量答辩时被问「罚款规则怎么改」直接指这里public final class FineCalc { /** 日罚金元/天 */ public static final BigDecimal PER_DAY new BigDecimal(0.20); /** 单本罚款封顶元 */ public static final BigDecimal CAP new BigDecimal(20.00); public static BigDecimal of(LocalDate due, LocalDate back) { // 同一天归还不算逾期between 对 due 在前返回正数 long days ChronoUnit.DAYS.between(due, back); if (days 0) return BigDecimal.ZERO.setScale(2); BigDecimal raw PER_DAY.multiply(BigDecimal.valueOf(days)); return raw.min(CAP).setScale(2, RoundingMode.HALF_UP); } /** 当前逾期天数用于列表里标红 */ public static long overdueDays(LocalDate due, LocalDate today) { return Math.max(0, ChronoUnit.DAYS.between(due, today)); } }参数上我一般定借期 30 天、日罚金 0.2 元、封顶 20 元约等于一本普通书价。ChronoUnit.DAYS.between(due, back)在back早于due时返回负数所以用 0一次性处理「提前还」和「当天还」两种情况不需要额外判断。注意BigDecimal的setScale(2)一定要在min()之后调用顺序反了会出现 20.000 这种显示虽然值对但列宽会跳表格看起来像出了 bug。4.3 Swing 表格分页与模糊查询一次SELECT * FROM book塞进JTable数据到几千行时窗口会卡住两三秒滚动条也拖不动。改成分页两个查询一个取当前页一个取总数。public PageResultBook search(String keyword, int page, int size) { String kw keyword null ? : keyword.trim(); // 转义 LIKE 的通配符否则用户输入 % 会匹配全表 String like % kw.replace(\\, \\\\) .replace(%, \\%) .replace(_, \\_) %; int total countByKeyword(like); int offset Math.max(0, (page - 1) * size); String sql SELECT id, isbn, title, author, category, total_copies, avail_copies FROM book WHERE title LIKE ? OR author LIKE ? OR isbn LIKE ? ORDER BY id DESC LIMIT ? OFFSET ?; // ... 三个 like 各绑定一次再 setInt(offset)、setInt(size) return new PageResult(rows, total, page, size); }PageResult里顺手算好totalPages (total size - 1) / size界面只负责显示「第 2 / 7 页」。size我一般给 20正好一屏。JTable这边不要直接用DefaultTableModel写一个只读的AbstractTableModel把Book列表塞进去isCellEditable返回false。这样做的好处是双击某行能直接getValueAt拿到对应的Book对象不用再去反查一次数据库。列名和字段的映射写成常量数组改列顺序的时候只动一处。翻页按钮要记得在查询前setEnabled(false)查完再恢复。Swing 是单线程模型查询放在SwingWorker的doInBackground里别在 EDT 上直接跑 JDBC否则查询期间整个窗口假死老师点两下没反应就以为程序崩了。4.4 导出 CSV一个 BOM 解决 Excel 乱码导出功能答辩时很加分实现却只要二十行。注意两点文件用 UTF-8 写并且开头写一个 BOM否则 Excel 双击打开中文列名全是乱码这个锅会算在你头上。public void exportCsv(ListBook books, Path target) throws IOException { try (BufferedWriter w Files.newBufferedWriter(target, StandardCharsets.UTF_8)) { w.write(\uFEFF); // BOM让 Excel 认出 UTF-8 w.write(ISBN,书名,作者,分类,可借,馆藏\n); for (Book b : books) { // 字段里可能本来就有逗号或引号用双引号包裹并把内部引号翻倍 w.write(csv(b.getIsbn()) , csv(b.getTitle()) , csv(b.getAuthor()) , csv(b.getCategory()) , b.getAvailCopies() , b.getTotalCopies() \n); } } } private static String csv(String raw) { String s raw null ? : raw.replace(\, \\); return \ s \; }字段一律加引号是最省事的转义策略。别用逗号去split回溯也别指望用户的书名里没有逗号——「Java 编程思想第 4 版」这种书名一定会有。5. 现场排查五个让答辩当场翻车的坑5.1 环境与连接驱动、时区、字符集现象一本机跑得好好的换到机房的电脑上启动就抛No suitable driver found for jdbc:mysql://...。原因通常是两种一是mysql-connector-java-x.x.x.jar没放进classpathIDE 里是 Maven 依赖导出的可运行包却没打进去二是用了Class.forName(com.mysql.cj.jdbc.Driver)的老写法但驱动版本里类名变了。解决方式是先确认 jar 真的在lib目录里再让驱动走 SPI 自动加载——JDBC 4.0 之后Class.forName可以不写写了反而容易写错类名。导出前养成习惯java -cp lib/*;classes Main手动跑一次能起来再拷走。现象二借书日期总是差一天或者查出来的应还日比数据库里看到的少一天。原因几乎都是连接串缺serverTimezone驱动按 UTC 解释 DATE 类型本地是东八区来回换算就偏了。解决是在 URL 里补上serverTimezoneAsia/Shanghai并且确认实体类全程用LocalDate不要把java.sql.Date和LocalDate混着传。现象三读者姓名入库变成???或者存进去取出来是乱码。三个地方要同时对齐建库建表用utf8mb4、连接串带characterEncodingutf8、导出 CSV 带 BOM。少任何一个都会在某条路径上出问题。排查时先直接连数据库看表里存的到底是什么如果表里就已经是问号那是写入链路的问题跟界面无关。5.2 数据一致性库存与流水对不上现象四图书列表显示「可借 0」但在借记录里只有 2 条馆藏是 5 本剩下 3 本凭空消失。原因九成是某次借书时扣库存成功了写流水时抛了异常比如due_date传了 null 触发非空约束而两条 SQL 各自自动提交回滚不了。解决是把借还操作全部包进Db.inTx()确认conn.setAutoCommit(false)真的执行了。另一个隐蔽来源是close()放在commit()之前某些连接实现会在关闭时回滚未提交事务看上去像「明明没报错却没写进去」。排查手法是跑一条对账 SQL把库存和流水放在一起看SELECT b.id, b.title, b.total_copies, b.avail_copies, b.total_copies - COUNT(r.id) AS expect_available FROM book b LEFT JOIN borrow_record r ON r.book_id b.id AND r.return_date IS NULL GROUP BY b.id, b.title, b.total_copies, b.avail_copies HAVING b.avail_copies expect_available;有结果集就说明数据已经歪了演示前必须修。修复脚本不要手工一条条改写一条UPDATE book SET avail_copies total_copies - (SELECT COUNT(*) ...)统一刷一遍再跑一次对账确认返回空。5.3 界面与结果集重复行、空指针、按钮连点现象五借阅记录表格里同一行出现两次或者「归还」按钮点两下罚款被算了两次。表格重复行的原因通常是 SQL 里 JOIN 了读者表又 JOIN 了图书表而两张表都有id和name列rs.getLong(id)取到的是后一个同名列构造出来的对象全指向同一条。解决SELECT时把列名显式起别名比如r.id AS reader_id, b.id AS book_id取值一律用别名。重复归还则是并发问题第 3 章那个FOR UPDATE就是为它准备的。除此之外界面上也要做一层点击后立刻button.setEnabled(false)事务结束在finally里恢复。数据库锁负责正确性界面禁用负责体验两层都要有。6. 验收前跑一遍对账 SQL把主流程从界面穿到数据库演示前一个晚上我不会再去改功能只做一件事把主流程从界面点到数据库逐层对一遍。顺序固定成五步——启动、加一本书馆藏 1 本、加一个读者、借出、归还每一步之后都去数据库里看一眼真实的值。这套动作走完大概五分钟能拦下八成的现场事故。配套的检查我用三条 SQL 收尾。第一条是上一节那条库存对账返回空才算过。第二条查有没有「已还但罚款为 0 却明显超期」的记录SELECT id, reader_id, book_id, due_date, return_date, fine FROM borrow_record WHERE return_date IS NOT NULL AND return_date due_date AND fine 0.00;第三条是边界用例用固定的日期手算一遍跟程序算出来的对比场景应还日归还日期望罚款说明提前还2024-06-302024-06-200.00负数天数归零当天还2024-06-302024-06-300.00差 0 天不算逾期逾期 1 天2024-06-302024-07-010.20最小非零值逾期 100 天2024-06-302024-10-0820.00触发封顶跨月逾期2024-01-312024-03-016.0030 天验证月份天数这五行我一般直接写成一个main方法里的断言跑一遍全绿再打包。比手工点界面靠谱得多因为界面点不出「逾期 100 天封顶」这种极端情况——你总不能真等三个月。还有个小技巧把borrow_record的due_date手工改到过去就能在界面上直接看到列表标红和罚款预览不用改代码就能造出逾期态。演示时这段比任何功能都好看因为它证明系统真的在算而不是把数字写死。从那以后我每次交这类实训项目都会强制走一遍「加书 → 借 → 改日期 → 还 → 跑对账」这条链路哪怕功能一行没动。界面能点通不代表数据是对的而对账 SQL 返回空行的那一刻才是真的可以放心去演示。希望帮到你。本文还有配套的精品资源点击获取