SpringBoot图书进销存系统实战:从库表设计到事务防超卖

发布时间:2026/10/17 4:50:35
SpringBoot图书进销存系统实战:从库表设计到事务防超卖 1. 项目拆解一个图书进销存系统背后藏着哪些必修课每年毕业季都会有人问Java 毕设到底选什么题目我的建议一直是销售信息管理系统、库存管理系统这类题目看着朴素但麻雀虽小五脏俱全。拿这个基于 SpringBoot 的图书进销存一体化管理平台来说它几乎覆盖了 Java Web 开发的所有核心知识点——CRUD 接口、表关联查询、事务控制、权限拦截、报表统计甚至还能延伸到并发扣库存这种进阶场景。对毕设来说这类题目最大的优势在于好讲清楚、容易演示系统的业务闭环完整从采购入库到销售出库再到库存预警一条链路走下来答辩时能讲的内容非常充实。这个平台解决了什么问题说白了书店日常经营中最头疼的三件事进货台账混乱、库存数据滞后、销售流水难以汇总。传统记账方式下老板想知道某本书还剩几本需要翻本子想知道本月哪类书卖得好基本靠记忆。这套系统就是把采购、库存、销售、统计四个环节串起来所有的数据在一个系统里流转进一本书系统自动加库存卖一本书库存自动扣减月底报表一键生成不再依赖人工反复抄录。适合谁来参考正在做 Java 毕设的学生、想快速搭一套进销存系统的开发者、甚至是准备做小型图书电商后台的初级工程师。如果你对 SpringBoot 已经能跑通简单的 CRUD但是对如何设计一张规范的库存流水表、如何控制库存扣减的事务一致性、如何做角色权限过滤这些实际工程问题心里没底这篇文章能把关键环节都给你梳理清楚。顺便说一句我的建议是不要只把它当应付答辩的项目来做因为图书进销存这个业务模型是理解所有电商系统包括图书、服装、数码产品的通用模板。今天你把图书的进销存搞明白了明天换成任何商品 SKU 系统核心逻辑完全复用。你在这套毕设里打下的基础面试时可以直接当项目经验讲。2. 技术选型解析为什么这套组合是毕设的标准答案在给项目搭骨架之前技术栈的选择是第一个决策点。这套系统之所以推荐 SpringBoot 作为核心框架是因为它把 Spring 那一套复杂的 XML 配置全部自动装配了。对毕设而言开发周期的压力摆在那里SpringBoot 的起步依赖、内嵌 Tomcat、自动配置这些特性能让你把精力集中在业务代码而不是环境配置上。我见过太多同学在配置 Spring MVC 和 MyBatis 的事务管理器上耗掉一周时间换成 SpringBoot 之后这些基本都是内置好的。2.1 持久层框架MyBatis-Plus 是省时间的关键持久层我推荐 MyBatis-Plus而不是原生 MyBatis。原因特别实际进销存系统里的核心操作是大量的单表 CRUD如果每个实体都手写一份 XML 映射文件和基础 SQL工作量会增加至少三分之一。MyBatis-Plus 提供的 BaseMapper 接口直接内置了 insert、deleteById、selectPage 这些通用方法单表操作基本不用写 SQL把精力留给多表关联和统计查询。举个例子图书表 book 的模糊分页查询用 MyBatis-Plus 的 LambdaQueryWrapper 只需要这样LambdaQueryWrapperBook wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.isNotBlank(keyword), Book::getTitle, keyword) .eq(StringUtils.isNotBlank(categoryId), Book::getCategoryId, categoryId) .orderByDesc(Book::getCreateTime); IPageBook page bookMapper.selectPage(new Page(pageNum, pageSize), wrapper);这段代码的写法非常直观第一个参数是条件是否生效的布尔值这样搜索关键词为空时like 条件自动跳过不需要手写多个 if 分支去拼接 SQL。这里有个实际经验filter 条件通过 boolean 控制是 MyBatis-Plus 最推荐的写法而不是用 QueryWrapper 的字符串传参后者容易写错字段名。字段引用用 Book::getTitle 这种 Lambda 表达式编译期就能发现字段名拼错的问题不等到运行时才报 SQL 错。2.2 前端方案取舍服务端渲染还是前后端分离很多同学在这里纠结。我的判断标准只有一个你的答辩演示是否依赖独立部署的前端工程。如果是纯毕设场景、没有部署到公网服务器演示的需求建议直接用 Thymeleaf 做服务端渲染整个系统打成一个 jar 包就能跑演示时最省心。如果题目明确要求分开写前端比如 Vue Element UI那就走 RESTful API 的路子。这套图书进销存系统最初用的就是 Thymeleaf 方案好处特别明显不用处理跨域、不用考虑 Token 刷新的问题页面直接渲染数据通过Model传给视图层。例如table classtable table-hover thead tr thISBN/th th书名/th th库存/th th售价/th /tr /thead tbody tr th:eachbook : ${page.records} td th:text${book.isbn}/td td th:text${book.title}/td td th:text${book.stock}/td td th:text${book.salePrice}/td /tr /tbody /table不过有一点要提醒如果前端页面较多Thymeleaf 模板的组织方式要提前安排好。建议按功能模块分目录book、purchase、sale、supplier、report、system每个模块下放 list.html、form.html、detail.html 这类对应页面后期维护起来不混乱。2.3 技术栈版本搭配参考这里直接给一套我验证过能稳定跑通的版本组合组件版本选择说明JDK1.8 或 11两者都可SpringBoot 2.x 完全兼容SpringBoot2.5.x 或 2.7.x稳定版本优先避免用 3.x 导致兼容性问题MyBatis-Plus3.5.x目前最稳定的大版本支持 Lambda 写法MySQL5.7 或 8.0生产建议 8.0开发 5.7 更省内存ThymeleafSpringBoot 内置维护版本不单独定义版本号Druid 连接池1.2.x自带监控界面答辩时能展示 SQL 监控Lombok最新稳定版简化实体类 getter/setter这套组合在兼容性上踩过坑的只有一处SpringBoot 2.7 版本下的 MyBatis-Plus 3.5.3 出现过分页插件失效的问题需要在配置类里显式注入 PaginationInnerInterceptor这个后面专门讲。3. 系统功能规划一张业务流程图讲清进销存闭环功能规划决定了整个项目的骨架这一步如果没想清楚就直接写代码后期返工成本极高。图书进销存系统的核心业务可以归纳为一句话采购单录入实现入库销售单录入实现出库库存数量作为动态中间值被每次出入库操作更新最后通过统计报表把数据呈现出来。这个逻辑一旦想明白数据库表和接口设计都会跟着清晰。3.1 角色权限和功能模块划分系统设计了三种角色管理员店长、采购员、收银员。角色不同能访问的菜单和操作的按钮也不同。管理员拥有全部权限包括供应商管理、员工账号管理、报表导出采购员只能操作图书管理、采购入库和库存预警查看收银员只负责销售开单和销售记录查询。权限这块用 SpringBoot 拦截器 Session 的方式实现就足够了。定义一个AuthInterceptor在preHandle里检查当前用户的角色 code如果访问的路径不在当前角色的权限范围内重定向到无权限提示页。public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(); User loginUser (User) session.getAttribute(loginUser); if (loginUser null) { response.sendRedirect(/login); return false; } String uri request.getRequestURI(); if (uri.startsWith(/admin) !1.equals(loginUser.getRoleCode())) { response.sendRedirect(/noAuth); return false; } return true; } }注册拦截器时注意顺序问题。静态资源路径/css、/js、/images必须要放行否则页面样式加载不出来。我犯过的低级错误就是拦截器放行路径漏了 static 资源导致整个后台样式全部丢失排查了半天才发现只是路径匹配的问题。3.2 具体功能清单拆解数据库表设计上主要包含以下核心表和它们的用途说明表名用途核心字段user系统用户登录、权限username, password, roleCode, statusbook图书基本信息isbn, title, categoryId, publisher, author, stock, salePrice, purchasePrice, warningStockcategory图书分类name, parentIdsupplier供应商信息name, contact, phone, addresspurchase_order采购单主表orderNo, supplierId, totalAmount, status, createTimepurchase_order_item采购单明细表orderId, bookId, quantity, price, amountsale_order销售单主表orderNo, cashierId, totalAmount, createTimesale_order_item销售单明细表orderId, bookId, quantity, price, amountstock_record库存流水表bookId, changeType, changeQty, beforeStock, afterStock, remarkinventory_warning库存预警记录bookId, stock, warningStock, status从表结构能看出采购和销售都采用了主表 明细表的标准设计模式以销售单为例sale_order记录一笔销售的整体信息包括单号、收银员、总金额、时间sale_order_item记录这一笔单卖出了哪些书、每本卖了多少。为什么这样设计因为一张销售单往往包含多本书如果所有信息塞在一张表里会导致大量重复信息分开后查询某笔订单的商品明细只需要按 orderId 查明细表查询一本书的历史销售记录只需要按 bookId 关联销售明细表灵活度完全不同。3.3 四个核心业务闭环围绕上述表结构系统的主体功能围绕下面几条链路展开图书管理图书的增删改查是基础操作删除图书前必须判断是否有库存流水记录如果有历史数据则改为下架操作而不是物理删除避免破坏历史数据的完整性。这个设计在答辩时是加分项往大了说就是逻辑删除的思想。采购入库采购员创建采购单选择供应商逐条录入要采购的图书和数量提交后系统校验图书是否存在如果不存在则提示先创建图书信息审核通过后生成入库流水同时更新图书库存并在库存流水表写入记录。采购单的主表和明细表需要在一个事务里同时提交保证数据一致性。销售开单收银员选择图书输入数量后系统自动计算总价。这里有一个细节当输入的数量超过当前库存时提交会被拦截并提示库存不足。开单成功后同样扣减库存并生成销售流水和数据记录。对收银这种操作频次较高的场景做到提交时的二次确认校验非常重要能拦截掉大部分误操作。库存预警当图书库存低于设置的预警阈值系统在首页 dashboard 展示预警列表。这个功能其实很简单就是查询 book 表中 stock warning_stock 的数据但是把它从被动查询变成主动提醒对用户的价值完全不一样。展示形式可以直接做成数字角标红色数字表示预警的商品种类数。这四条链路走通之后系统的基本功能就完整了。剩下的是报表统计月度销售柱状图、分类销售占比饼图、库存滞销排行等这些都是基于销售单、销售明细和图书表做聚合统计查询用 ECharts 画图展示。4. 库表设计与手工建模的完整记录数据库设计这一步是很多毕设同学看着简单、实际动手不知道从哪开始的环节。我在做这套系统的时候建表顺序有一个自己的习惯先建基础数据表user、category、supplier、book再建业务表purchase_order、sale_order 及明细表最后建流水表和预警表。这样建表的好处是外键引用关系不会乱而且每张表建立后都能立刻验证字段是否完整。4.1 图书表的设计细节图书表是整个系统的核心基础表它的字段设计直接关系到后续业务功能的开发难度。我给出实际建表 SQL 供参考CREATE TABLE book ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键ID, isbn varchar(32) NOT NULL COMMENT ISBN编号, title varchar(255) NOT NULL COMMENT 书名, author varchar(100) DEFAULT NULL COMMENT 作者, category_id bigint(20) DEFAULT NULL COMMENT 分类ID, publisher varchar(255) DEFAULT NULL COMMENT 出版社, purchase_price decimal(10,2) DEFAULT 0.00 COMMENT 进价, sale_price decimal(10,2) DEFAULT 0.00 COMMENT 售价, stock int(11) DEFAULT 0 COMMENT 当前库存, warning_stock int(11) DEFAULT 10 COMMENT 库存预警阈值, status tinyint(1) DEFAULT 1 COMMENT 状态(0下架/1在架), create_time datetime DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), UNIQUE KEY uk_isbn (isbn) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT图书信息表;字段设计有几个容易忽略的点。isbn 字段建议加唯一索引因为同一本书的 ISBN 在系统里应该是唯一的。价格字段统一用 decimal(10,2)绝对不能用 double 或 float否则涉及金额精度时会出现 0.1 0.2 不等于 0.3 的情况。update_time 用ON UPDATE CURRENT_TIMESTAMP让数据库在更新行时自动维护修改时间省去应用层代码手动赋值。这里的库存字段默认给 0后续通过采购入库增加数量。需要注意的是这个表设计里隐藏了一个时间换空间的取舍。库存数量 stock 字段其实是一个冗余字段理论上可以通过库存流水表 sum 计算得到但每次都去汇总会非常慢所以冗余这个字段直接查询当前库存只需要一次索引读取。但冗余也带来了相应的问题——会出现 book.stock 和 stock_record 里累计数量不一致的情况必须通过事务保证每次库存变更同时更新这两个地方。这是进销存系统最容易踩坑的点后面在问题排查部分会讲到。4.2 采购订单表设计主从结构防止数据膨胀采购订单表分为主表和明细表这种设计在面试时装系统里特别常见且实用。主表记录订单的整体信息明细表记录每个商品的具体采购数量。以下是主表建表 SQL 片段CREATE TABLE purchase_order ( id bigint(20) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 采购单号, supplier_id bigint(20) NOT NULL COMMENT 供应商ID, total_amount decimal(10,2) DEFAULT 0.00 COMMENT 采购总金额, status tinyint(1) DEFAULT 0 COMMENT 状态0待审核 1已入库 2已作废, create_by bigint(20) DEFAULT NULL COMMENT 创建人ID, create_time datetime DEFAULT CURRENT_TIMESTAMP, audit_time datetime DEFAULT NULL COMMENT 审核时间, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE purchase_order_item ( id bigint(20) NOT NULL AUTO_INCREMENT, order_id bigint(20) NOT NULL COMMENT 采购单ID, book_id bigint(20) NOT NULL COMMENT 图书ID, quantity int(11) NOT NULL COMMENT 采购数量, price decimal(10,2) NOT NULL COMMENT 采购单价, amount decimal(10,2) NOT NULL COMMENT 小计金额, PRIMARY KEY (id), KEY idx_order_id (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;主表和明细表通过 order_id 关联明细表加索引 idx_order_id 提升按单查询效率这是一个关键优化点。设计时注意这里的 price 字段是采购单价而不是进价因为同一个书在不同批次可能有不同进价。总金额 total_amount 是冗余字段由服务层计算所有明细金额后汇总避免每次统计订单都要再去 join 明细表求和。4.3 库存流水表追踪每一次变动库存流水表是整个系统的账本它记录每一次库存变动不管是采购入库、销售出库、手动调整都要写一条流水。这是进销存系统与普通 CRUD 系统最大的区别也是最值得在答辩时展开讲的亮点。CREATE TABLE stock_record ( id bigint(20) NOT NULL AUTO_INCREMENT, book_id bigint(20) NOT NULL, change_type tinyint(1) NOT NULL COMMENT 变动类型1采购入库 2销售出库 3手动调整, change_qty int(11) NOT NULL COMMENT 变动数量出库为负数, before_stock int(11) NOT NULL COMMENT 变动前库存, after_stock int(11) NOT NULL COMMENT 变动后库存, order_no varchar(32) DEFAULT NULL COMMENT 关联订单号, create_time datetime DEFAULT CURRENT_TIMESTAMP, remark varchar(255) DEFAULT NULL, PRIMARY KEY (id), KEY idx_book_id (book_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;before_stock 和 after_stock 两个字段很多人会省略但建议保留。有了变动前后库存一旦数据对不上就能直接看出哪一步丢了流水这是排查数据不一致问题的关键依据。change_type 用 tinyint 存储而不是字符串避免中文值在数据库层面占存储大还容易写错。4.4 订单号生成规则订单号的生成看起来不起眼但却是交货时容易被追问的一个细节。它需要满足两个要求唯一性和可读性。我的做法是日期 随机数的组合格式如下采购单PO202406121001PO 年月日 4位随机数字 销售单SO202406121010SO 年月日 4位随机数字生成逻辑在 Java 里实现String orderNo PO LocalDate.now().format(DateTimeFormatter.ofPattern(yyyyMMdd)) String.format(%04d, new Random().nextInt(10000));这个方案在小规模系统下完全够用同时保留了可读性直接看单号就能知道是哪天下单的。但如果系统并发量再大一些建议改为日期 自增序号从数据库里取当天记录数加一保证同一天内的单号有序递增。当时做毕设时用随机数图方便但答辩时被问到随机数会不会重复确实有被噎住的风险后来才改成拼接数据库自增主键的方式。这块学生如果主动考虑周全是技术和实践结合得很好的加分点。5. 核心功能实现采购、销售两条主线的详细代码配置功能模块的实现是项目的重头戏采购入库和销售出库是系统里最复杂的两个功能也是真正考验代码水平的环节。下面把这两条主线的实现细节逐个拆开讲包括事务处理、库存扣减逻辑、数据回显每一步都有明确的编码思路。5.1 采购入库的完整实现采购入库的链路是页面创建采购单 - 往 purchase_order 表插主记录 - 往 purchase_order_item 插明细记录 - 更新 book 表的 stock 字段 - 向 stock_record 插库存变动流水。这五步操作必须放在同一个事务里要么全部成功要么全部回滚。SpringBoot 的Transactional注解在这里是关键。因为事务管理是框架自动处理的只需在 Service 方法上标注并设置传播行为和异常回滚Service public class PurchaseOrderServiceImpl implements PurchaseOrderService { Autowired private PurchaseOrderMapper purchaseOrderMapper; Autowired private PurchaseOrderItemMapper purchaseOrderItemMapper; Autowired private BookMapper bookMapper; Autowired private StockRecordMapper stockRecordMapper; Transactional(rollbackFor Exception.class) public void createOrder(PurchaseOrderDTO dto) { // 1. 生成单号并插入主表 PurchaseOrder order new PurchaseOrder(); order.setOrderNo(generateOrderNo(PO)); order.setSupplierId(dto.getSupplierId()); BigDecimal totalAmount BigDecimal.ZERO; // 2. 处理明细并更新库存 ListPurchaseOrderItem items new ArrayList(); for (PurchaseOrderItemDTO itemDTO : dto.getItems()) { Book book bookMapper.selectById(itemDTO.getBookId()); if (book null) { throw new BusinessException(图书不存在: itemDTO.getBookId()); } if (itemDTO.getQuantity() 0) { throw new BusinessException(采购数量必须大于0); } PurchaseOrderItem item new PurchaseOrderItem(); item.setBookId(book.getId()); item.setQuantity(itemDTO.getQuantity()); item.setPrice(itemDTO.getPrice()); // 注意金额精度处理 BigDecimal amount itemDTO.getPrice() .multiply(new BigDecimal(itemDTO.getQuantity())); item.setAmount(amount); items.add(item); totalAmount totalAmount.add(amount); // 3. 更新库存先查旧值再更新 int updated bookMapper.increaseStock(book.getId(), itemDTO.getQuantity()); if (updated 0) { throw new BusinessException(库存更新失败可能图书已被删除); } // 4. 写入库存流水 StockRecord record new StockRecord(); record.setBookId(book.getId()); record.setChangeType(1); // 采购入库 record.setChangeQty(itemDTO.getQuantity()); record.setBeforeStock(book.getStock()); record.setAfterStock(book.getStock() itemDTO.getQuantity()); record.setOrderNo(order.getOrderNo()); stockRecordMapper.insert(record); } order.setTotalAmount(totalAmount); purchaseOrderMapper.insert(order); for (PurchaseOrderItem item : items) { item.setOrderId(order.getId()); purchaseOrderItemMapper.insert(item); } } }这里面有几处细节值得展开说。事务注解使用rollbackFor Exception.class的原因在于 Spring 默认只对 RuntimeException 回滚如果你在代码里抛了一个自定义 BusinessException 但没继承 RuntimeException事务不会回滚库存照加但主表没插进去数据直接不一致。这是最隐蔽也最常见的坑。BookMapper.increaseStock是自定义 SQL直接做增量更新UPDATE book SET stock stock #{quantity} WHERE id #{id}这里用这种写法而不用先查后设值的方式是为了避免多用户并发采购时出现覆盖更新的问题。比如两个人同时采购同一本书各自读到库存 10分别加 5 和 3如果各自回写 15 和 13最后结果是 13丢了 5 本库存。而用 SQL 里的stock stock #{quantity}则让数据库保证更新的原子性最终结果是 18完全正确。5.2 销售开单与扣库存的防超卖方案销售出库和采购入库基本对称但有一个额外需求不能超卖即卖出的数量不能超过当前库存。实现防超卖最简单可靠的方式是使用带条件的更新语句Transactional(rollbackFor Exception.class) public void createSaleOrder(OrderCreateDTO saleDTO) { // 省略主表/明细表插入逻辑 // 关键是这里 for (SaleOrderItemDTO itemDTO : saleDTO.getItems()) { Book book bookMapper.selectById(itemDTO.getBookId()); if (book null) { throw new BusinessException(图书不存在); } // 预检查 if (book.getStock() itemDTO.getQuantity()) { throw new BusinessException(库存不足: book.getTitle()); } // 安全扣减 int rows bookMapper.decreaseStockIfEnough(book.getId(), itemDTO.getQuantity()); if (rows 0) { throw new BusinessException(库存扣减失败该图书库存不足或已被修改); } // 写库存流水 StockRecord record new StockRecord(); record.setBookId(book.getId()); record.setChangeType(2); record.setChangeQty(-itemDTO.getQuantity()); record.setBeforeStock(book.getStock()); record.setAfterStock(book.getStock() - itemDTO.getQuantity()); record.setOrderNo(saleOrder.getOrderNo()); stockRecordMapper.insert(record); } }decreaseStockIfEnough是关键方法对应的 SQL 是UPDATE book SET stock stock - #{quantity} WHERE id #{id} AND stock #{quantity}这个 SQL 自带条件判断如果库存不够受影响行数为 0Java 层检测到返回值就能立刻抛出业务异常。这是用数据库的行锁机制天然实现了检查 - 扣减的原子操作防止了并发情况下两个线程同时检查通过但实际只有一个能扣减成功的问题。在实际开发中库存流水里 after_stock 我这里用的是 book.getStock() - itemDTO.getQuantity()这段代码有一个隐患——book.getStock() 是查询出来的旧值如果并发修改记录到的 after_stock 可能与实际值不一致。如果要做到完全准确可以在扣减后重新查询一次 book 的库存再写入流水代价是多一条 select 语句但对于毕设系统旧值已经足够使用。5.3 报表模块的 SQL 实现报表统计在毕设答辩中是展示系统价值的重要模块也是面试常问的 SQL 聚合能力。最核心的几个统计场景和 SQL 写法如下本月销售额统计SELECT DATE(create_time) as day, SUM(total_amount) as amount FROM sale_order WHERE create_time CONCAT(DATE_FORMAT(CURDATE(), %Y-%m), -01) GROUP BY DATE(create_time) ORDER BY day图书分类销售占比SELECT c.name AS category_name, SUM(soi.quantity * soi.price) AS sales_amount FROM sale_order_item soi JOIN book b ON soi.book_id b.id JOIN category c ON b.category_id c.id JOIN sale_order so ON soi.order_id so.id WHERE so.create_time BETWEEN #{startTime} AND #{endTime} GROUP BY c.id, c.name ORDER BY sales_amount DESC最低库存图书排行SELECT title, stock, warning_stock FROM book WHERE status 1 ORDER BY stock ASC LIMIT 10SQL 编写有几个关键意识统计日期范围时BETWEEN #{startTime} AND #{endTime}这样写要注意 startTime 可以是当天零点endTime 则要设置为第二天的零点或者用 次日这种边界条件避免漏掉当天最后一秒钟的数据。关联查询时如果字段有同名的就一定要加表前缀比如上面例子中的创建时间就很容易出现两个表都有 create_time 的报错。6. 前端页面与后端接口的对接过程多数毕设选题最容易被低估的就是前后端联调的工作量页面交互的流畅度直接决定了演示体验。这个系统里几个核心页面的交互逻辑可以展开说一下如果你用的是 Thymeleaf那么页面和接口基本不需要额外的 axios 请求直接靠表单提交、模板渲染。6.1 图书列表页分页 多条件搜索的规范写法图书列表页是全系统使用频率最高的页面也是展示博客风格与工程基本功的地方。它包含分页、搜索、排序、状态切换多个功能实现的思路对其它列表页供应商、订单、用户都能复用。分页请求的 Controller 层代码GetMapping(/book/list) public String list(RequestParam(defaultValue 1) Integer pageNum, RequestParam(defaultValue 10) Integer pageSize, String keyword, Long categoryId, Model model) { LambdaQueryWrapperBook wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.hasText(keyword), Book::getTitle, keyword) .eq(categoryId ! null, Book::getCategoryId, categoryId) .orderByDesc(Book::getCreateTime); IPageBook page bookMapper.selectPage(new Page(pageNum, pageSize), wrapper); model.addAttribute(page, page); model.addAttribute(keyword, keyword); model.addAttribute(categoryId, categoryId); return book/list; }这里有一个关键细节搜索条件必须回传页面。如果列表渲染时没有把 keyword 绑回 Model用户翻到第二页时搜索条件就会丢失展示出全量数据这属于直接功能性缺陷。任何列表页的搜索参数、筛选条件、分页页码都要在 Model 里保留并传给页面。Thymeleaf 页面里构建分页链接时推荐用th:href{/book/list(pageNum${page.current 1}, keyword${keyword})}这种方式让 Spring MVC 自动拼接请求参数。不要手动拼字符串 URL否则遇到中文关键词时会出现编码混乱的问题。6.2 采购单创建的页面交互创建采购单的页面是系统里交互最复杂的一个。它具有动态行特性不是固定表单所以流程上是点击添加行填写图书名称、数量、价格再点击保存。如果用 Thymeleaf 实现这个页面需要引入一点 JavaScript 来动态添加删除行form th:action{/purchase/save} methodpost div iditemRows div classrow-item select nameitems[0].bookId classbook-select option th:eachbook : ${books} th:value${book.id} th:text${book.title}/option /select input typenumber nameitems[0].quantity min1 required input typenumber nameitems[0].price step0.01 required /div /div button typebutton onclickaddRow()添加一行/button button typesubmit提交采购单/button /form问题在于名称的索引是动态变化的使用items[0].bookId这种方式当用户点了删除中间行之后下标会不连续Spring MVC 的数据绑定就会出问题。后来我改成了提交前重新编号的方案在表单的 submit 事件里先遍历所有行统一给 name 属性重新赋值下标再提交。这个坑特别典型凡是做动态行表单都会遇到一次提前知道了能省一个晚上的调试时间。6.3 销售开单的快捷操作细节收银员场景讲究一个快字销售开单页我实现了输入 ISBN 回车自动带出书名和价格的功能。实现方式是后端提供接口GetMapping(/api/book/isbn/{isbn}) ResponseBody public Result getBookByIsbn(PathVariable String isbn) { LambdaQueryWrapperBook wrapper new LambdaQueryWrapper(); wrapper.eq(Book::getIsbn, isbn).eq(Book::getStatus, 1); Book book bookMapper.selectOne(wrapper); if (book null) { return Result.error(未找到对应图书); } return Result.success(book); }收银台页面用一小段 jQuery 监听输入框的 keyup 事件当输入的长度到达 ISBN 的标准长度时自动发起 Ajax 请求把图书信息填充到表格行里。这个功能演示效果很好答辩时直接展示扫码枪扫入、系统自动带出图书的场景比单纯演示增删改查有说服力得多。这个也需要在答辩演讲稿里埋一个伏笔老师问起前端交互设计时就可以重点介绍。7. 常见问题与避坑实录真实开发中的关键排查记录所有项目做完之后编译运行、功能测试、答辩演示这几关都会踩到形形色色的坑。下面把我在这个项目中实际遇到的典型问题和排查思路整理出来有些坑是毕设学生必踩的提前知道能省不少时间。7.1 事务不生效同一个类里的方法自调用这是 Spring 事务里最经典的一个坑。我刚开始把保存采购单的逻辑拆成了 saveOrder 和 updateBookStock 两个私有方法在 saveOrder 里调用了 updateBookStock并且分别在两个方法上标了 Transactional结果诡异的情况出现了主表插进去了库存更新抛了异常但是主表的数据没有回滚出现了买了东西但没扣库存的脏数据。原因很简单Spring 的事务是通过 AOP 代理实现的只有通过代理对象调用方法时切面才会拦截并开启事务。而同一个类里this.updateBookStock()这种写法是直接调用原始对象的方法压根不会经过代理所以第二个方法上的 Transactional 完全失效。解决办法是把需要事务控制的代码放在同一个事务方法里或者把更新库存的操作拆分到另一个 Service 类中通过注入代理对象去调用。这个排查记忆非常深刻因为现象看似是事务失效实际上是代理机制在起作用。7.2 MyBatis-Plus 分页插件不生效分页是列表页的核心功能但 MyBatis-Plus 有个常见坑分页插件必须要显式配置。如果你只加了一个分页依赖却没有在配置类里注册 PaginationInnerInterceptor调用 selectPage 时返回的 records 实际上是全量数据只是在你眼前伪装成分页而且 total 永远是 0。正确的配置示例Configuration MapperScan(com.example.mapper) public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }配置完这次分页插件还要特别注意一个问题如果你有两个数据源需要配置两个不同命名的 MybatisPlusInterceptor否则会出现分页串库的诡异问题。7.3 时间参数传入 MySQL 时的格式报错在写销售统计报表时前端通过表单提交 startTime 和 endTime 到后端如果前端传的是2024-06-01这种格式而后端实体类对应字段是LocalDateTimeSpring MVC 经常直接报 400 错误。原因是默认的类型转换器不支持这种没有时间部分的格式。最简单的解决办法在 Controller 层改用字符串接收参数在方法内部用 DateTimeFormatter 解析GetMapping(/report/sale) public String saleReport(RequestParam(required false) String startTime, RequestParam(required false) String endTime, Model model) { LocalDateTime start null; LocalDateTime end null; if (StringUtils.hasText(startTime)) { start LocalDate.parse(startTime).atStartOfDay(); } if (StringUtils.hasText(endTime)) { end LocalDate.parse(endTime).plusDays(1).atStartOfDay(); } // 查询时使用 start createTime end }这里 end 取次日零点保证当天最后一秒的数据也能被统计进去。很多人的报表数据少一天、多一天都是因为直接用字符串比较或者边界条件没设置对。其实这就是 SQL BETWEEN 语法魔幻的地方统一改成开区间start createTime end后问题就消失了。7.4 库存数据经常对不上账这种问题通常有几种成因排查思路分享给遇到类似情况的开发者第一检查库存流水表。对比 book.stock 与 stock_record 中该图书所有变动数量的总和如果两边不一致基本可以断定是某次操作写入了流水但没更新库存或者反过来。定位方法是按时间倒序查看 stock_record 表中该图书的流水找出断点。第二检查是否有绕过系统直接操作数据库的情况。例如开发测试时手动在 SQL 工具里改过 book 表的 stock 值但没写流水。第三检查 BigDecimal 精度问题。如果计算金额时没有使用高精度运算而是用了 double 累加小数位会对不上。整型字段没有小数但是金额会直接存在精度位数不一致的问题排查起来耗时很长。排查这类问题时用系统化的对比思路永远比盯着一处代码空想要效率高。7.5 前端样式加载不出来这个问题在拦截器方案下太常见了。登录拦截器放行了接口却忘了放行静态资源导致每个页面都没有 CSS 和 JS。排查方法是看浏览器控制台的 Network tab找到被 302 或 403 拦截的静态资源请求然后在拦截器里把这些路径加入白名单registry.addInterceptor(authInterceptor) .addPathPatterns(/**) .excludePathPatterns(/login, /css/**, /js/**, /images/**, /api/**);/api/** 放行是因为前端 Ajax 调用的接口不走页面跳转如果这里拦截了前端拿不到 JSON 数据会一片空白。这套放行逻辑相对符合 SpringBoot 项目中页面访问走拦截、接口调用走鉴权的设计惯例。8. 项目扩展与答辩亮点让系统从可用变成有想法做完一个毕设项目答辩时的层次往往差在有没有自己的想法。如果只是把需求功能的 CRUD 全部实现并演示一遍老师只会给个基础分。但如果在某些点上体现出设计上的思考甚至做了扩展性的预研答辩效果完全不同。下面给几个这个项目里值得深入的出彩方向。8.1 增加多租户数据权限图书进销存系统如果只是单门店使用数据权限区分非常简单。但如果将来扩展到连锁书店场景不同门店的数据是必须隔离的。MyBatis-Plus 自带的多租户插件可以直接支持只需要在建表时加一个 store_id 字段然后在配置插件的地方设置 TableNameHandler所有查询自动追加门店条件。这个扩展点如果能在论文里写清楚会展示出足够的前瞻设计意识。8.2 Redis 缓存热点数据图书列表页和分类信息是访问频率最高的数据每次请求都去查 MySQL 其实很浪费。引入 Redis 做缓存之后首页推荐图书、图书分类这些变化频率低的数据可以直接缓存在内存里查询速度从几十毫秒降低到几毫秒而且对 MySQL 的压力大幅下降。这套系统里的图书分类和图书列表都可以做缓存缓存过期时间设置成 30 分钟数据变动时主动删除缓存即可。如果要在答辩里展示并发优化的思路可以用压测工具对比一下加缓存前后的 QPS 变化记录两个数字放进论文的测试章节说服力会直线上升。8.3 消息队列解耦异步通知这个属于可选进阶项。在采购单审核通过之后原有的实现是同步更新库存再给管理员发一条短信或站内信提醒。同步发送会拖慢主流程的响应速度而且一旦短信服务挂了采购入库也跟着失败。把通知逻辑丢到消息队列里异步处理主流程就只关心数据库事务通知失败了也不影响核心业务这是生产环境的标准设计思路。对毕设来说展开讲清楚为什么要解耦、异步带来的好处是什么比真正把 RocketMQ 跑起来更重要。但如果你确实想跑起来建议用 RabbitMQ它的安装和 SpringBoot 集成成本最低。8.4 答辩时可能被问到的问题清单提问方向建议回答思路系统架构是怎么分层的Controller - Service - Mapper 三层加 Model/DTO/VO 处理数据进出Controller 不写业务逻辑为什么用 MyBatis-Plus 而不是 JPASQL 可控性强复杂统计好写代码生成方便对校园项目更顺手采购入库和销售出库的事务怎么保证主表、明细表、库存更新、流水记录放在同一事务里用了 setRollbackFor 处理所有异常回滚如何防止超卖通过数据库的 update 语句原子性条件 stock quantity 保证并发安全数据库为什么选用 InnoDB支持事务、支持行级锁数据量再大一点也能支撑库存和流水数据不一致怎么解决用事务保证同生共死再用对账任务兜底可以扩展讲对账逻辑准备答辩的时候不要背答案要把这些问题的思路理解透用大白话讲给面试官或老师听比背几句专业术语效果要好得多。这套图书进销存系统做下来我最大的体会是毕设不只是完成任务它其实是一次完整的业务开发训练。从需求分析到库表设计从接口实现到页面交互再从事务控制到并发安全每一个环节都能学到教科书和博客里纸上谈兵之外的真实经验。如果你正在做类似的 Java Web 项目我的建议是不要急于写代码先在数据库设计上多花时间把库存流水这种关键表设计合理后面的开发节奏会明显顺畅很多。遇到报错也别慌按先看日志、再查 SQL、最后断代码的顺序排查任何一个坑都是可以把问题解决掉并讲出经验的。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询