ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java实现图书仓储管理系统:从状态机设计到事务并发控制

Java实现图书仓储管理系统:从状态机设计到事务并发控制 简介这份Word文档是一份基于Java的图书仓储管理系统完整设计资料面向计算机相关专业学生、毕业设计者及图书管理信息化开发人员针对库存混乱、查找困难、更新不及时等仓储痛点给出系统化解决方案。文档涵盖系统开发目的与意义、JavaSSMJSPMySQL技术选型以及人员管理、库位管理、图书管理、图书报废管理、图书退回管理等核心功能模块同时详细说明了MVC分层架构、数据库设计与实现细节并配有摘要、目录、可行性分析、业务流程分析等内容结构完整可直接作为课程设计或毕业论文的参考底稿。资源包为单个Word文档仅1个文件大小1.05MB内容精炼且便于二次编辑。已有287人学习浏览对于正在筹备图书管理类项目的读者这份文档能有效帮助梳理设计思路、掌握SSM框架应用方法并减少前期资料搜集与架构设计的时间成本。1. 图书仓储管理系统为什么值得用 Java 从头写一遍如果你打开百度或者知乎搜“java图书仓储管理系统设计与实现”看到的十个里有八个是课程设计或者毕业设计。但你真去复现的时候会发现很多所谓的“源码”要么是二手东拼西凑要么只做了图书的增删改查根本没有“仓储”的概念。我这篇讲的是把“图书”和“仓储”分开建模用 Java 做一套附带完整业务闭环的图书仓储管理系统——从采购入库、库存占用、借阅出库到退货盘点每个环节都有状态流转和数据约束。这套东西做完你的 Java 基础、面向对象编程java的那一套类设计思想、JDBC 事务控制、以及 java 八股文里常问的数据一致性、行级锁这些内容都能落到真实代码上。这套系统适合两类人。一类是正在做 Java 课程设计案例源码、需要答辩能说清业务的在校生另一类是工作中想补一块“库存系统”经验、但不想直接上 Spring Cloud 那种重框架的 Java 工程师。我用的是 Servlet JSP MySQL 的经典组合不引 Spring 全家桶因为仓储系统的核心逻辑在事务边界和状态机不在框架。目录规划六章按“数据怎么设计 → 代码怎么组织 → 核心业务怎么实现 → 踩了哪些坑 → 怎么验证和进阶”展开每一节都有能直接抄的代码和参数。2. 表结构与状态机设计先想清楚“仓”和“书”的关系2.1 为什么不能照抄图书管理系统的三张表常见的图书管理系统只有三张表图书表、读者表、借阅记录表。这在图书馆场景下够用但在“仓储”场景下会翻车。仓储系统要回答三个问题这本书在哪个仓位这个仓位的库存是可用还是被占用一本书从采购到报废经历了哪些状态所以我一般会把表拆成六张biz_book图书主数据、biz_warehouse仓位、biz_stock库存快照、biz_stock_record库存流水、biz_borrow_record借阅记录、biz_supplier供应商。其中主数据只管图书的 ISBN、书名、作者、分类这些静态属性库存快照表管“哪个仓位、哪本书、多少册”流水表管每一次变动是查询和复盘时唯一可信的数据来源。表结构设计上有两个容易忽略的点。第一仓位表里要加“仓位类型”字段区分“正常货架”“退货暂存区”“报废待处理区”。第二库存表里的“占用数量”和“可用数量”要分开不能只存一个总数。后面讲核心业务时你会看到借阅预占和采购在途都依赖这个字段没分的话并发一上来就乱。2.2 状态机字段的取值规则与流转约束图书在仓储系统里不是只有“在架”和“借出”两种状态。我在 biz_book 表加了一个book_status字段取值为ONSHELF在架可借、BORROWED已借出、RETURNING归还中、REPAIRING维修中、DISCARDED已报废。同时在biz_stock_record表里加change_type字段取值为PURCHASE_IN采购入库、BORROW_OUT借出、RETURN_IN归还入库、TRANSFER移仓、RETURN_SUPPLIER退供应商、DISCARD报废。这些状态不是随便定的每一条转移路径都要在 service 层校验。比如 “已借出” 的图书不能直接执行“报废”操作必须先进“归还中”确认归还后才能走报废流程。这个约束用 Java 枚举加一个canTransitTo()方法来实现最可靠——比在数据库里写 CHECK 约束要灵活也比在 Controller 里散落 if-else 要清晰。public enum BookStatus { ONSHELF, BORROWED, RETURNING, REPAIRING, DISCARDED; private static final MapBookStatus, SetBookStatus TRANSITIONS new EnumMap(BookStatus.class); static { TRANSITIONS.put(ONSHELF, EnumSet.of(BORROWED, REPAIRING, DISCARDED)); TRANSITIONS.put(BORROWED, EnumSet.of(RETURNING)); TRANSITIONS.put(RETURNING, EnumSet.of(ONSHELF, REPAIRING, DISCARDED)); TRANSITIONS.put(REPAIRING, EnumSet.of(ONSHELF, DISCARDED)); TRANSITIONS.put(DISCARDED, EnumSet.noneOf(BookStatus.class)); } public boolean canTransitTo(BookStatus target) { return TRANSITIONS.getOrDefault(this, EnumSet.noneOf(BookStatus.class)).contains(target); } }这段代码的逻辑很直观每个状态枚举了一个“允许流转到哪些状态”的集合。EnumMap做映射表的性能优于HashMap而且天然按枚举顺序排列调试时打印出来更易读。注意DISCARDED的转移集合是空集也就是说报废是终态不能再流入任何状态——这是为了防止误操作把报废书重新上架。实际开发中这个枚举还要配合数据库查询一起用不能只靠内存校验因为多实例部署时状态可能不同步。但作为课程设计或单机系统放在 service 层做校验已经足够。2.3 建表 SQL 中必须写对的关键约束建表 SQL 里有一个高频错误所有字段都允许 NULL。库存表的warehouse_id和book_id如果允许 NULL一旦程序漏传参数就会产生一条无法归属的脏数据。我习惯在每个表上都加三个通用审计字段create_time、update_time、deleted其中deleted用tinyint默认 0做逻辑删除而不是物理删除。CREATE TABLE biz_stock ( id INT PRIMARY KEY AUTO_INCREMENT, warehouse_id INT NOT NULL, book_id INT NOT NULL, total_count INT NOT NULL DEFAULT 0, occupied_count INT NOT NULL DEFAULT 0, available_count INT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted TINYINT NOT NULL DEFAULT 0, UNIQUE KEY uk_warehouse_book (warehouse_id, book_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;建表时把available_count直接冗余出来而不是每次用total_count - occupied_count实时算是因为查询列表页时要按可用数量排序和过滤实时算会导致索引失效。这条 SQL 里的联合唯一键uk_warehouse_book非常关键它保证了同一个仓位里同一本书只有一条库存记录从数据库层面堵住了重复插入的漏洞。这里再补一条所有涉及库存的查询SQL 里都要带deleted 0条件否则逻辑删除的数据会污染统计结果。InnoDB 必须指定MyISAM 不支持行级锁和事务后面讲并发时会展开。3. 从 Maven 工程到分层代码把包结构搭成能扩展的样子3.1 工程结构与依赖版本的选择思路我见过太多课程设计的代码把 Controller、Dao、Utils 全部塞进com.example.demo一个包里类几百个没人能说清调用关系。做仓储系统这种业务状态多的项目包结构直接决定你调试时的幸福感。我从一开始就会按controller、service、dao、entity、dto、enums、util分包同时把常量类StockConstants单独拎出来放缓存 key 和状态值。依赖上我只引入五个mysql-connector-java8.0.x、servlet-api4.0.x、jsp-api、fastjson2序列化、junit单元测试。不用 Lombok因为课程设计答辩时老师会问 getter/setter 去哪了如果答不清楚反而减分自己写虽然啰嗦但能展示 Java 基础。Maven 的pom.xml里注意把maven-compiler-plugin的source和target设为 1.8避免高版本 JDK 编译出问题。properties maven.compiler.source1.8/maven.compiler.source maven.compiler.target1.8/maven.compiler.target project.build.sourceEncodingUTF-8/project.build.sourceEncoding /properties这段配置看着不起眼但真能救命。如果你本机装的是 JDK 17又不加这段Maven 默认按 JDK 17 的语法编译代码里只要用了 JDK 8 之后的 API比如var部署到服务器的 JDK 8 环境就会抛UnsupportedClassVersionError。我实际遇到过这种翻车现场——本地一切正常war 包扔到服务器直接起不来。所以无论你本地是哪个 JDK编译目标锁定 1.8 是最稳妥的。3.2 Service 层接口设计与事务注解的边界仓储系统的 Service 层接口要按“业务动作”来命名而不是按“数据表”来命名。比如StockService下面放purchaseIn()、borrowOut()、returnIn()、transferBetweenWarehouses()每个方法对应一个完整的业务动作。这里面有一个细节purchaseIn()这个方法内部要完成三件事——写库存流水、更新库存快照、更新图书主数据状态。这三件事必须在一个事务里如果流水写了但库存没更新对账时就会查出差异。事务控制我用的是 JDBC 的ThreadLocalConnection方案而不是 Spring 的Transactional。原因很简单这套代码没引 Spring事务边界要自己控制。我在TransactionManager里维护一个ThreadLocal在 Service 方法开头begin()结尾commit()异常时rollback()。只要所有 Dao 方法都从同一个ThreadLocal里拿连接就能保证多张表写入的原子性。public class TransactionManager { private static final ThreadLocalConnection CONN_HOLDER new ThreadLocal(); public static Connection getConnection() throws SQLException { Connection conn CONN_HOLDER.get(); if (conn null) { conn DataSourceUtils.getDataSource().getConnection(); CONN_HOLDER.set(conn); } return conn; } public static void begin() throws SQLException { Connection conn getConnection(); conn.setAutoCommit(false); } public static void commit() throws SQLException { Connection conn CONN_HOLDER.get(); if (conn ! null) { conn.commit(); conn.close(); CONN_HOLDER.remove(); } } public static void rollback() { Connection conn CONN_HOLDER.get(); if (conn ! null) { try { conn.rollback(); } catch (SQLException e) { e.printStackTrace(); } finally { try { conn.close(); } catch (SQLException e) { e.printStackTrace(); } CONN_HOLDER.remove(); } } } }这段代码里的CONN_HOLDER.remove()是必须写的。如果不移除Web 容器线程池中的线程复用时ThreadLocal还留着上一个请求的连接轻则连接泄漏重则把未提交的事务带到下一个请求里。setAutoCommit(false)之后所有 SQL 都不会自动提交只有commit()才会真正落盘。注意rollback()方法里没有setAutoCommit(true)因为close()之后连接回到连接池会被重置但如果你的连接池没有配置自动重置就要在rollback()里恢复自动提交这是一个很容易踩的边界问题。3.3 Controller 层返回 JSON 的统一格式与状态码约定Controller 层的职责是参数接收、参数校验、调用 Service、返回结果。我通常会写一个ApiResponseT的通用返回体里面code是业务状态码message是给前端看的提示data是业务数据。code按段位约定200 成功400 参数错误500 系统异常600 开头留给业务冲突比如库存不足、状态不允许流转。public class ApiResponseT { private int code; private String message; private T data; public static T ApiResponseT success(T data) { ApiResponseT resp new ApiResponse(); resp.code 200; resp.message success; resp.data data; return resp; } public static T ApiResponseT error(int code, String message) { ApiResponseT resp new ApiResponse(); resp.code code; resp.message message; return resp; } }为什么统一返回体这么重要因为仓储系统前端要处理的业务状态多——库存不足、仓位不存在、图书已借出每种情况前端都要有对应的提示而不是弹一个“系统错误”。如果你的接口一会儿返回String一会儿返回Map前端接起来只会想骂人。统一返回体之后加一个ResponseBodyAdvice或者拦截器统一包装也行但为了看的直观我选择在 Controller 方法里显式构造ApiResponse.success(...)这样答辩时每一条业务路径都一目了然。3.4 库存操作的核心方法占用与释放的对称性库存操作中最重要的规则是“对称”——每一次占用必须有对应的释放每一次释放必须能追溯到占用时的流水号。我在StockService里写了一个occupyStock()方法参数包括warehouseId、bookId、count、bizType、bizSerialNo。bizSerialNo是业务单据号比如借阅单号或者采购单号这个字段是后续对账的锚点。public boolean occupyStock(int warehouseId, int bookId, int count, String bizType, String bizSerialNo) { // 1. 查询当前库存 Stock stock stockDao.selectForUpdate(warehouseId, bookId); if (stock null) { return false; } // 2. 校验可用数量 if (stock.getAvailableCount() count) { throw new BizException(库存不足可用数量: stock.getAvailableCount()); } // 3. 更新库存快照 int rows stockDao.occupy(warehouseId, bookId, count); // 4. 写入库存流水 if (rows 1) { stockRecordDao.insert(StockRecord.of(warehouseId, bookId, count, bizType, bizSerialNo)); return true; } return false; }注意第一步用了selectForUpdate()这是 InnoDB 的行级锁锁住这条库存记录直到事务结束。如果不用行级锁两个线程同时读到available_count 10同时执行扣减最后库存变成 -2数据就错了。这种问题在 java 面试八股文里叫“数据一致性”面试时你要能说出行级锁的原理和加锁的代价。selectForUpdate()必须放在事务内执行否则事务一提交锁就释放了后面还有return或release操作时会出问题。这里再补一句bizSerialNo要加唯一索引防止同一张单据重复占用库存。4. 核心业务链路实现采购、借阅与退货的完整性4.1 采购入库的完整流程与仓位推荐策略采购入库是仓储系统的“源头”它决定了库存的初始数据是否可信。完整的流程是创建采购单 → 采购单审核 → 图书到货 → 分配仓位 → 库存增加 → 生成入库流水。在纯粹只做仓储而不是进销存的系统里采购单可以只记录供应商信息和到货批次不涉及应付账款但批次号一定要有否则后续退货无法定位是哪一批入库的。仓位分配策略我用的是“推荐优先”先查同一个book_id下是否有库存记录且仓库有剩余容量如果有则直接入到现有仓位避免同一本书散落在多个仓位导致拣货困难如果没有再找一个同分类的空仓位。这个策略用一条 SQL 就能实现SELECT w.id, w.warehouse_code FROM biz_warehouse w LEFT JOIN biz_stock s ON w.id s.warehouse_id AND s.book_id #{bookId} AND s.deleted 0 WHERE w.deleted 0 AND w.warehouse_type NORMAL AND (s.id IS NULL OR s.total_count w.max_capacity) ORDER BY CASE WHEN s.id IS NOT NULL THEN 0 ELSE 1 END, w.id LIMIT 1这条 SQL 的排序逻辑是如果这个仓位已经有这本书的库存优先排到前面如果没有排到后面。ORDER BY CASE WHEN ... THEN 0 ELSE 1 END是推荐的它能避免到处写子查询。注意max_capacity这个容量校验如果忽略了它一个仓位会被塞爆盘点时对不上数。实际业务中采购到货往往有多个批次、不同单价仓库系统里如果不追踪批次后续做退货和成本核算时就是一笔糊涂账所以我在设计里保留batch_no字段每批入库生成一个唯一批次号退货时必须要传批次号。4.2 借阅预占与出库确认的两段式提交图书借阅如果直接扣减库存会导致“用户 1 下单但未取书”期间库存被白白锁死。我做的是“两段式提交”第一阶段在用户下单时预占库存occupied_count Navailable_count - N此时图书状态不变还是ONSHELF第二阶段在图书实际出库时确认扣减total_count - Noccupied_count - N同时把图书状态改成BORROWED。public void borrowOut(int warehouseId, int bookId, int count, String borrowNo) { TransactionManager.begin(); try { boolean occupied stockService.occupyStock(warehouseId, bookId, count, BORROW_OUT, borrowNo); if (!occupied) { throw new BizException(预占库存失败); } bookService.changeStatus(bookId, BookStatus.ONSHELF, BookStatus.BORROWED); TransactionManager.commit(); } catch (Exception e) { TransactionManager.rollback(); throw e; } }两段式的核心好处是“预占不改变图书状态确认才改变”。如果用户一直不取书预占记录会一直占用可用库存所以必须有个定时任务超过 24 小时未确认的预占单自动释放否则库存会被僵尸单吃光。这个设计在 java 面试八股文里可以作为一个“如何保证数据一致性”的案例来答预占和确认在同一事务里通过行级锁保证不会超占超时释放则解决了分布式系统中常见的“锁未释放”问题。注意这里的事务边界是整个业务动作不是单个 SQL所以TransactionManager.begin()放在 Service 方法开头commit()放在所有操作成功之后。4.3 退货供应商的批次定位与库存回滚退供应商这个操作最容易被忽略但却最能体现仓储系统的严谨性。退货不是简单地从库存表里减数量而是要回答“退的是哪一批的书、多少钱、哪个仓位出的货”。我在biz_stock_record表里把change_type设成RETURN_SUPPLIER同时记录batch_no、supplier_id、return_reason三个字段。库存回滚时要同时减少total_count和available_count但occupied_count不能动因为已借出的书不可能退货。public void returnToSupplier(int warehouseId, int bookId, String batchNo, int count, String reason) { TransactionManager.begin(); try { Stock stock stockDao.selectForUpdate(warehouseId, bookId); if (stock.getTotalCount() count) { throw new BizException(退货数量大于库存总量); } int availableAfter stock.getAvailableCount() - count; if (availableAfter 0) { throw new BizException(退货数量大于可用数量请先处理借出中的图书); } stockDao.decreaseTotalAndAvailable(warehouseId, bookId, count); stockRecordDao.insert(StockRecord.returnSupplier(warehouseId, bookId, batchNo, count, reason)); TransactionManager.commit(); } catch (Exception e) { TransactionManager.rollback(); throw new BizException(退货失败: e.getMessage()); } }这个方法的校验逻辑是核心availableAfter 0这个判断保证了不会把已经借出的库存退掉。如果只校验total_count可能出现“库存总量够但可用量不够”的情况退货单出了用户借的书却没库存可还。实际项目里退货往往有金额折算仓库系统可以不关心钱但必须记录return_reason。有一个分支你可能想不到如果某些图书状态是BORROWED退货单可以先创建但标记为“待回收退货”等图书归还后再执行真正的退货这属于更细的业务状态课程设计做到这个程度已经能拿高分了。4.4 盘点的差异生成与复盘 SQL 写法盘点曾经是我翻车最多的地方最初的实现方式是“把磁盘上的库存数直接覆盖成盘点数”结果盘点数录错一位整个库存就错了而且没有后悔药。正确的做法是“盘点只生成差异记录不修数据”。具体而言写一个biz_inventory_task表记录盘点任务biz_inventory_item表记录明细录入盘点数后系统生成盈亏单审核后才更新库存未审核前一切可改可删。SELECT s.warehouse_id, s.book_id, s.total_count, i.inventory_count, (i.inventory_count - s.total_count) AS diff_count FROM biz_stock s JOIN biz_inventory_item i ON s.warehouse_id i.warehouse_id AND s.book_id i.book_id WHERE i.task_id #{taskId} AND i.inventory_count ! s.total_count ORDER BY diff_count DESC这条 SQL 把差异为正盘盈和差异为负盘亏的记录一次查出来然后逐条生成盘盈单或盘亏单。注意这里JOIN而不是LEFT JOIN因为盘点明细必须关联到库存记录才算有效如果没有库存记录说明盘点单录入的书根本不在这个仓位需要先确认仓位。生成的差异单不直接改库存而是进入“待审核”状态审核操作最终执行stockDao.increase或stockDao.decrease。这样每一步都有记录盘点人员录错也能通过“反审核”回滚。整个过程中库存流水表记录了所有来源这是对账和复盘的基础。5. 避坑指南仓储系统里最容易翻车的四个细节5.1 事务不生效扣了库存却没写流水这是最常见的问题。现象调用borrowOut()后图书状态变了库存数量也变了但biz_stock_record表里查不到任何记录。原因大概率是TransactionManager.begin()忘了调用或者 Dao 层自己通过DriverManager.getConnection()新开了连接绕过了ThreadLocal中绑定的事务连接。解决检查所有 Dao 类确保连接只能来自TransactionManager.getConnection()禁止在 Dao 里自行创建连接。再补一条如果 Service 方法内部 catch 了异常但没有重新抛出事务会“假装成功”并提交所以在 catch 块里务必要TransactionManager.rollback()并throw new BizException(e.getMessage())。5.2 高并发下库存超卖根因在 no wait 锁之外现象并发测试时用 100 个线程同时借阅最终库存变成负数。原因selectForUpdate()虽然锁行但如果stockDao.selectForUpdate()方法没有在事务内执行行锁会在 SQL 执行完立即释放后续的occurStock()操作读到旧值。解决确认TransactionManager.begin()必须在selectForUpdate()之前调用且selectForUpdate()的 Dao 方法里不能有conn.commit()或conn.close()操作。另外Connection的隔离级别默认是REPEATABLE_READ如果对一致性要求高可以考虑提升为SERIALIZABLE但并发性能会下降。实际系统中可以在扣减 SQL 里直接加WHERE available_count #{count}作为兜底这样即使应用层逻辑有 bug数据库也会拒绝超卖。5.3 前端传参为 null 导致仓位错乱现象修改库存单据时某个仓位没选前端没拦截到后端插入时warehouse_id变成 0库存数据乱了。原因Controller 层只做了WebServlet的基本参数获取没有做非空校验。解决在 Service 入口处统一做参数校验warehouse_id 0直接抛BizException(仓位不能为空)。还可以在数据库层加外键约束保证biz_stock的warehouse_id必须在biz_warehouse中存在但这会让删除仓位变麻烦建议只加应用层校验。之前有一个尴尬的情况是前端传了warehouseId-1结果库存全部显示不出来了通过接口日志定位到参数问题从那以后我在所有入口都写了防御性判断。5.4 日期时区不一致统计报表差 8 小时现象统计某天借阅量时凌晨 0 点到 8 点的记录全部少算。原因JDBC URL 里没有配置serverTimezoneAsia/ShanghaiMySQL 连接默认使用服务器时区而 Java 应用在北京时区两边差 8 小时。解决在jdbc.properties里设置jdbc:mysql://localhost:3306/library_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai。注意useUnicodetruecharacterEncodingutf8这个参数是保证中文不乱码的关键如果漏了图书书名存进去再读出来就是乱码。同时 MySQL 服务器端default-time-zone 08:00也建议加上两边统一后报表数据才对齐。另外create_time字段如果想让 MySQL 自动维护建表时用DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP程序里不要手动给这个字段赋值否则容易造成时间精度不一致。5.5 文件上传路径写死部署后图片全丢这个坑不是图书仓储系统独有但所有 Java Web 项目都会遇到。现象开发环境上传的图书封面在E:\upload部署到服务器后图片全部无法显示。原因上传路径写死在代码里而服务器上是 Linux 环境根本没有E:\upload。解决把上传根路径配置到web.xml的context-param里或者放到一个System.getProperty(user.home) /library_upload下这样开发和部署都不用手动改代码。还需要注意上传文件大小限制Tomcat 默认允许上传大小是 2MB图书封面如果超过这个值会被丢弃可以在web.xml里配置maxFileSize参数或改用 Base64 上传。这个问题在答辩时也常被问到能答出来说明你有实际部署经验。6. JSP 表格渲染、导出与日常验证技巧6.1 超实用的排除 SQL 异常技巧仓储系统跑一段时间后如果发现某个报表数字对不上我一般会先跑两条 SQL 做“总量校验”SELECT SUM(total_count), SUM(occupied_count), SUM(available_count) FROM biz_stock再跑SELECT change_type, COUNT(*), SUM(count) FROM biz_stock_record GROUP BY change_type。如果库存表的总量不等于流水表的出入库差说明有事务漏提交或重复提交。这个“对账”动作帮我排查过很多问题是一次很典型的用第一性原理排查问题的过程。调试阶段我还会在TransactionManager.commit()前加一段开发环境的日志打印把执行过的 SQL 数组按时间顺序打出来这样能直接看到扣减顺序是否正确。// 调试时在 service 层调用结束前打印流水 if (LogUtil.isDebugEnabled()) { ListStockRecord records stockRecordDao.listByBizSerialNo(bizSerialNo); for (StockRecord record : records) { LogUtil.debug(流水: type{}, bookId{}, count{}, ts{}, record.getChangeType(), record.getBookId(), record.getCount(), record.getCreateTime()); } }注意LogUtil.isDebugEnabled()的判断不能省否则每次都拼字符串会拖慢性能。这个调试技巧只建议在开发环境开启线上环境把日志级别调到INFO以下就自动关闭了。6.2 用 JSP 自定义标签渲染库存状态与操作按钮JSP 里直接写 Java 代码是最糟糕的维护体验。我的习惯是写一个自定义标签stock:statusTag传入status字段自动渲染对应的中文标签和颜色标记。比如ONSHELF显示绿色“可借”BORROWED显示橙色“已借出”RETURNING显示灰色“归还中”。这样 JSP 页面的逻辑就非常干净前端只管贴标签不用关心枚举取值。% taglib uri/WEB-INF/tlds/stock-tags.tld prefixstock % stock:statusTag status${book.bookStatus}/这个标签的实现类继承TagSupport在doStartTag()里根据status打印 HTML 片段。要注意的是自定义标签的.tld文件必须放到WEB-INF目录下JSP 才能通过uri引用到。这里是一个 Java 课程设计里经常被忽略的加分点能用标签封装展示层逻辑而不是在 JSP 里堆c:if老师一眼就能看出代码功底。不过如果是前后端分离的项目这套就用不上了会换成 Vue 或 React 的v-if来切换按钮但后端返回的枚举值设计仍然一样。6.3 模拟对账与压测的最小验证命令系统完成之后不要急着写报告先自己造一批数据做对账。我会写一个 JUnit 测试类用多线程模拟同时借阅、归还、退货跑完后断言库存总量 占用 可用 期初库存总量。这个测试相当于一个小型的并发压测能提前暴露事务边界问题。Test public void testConcurrentBorrowOut() throws InterruptedException { ExecutorService pool Executors.newFixedThreadPool(20); CountDownLatch latch new CountDownLatch(20); for (int i 0; i 20; i) { pool.submit(() - { try { borrowService.borrowOut(1, 1, 1, TEST System.nanoTime()); } finally { latch.countDown(); } }); } latch.await(30, TimeUnit.SECONDS); Stock stock stockDao.selectForUpdate(1, 1); Assertions.assertEquals(0, stock.getAvailableCount()); }CountDownLatch保证了所有线程同时开始借阅Assertions.assertEquals(0, ...)最后校验库存是否刚好扣完。如果这里抛异常说明事务边界或行级锁没生效。注意ExecutorService用完要shutdown()否则测试不会结束。这个方法虽然简单但它是验证仓储系统并发正确性最直接的手段。各类课程设计里“并发压测”是绝大多数人不会主动去做的环节但这是拉开差距的地方。如果你能把这个测试类放到项目里并在答辩时演示给老师看胜过写十页 PPT 功能清单。日常改代码后我也会跑一遍这个测试确保没有引入新的并发问题。就算时间紧至少把两段式提交的borrowOut流程跑一遍确认预占和确认不会互相覆盖。写到这里收个尾。做这套 Java 图书仓储管理系统最深的感受是业务约束比框架重要状态机比 CRUD 难设计事务边界是血泪换来的。如果你正在做类似的课程设计希望你从表结构设计就动手认真地写事务边界试试加一个盘点功能你会在答辩时获得完全不同的底气。希望这些踩过的坑和验证技巧能帮到你。本文还有配套的精品资源点击获取
返回列表