☰
图书进销存系统实战:Spring Boot+Vue+MyBatis设计与实现
2026/10/5 7:19:46 网站建设 项目流程

接到“图书进销存管理系统”这类需求,很多人的第一反应是:“这不就是个图书增删改查吗?Spring Boot + Vue 搭个界面,一天就能跑完。”结果做出来的东西,看页面挺热闹,一到演示“进货、售书、库存对不上”这种最常见的业务场景,当场就翻车。原因不在技术,而在最开始就把业务边界想偏了——图书进销存的核心不是“图书管理”,而是“进、销、存”这条单据流和库存账。这篇我用 Spring Boot + Vue + Java + MySQL + MyBatis 完整拆解一遍设计与实现,覆盖业务建模、数据库表设计、后端事务控制、前端路由权限、联调部署和排错清单。正在做课程设计、毕业设计,或者想快速搭一套小后台管理系统的人可以参考,我会把“为什么这么做”也一并写清楚。

1. 先想明白业务边界:图书进销存不是图书借阅系统

1.1 三条主链路让需求清晰起来

我见过很多项目把 book 表做得特别复杂,封面图、分类、书评、点赞都齐了,却没有一张“入库单”和“出库单”。这就是典型的把进销存下意识做成了图书展示系统。图书进销存真正的主干线是三条:采购入库、销售出库、库存盘点。

采购入库这条链路,本质是“先有供应商,再有采购计划,最后形成入库凭证”。业务上通常是这样走的:系统或库管发现某本书库存低于安全值,生成采购建议;采购员据此向供应商下单;图书到货后,库管录入一张入库单,单子上要有供应商、进货价、数量、经手人;保存入库单的同时,库存表里对应图书的数量要增加,并生成一条库存流水。注意,这一步不是“改一下图书的库存字段就完事”,而是“入库单 + 入库明细 + 库存更新 + 库存流水”四个动作必须同时成功。

销售出库是反向链路。前台收银或销售员开一张销售单,选择图书、填数量、价格,系统先校验库存是否充足,保存销售单时扣减库存并写流水。如果客户退货,则再走一张退货单或红字销售单,把库存加回来。这里最容易出的问题,是把扣库存写成“先查询库存、再在内存里减、最后更新”。这个做法我后面会详细说,并发情况下会出大乱子。

库存盘点则用来处理“账面库存和实际库存不一致”。比如图书破损、丢失、盘盈盘亏,通过盘点单把库存调整到实际值。很多同学觉得盘点可有可无,但其实它才是进销存系统里最体现专业度的模块。把三条链路画清楚,数据库设计就有了方向,不用对着空白设计文档发呆。

1.2 角色权限要跟着业务流程走

业务流程决定了系统里至少要有这几类人:采购员、库管员、销售员、管理员。不同角色能操作的功能不一样,后端接口要有权限控制,前端菜单也要动态显示。

我先给出一份简化的角色-功能对照表,设计需求时可以直接照抄:

角色核心功能
采购员供应商管理、采购入库单录入与审核
库管员图书档案管理、入库出库确认、库存盘点
销售员客户管理、销售出库单录入、销售退货
管理员用户管理、角色权限、数据统计、系统设置

别小看这张表,它直接决定了两件事:一是后端需要为不同角色提供不同的菜单接口和数据权限;二是前端 Vue 需要做动态路由或动态菜单,而不是把所有页面都塞在左侧导航栏里。权限部分我放到第 5 节再展开,这里先记住结论:进销存系统的需求分析,从“谁在什么环节做什么事”出发,比从“技术表怎么建”出发要靠谱得多。

2. 技术选型复盘:Spring Boot、Vue、MyBatis 各自的活

2.1 为什么仍然选 MyBatis,而不是 Spring Data JPA

技术栈看起来是标配,但选型时还是要结合项目场景想清楚。图书进销存的特点是:表关联多、查询条件多、库存更新需要写复杂 SQL。Spring Data JPA 虽然声明式查询很爽,但面对“多条件动态拼接”“批量插入明细”“扣库存带条件更新”这类需求,写起来反而绕。MyBatis 的好处恰恰在这里:SQL 肉眼可见,可控性极强,动态 SQL 解决多条件查询,Mapper XML 里写 update 语句能精确控制原子操作。

有人会问,MyBatis-Plus 不是更省事吗?它确实省事,内置方法直接 CRUD,但有两个问题。一是课程设计和答辩环节里,面试官或评委大概率要问“你的 SQL 是怎么写的”“多表关联怎么做的”,如果你连一条手写 SQL 都说不出来,项目含金量会被扣不少;二是 MyBatis-Plus 在复杂报表和库存扣减这类场景下,依然要回到 XML 里写自定义 SQL,省事的部分恰恰不是系统核心。所以我的建议很直接:核心链路自己手写 MyBatis,公共的简单单表操作用通用方法,这样既有效率,又有技术亮点。

还要提一嘴 MyBatis 缓存这个高频面试点。默认情况下 MyBatis 一级缓存是 SqlSession 级别的,二级缓存默认不开启。对库存这类强一致数据,我建议不要开二级缓存,也不要开一级缓存来承担业务正确性,否则会出现“一个事务里查到旧库存,另一个事务已经扣减成功”的脏读问题。缓存可以用于图书分类、出版社列表这种不敏感数据,库存和资金相关数据一律直查数据库。

2.2 Vue 只负责界面装配,不承担业务判断

Vue 在项目里的定位就是“界面装配层”:把后端返回的数据渲染成表格、表单、统计图,收集用户输入后提交给后端。很多开源项目跑不起来,问题不在后端,而是前端写得太“重”——把库存计算、金额计算、甚至权限判断全部堆在页面里,一旦刷新页面或绕过前端直连接口,数据就对不上。

我的做法是:前端只保留两类逻辑。一类是纯 UI 状态,比如弹窗开关、表单校验、按钮 loading;另一类是轻量的展示计算,比如表格里根据单价和数量算出整行金额。至于库存扣减、单据号生成、事务提交这种核心逻辑,全部放后端 Service 层。这样分层的直接好处是,你用 Postman 测后端时,能测试出和页面一致的结果,不会出现“页面看着正常、接口测完数据是坏的”的情况。

2.3 版本选型和环境配置的常见坑

版本搭配是很多新手忽略的问题。Spring Boot 3.x 要求 JDK 17,如果你电脑装的是 JDK 8,硬上 Spring Boot 3 会直接编译失败。相反,如果你已经装了 JDK 17,却找了个 Spring Boot 2.3 的旧教程,也会遇到各种兼容问题。我给出一份当前比较稳妥的搭配方案:

组件推荐版本说明
JDK8 或 17JDK8 配 Spring Boot 2.7,JDK17 配 Spring Boot 3.2
Spring Boot2.7.18 或 3.2.x2.7 生态资料多,3.2 更贴近新版语法
mybatis-spring-boot-starter2.3.x 或 3.0.3Boot3 必须用 starter 3.x,否则启动直接报错
MySQL5.7 或 8.0字符集统一 utf8mb4,8.0 注意SSL和公钥检索
VueVue3 + Vite 或 Vue2 + Vue CLI熟练用哪种就选哪种,不要混合
Node.js18 以上Vite 5 要求较新版本

这里特别提醒热词里常被问到的“SpringBoot版本太高”问题。如果你用了 Spring Boot 3.2,却在 pom.xml 里写mybatis-spring-boot-starter的 2.3.x,应用启动时会缺SqlSessionFactory相关类,报错信息又长又难读。解决办法很简单:统一把 starter 换成 3.0.x;如果是 Spring Boot 2.7,则用 2.3.x。这类版本匹配问题,在选型时列一张表格固定下来,后面能少踩很多坑。

3. 数据库设计:用流水账约束库存,而不是直接改数值

3.1 核心表与关键字段

数据库表设计直接决定项目上限。我见过有人把所有数据塞进三张表里:user、book、order,看着简单,后期想加个供应商都无从下手。基于进销存业务,我建议至少设计以下核心表:

  • 图书档案表 book:isbn、book_name、author、publisher、publish_date、price、safety_stock、status。isbn 要加索引,因为进销存里图书检索最常用 ISBN 和书名模糊查询。
  • 供应商表 supplier:supplier_name、contact_person、phone、remark。
  • 客户表 customer:customer_name、phone、type(个人/单位)。
  • 入库单表 stock_in:in_no、supplier_id、operator_id、in_time、total_amount、status。
  • 入库单明细表 stock_in_item:in_id、book_id、quantity、price、amount。
  • 出库单表 stock_out:out_no、customer_id、operator_id、out_time、total_amount、status。
  • 出库单明细表 stock_out_item:out_id、book_id、quantity、price、amount。
  • 实时库存表 stock:book_id、quantity、version。
  • 库存流水表 stock_log:book_id、biz_type、biz_no、before_qty、change_qty、after_qty、create_time。
  • 用户表 sys_user:username、password、real_name、role。

这里最有价值的表是 stock_log 和 stock 表分开设计。很多新手会直接在 book 表上一个stock字段,入库就stock+1,出库就stock-1,这样做也不是不能用,但一遇到数据异常就完全没法追溯:明明是操作错了,却不知道是哪个单据、哪个环节改的。把库存流水单独成表,等于给库存账本加了“流水底账”,每笔变动都有据可查,这也是面试或答辩时最值得展开讲的亮点。

3.2 库存字段与库存流水的关系

实时库存表 stock 只保存当前数量,可以理解为“账本余额”;库存流水表 stock_log 保存每一次变化的明细,可以理解为“流水明细”。进销存的精髓就是“余额由流水累加而来”,而不是凭空填一个数值。数据库脚本初始化时,可以把 book 表里的冗余 stock 字段删掉,统一从 stock 表取库存。

设计时要特别注意几个约束。第一,book.isbn 应设置唯一索引,防止同一本书重复建档。第二,stock_in 和 stock_out 的单号字段要唯一,可以在 MySQL 层用唯一索引兜底,防止并发生成重复单号。第三,所有金额字段用 decimal,禁止用 float 和 double,否则会出现 0.1+0.2 不等于 0.3 的精度问题。第四,所有时间字段默认CURRENT_TIMESTAMP,并统一使用serverTimezone=Asia/Shanghai,避免 JDBC 连接 MySQL 时出现时区偏差。这条连接参数我放到排错清单里再细说。

如果你从零开始建库,我建议先建 book、supplier、customer、sys_user 这些基础表,再建 stock_in、stock_out 和对应明细表,最后再建 stock 和 stock_log。顺序很重要,因为入库单和出库单都依赖基础表的外键;一旦先建单据表再建基础表,写初始化脚本时顺序就容易乱。

4. 后端实现笔记:事务、动态 SQL 和并发扣库存

4.1 入库单保存:一个方法里完成四件事

后端最核心的一个方法,是“保存入库单并更新库存”。这个方法的正确写法,决定了整个系统能不能扛住业务校验。我们看一下整体步骤:

@Transactional(rollbackFor = Exception.class) public Long createStockIn(StockInDTO dto) { // 1. 生成入库单号,插入入库单主表 StockInEntity stockIn = new StockInEntity(); stockIn.setInNo(generateInNo()); stockIn.setSupplierId(dto.getSupplierId()); stockIn.setOperatorId(dto.getOperatorId()); stockIn.setTotalAmount(calculateTotal(dto.getItems())); stockInMapper.insert(stockIn); // 2. 批量插入入库明细 List<StockInItemEntity> items = buildItems(dto.getItems(), stockIn.getId()); stockInItemMapper.insertBatch(items); // 3. 循环明细,更新实时库存 for (StockInItemEntity item : items) { stockMapper.increaseStock(item.getBookId(), item.getQuantity()); // 4. 写库存流水 stockLogMapper.insert(StockLogEntity.increase(item, stockIn.getInNo())); } return stockIn.getId(); }

这段代码里最不能省的就是@Transactional(rollbackFor = Exception.class)。默认情况下,Spring 事务只对运行时异常回滚,如果你在业务里抛了一个自定义异常,不加 rollbackFor,事务就不会回滚,库存还是会变。我的习惯是统一加rollbackFor = Exception.class,保证只要方法抛出任何异常,前面插入的单据和 Update 的库存全部撤销。

这里的执行顺序也有讲究:先插单据主表,再插明细,最后更新库存。为什么不是先更新库存再插单据?因为入库存更新前要先拿到单据ID,明细表需要这个外键;而库存更新放在后面,是为了让“单据全部落库成功后再动余额”,避免“余额变了,单据没生成”的尴尬情况。实际压测中你会发现,这种顺序配合事务,连中途报错导致的脏数据也一并处理掉了。

4.2 MyBatis 动态 SQL:多条件查询和批量插入

图书进销存的后端接口,基本上逃不过多条件分页查询:按 ISBN、书名、出版社、库存区间组合筛选。如果为每一种组合写一条 SQL,工作量巨大,而且容易漏。MyBatis 的动态 SQL 就是为这种场景准备的。我给一个典型例子:

<select id="selectBookPage" resultType="com.demo.entity.Book"> select * from book <where> <if test="keyword != null and keyword != ''"> and (book_name like concat('%', #{keyword}, '%') or isbn like concat('%', #{keyword}, '%')) </if> <if test="publisher != null and publisher != ''"> and publisher = #{publisher} </if> <if test="stockMin != null"> and quantity &gt;= #{stockMin} </if> </where> order by id desc </select>

这里最需要注意的是,XML 里的<、>符号要转义,比如&gt;=,否则 XML 解析报错。另外一个坑是like拼接:MySQL 里推荐用concat('%', #{keyword}, '%'),不要直接在 XML 里写'%${keyword}%',后者会有 SQL 注入风险。

批量插入明细是另一个高频场景。入库单的明细可能几十条,最忌讳在 Java 里 for 循环一条条 insert,性能差且事务日志大。MyBatis 的 foreach 可以一次插入多条:

<insert id="insertBatch"> insert into stock_in_item(in_id, book_id, quantity, price, amount) values <foreach collection="list" item="item" separator=","> (#{item.inId}, #{item.bookId}, #{item.quantity}, #{item.price}, #{item.amount}) </foreach> </insert>

这里有个隐含要求:传入的 list 必须保证每条记录的 inId 都已赋值,否则外键为 null,最终会导致查询关联时数据丢失。所以 Service 层要先把入库单主表插入并拿到自增主键,再给每条明细 set inId,再调用 insertBatch。顺序我前面已经写进那段 Java 代码了,照着做不会错。

4.3 并发扣库存:乐观锁和正确的事务边界

图书进销存最经典的技术难点,就是“两个销售员同时卖出同一本书的最后一本”。如果代码写成三步:先查库存,判断数量够不够,再 update 库存,两个请求会同时读到库存还有 1,各自都认为可以卖,最后 update 两次,库存变成 -1。解决这个问题的标准方案,是在 SQL 层用条件更新,把“判断库存是否足够”和“扣减库存”放在一条 update 里完成。

<update id="decreaseStock"> update stock set quantity = quantity - #{delta}, version = version + 1, updated_time = now() where book_id = #{bookId} and quantity >= #{delta} </update>

执行这条 SQL 后,如果影响行数为 1,说明扣减成功;如果影响行数为 0,说明库存不足或数据被其他事务并发修改,这时要抛出“库存不足”的提示,并让整个销售单事务回滚。这里利用的是数据库行锁的原子性,比先查后改安全得多。

事务边界同样要控制好。扣减库存和插入销售单必须处于同一个事务中,但事务内不要做太多无关操作,比如不要在事务里调用外部 HTTP 接口、不要执行大批量耗时查询。事务时间越长,数据库连接持有越久,并发能力越差。我在实际项目里的习惯是,把库存扣减放在事务最后一步,前面把单据、明细都准备好,这样事务持锁时间最短。

如果要扩展,还可以在 stock 表加一个 version 字段做乐观锁。在 update 条件里加上and version = #{oldVersion},更新后version = version + 1,如果影响行数为 0,说明数据已被其他人改过,需要重试或提示。不过对于单体图书进销存系统,前面那条quantity >= #{delta}的条件更新已经够用,单纯加 version 反而多一次查询,收益不大。

5. 前端联调实录:路由权限、Axios 封装和打包放回 Spring Boot

5.1 动态路由:登录后按角色生成菜单

Vue 前端的核心难点不是写页面,而是动态路由。图书进销存系统有采购员、库管员、销售员、管理员等角色,总不能把所有菜单都展示给所有人。我的做法是,路由表分为基础路由和业务路由两部分:基础路由只有 login 和首页框架;业务路由按角色配置,登录成功后根据当前用户角色动态添加。

登录成功后,前端先请求后端接口获取用户角色,再动态生成可访问路由。Vue Router 4 里用router.addRoute逐个添加;Vue Router 3 里可以用router.addRoutes批量添加。具体逻辑大致是:

const businessRoutes = { admin: [{ path: '/book', component: BookList }, { path: '/stock/in', component: StockIn }], operator: [{ path: '/stock/in', component: StockIn }, { path: '/stock/check', component: StockCheck }], seller: [{ path: '/stock/out', component: StockOut }] }; router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (!token && to.path !== '/login') return next('/login'); if (token && !store.getters.roles) { return getUserInfo().then(res => { store.commit('setRoles', res.roles); businessRoutes[res.roles].forEach(route => router.addRoute('layout', route)); next({ ...to, replace: true }); }); } next(); });

这里有两个细节:一是后端返回的 roles 字段要可靠,前端只做展示控制,真正的接口权限必须后端拦截;二是刷新页面时动态路由要重新生成,否则直接访问某个业务 URL 会因为路由还没注册而 404。很多前端小白踩过刷新丢路由的坑,我这里先提个醒。

5.2 Axios 拦截器与统一响应格式

前后端接口联调时,我最推荐先约定统一的 JSON 响应体,比如{ code: 200, msg: 'success', data: ... }。前端 Axios 在响应拦截器里统一处理,这样每个接口就只需要关注 data 部分,错误提示和登录失效统一拦截,页面代码会干净很多:

axios.interceptors.response.use(res => { const { code, msg, data } = res.data; if (code === 200) return data; if (code === 401) { router.push('/login'); return Promise.reject(new Error('登录已过期')); } Message.error(msg || '请求失败'); return Promise.reject(new Error(msg || '请求失败')); });

请求拦截器里,每次都从 localStorage 拿到 token 塞进请求头。要注意的是,后端如果使用 JWT,一定要在拦截器里校验 token 有效性,而不是把校验逻辑全放在 Controller 里;否则就只是前端“看着有权限”,接口其实谁都能调。图书进销存涉及库存和价格,接口安全不能只靠前端藏按钮。

5.3 dist 打包放进 Spring Boot 的细节

很多项目最后部署是在同一台机器上跑一个 jar,所以前端最好打包后放进 Spring Boot 的src/main/resources/static目录。操作流程很简单:

  1. 前端执行npm run build,生成 dist 目录。
  2. 把 dist 下的文件复制到src/main/resources/static。
  3. 重新打包后端 jar,启动后访问http://localhost:8080就能看到页面。

但这一步有几个常见坑。第一个坑是路由模式。Vue 默认的 history 模式刷新任意子路径时,后端没有对应路由,会返回 404。想省事,就改用 hash 模式,URL 是/#/book这种,刷新不会 404。第二个坑是静态资源路径。如果后端设置了server.servlet.context-path=/bookstore,那前端打包时也要把资源路径设置成/bookstore/;Vite 项目里可以在vite.config.js设置base: '/bookstore/',否则 CSS、JS 会 404。第三个坑是接口地址。前端开发环境通过 Vite proxy 把/api转发到后端,生产环境打包后,则要把请求地址改成相对路径/api,由后端统一提供接口。否则页面部署了但接口全部连不上,照样白屏。

6. 从建库到跑通:一份可以直接照搬的联调顺序与排错清单

6.1 推荐联调顺序:先后端最小闭环,再前端对接

很多同学习惯“后端写完全部再写前端”,结果前端一上来就报各种错,还不知道问题出在后端还是前端。我更推荐按模块垂直推进,每个模块后端自测通过后,马上接前端。

第一步,先建库建表,导入初始化数据,至少包含一个 admin 账号和少量图书数据。第二步,完成后端登录接口,用 Postman 测通并拿到 token。第三步,写图书分页查询接口,再测一遍。第四步,做采购入库接口,这里要重点看事务是否生效,可以用 Postman 连续调用两次同一个入库单,看库存是否重复增加。第五步,做销售出库接口,先故意传一个超过库存的数量,确认接口返回“库存不足”,且没有产生脏数据。第六步,启动前端 Vite 开发环境,配好代理,先跑登录,再连列表页。最后再做打包放入 static 的部署验证。

这套联调顺序的核心思路是“后端先闭环,前端再对接”。图书进销存模块间有依赖,如果你先把销售出库做好了,入库却没做,测试时会发现没有库存可卖,整个流程就卡死了。按依赖关系推进,每一步都有数据可验,效率最高。

6.2 我遇到过的五个高频问题

这里整理我实际开发中踩过的几个典型问题,很多是搜索热词里的常见内容,直接给结论。

现象根本原因解决办法
MySQL 8 连不上,报 Public Key Retrieval 错误JDBC 默认不检索公钥URL 加上allowPublicKeyRetrieval=true&useSSL=false&serverTimezone=Asia/Shanghai
启动成功但访问接口返回 Whitelabel Error Page后端没有对应路由,或 mapper 扫描失败检查 Controller 路径;启动类加@MapperScan
MyBatis 报 Invalid bound statement (not found)Mapper 接口和 XML 命名空间/方法 ID 不对应检查mapper-locations路径,并确认 XML 在target/classes下
端口被占用,8080 起不来其他 Java 进程占用了端口改server.port=8081,并同步前端代理端口
Spring Boot 3 项目启动瞬间报错MyBatis starter 版本仍是 2.x换成mybatis-spring-boot-starter的 3.0.x

再补充一个 MySQL 5.7.44 安装时的常见场景:很多人用 zip 包安装,结果服务启动失败,日志提示找不到 data 目录。这是因为第一次启动前没有初始化数据目录。解决办法是在my.ini里配置好basedir和datadir,然后执行mysqld --initialize-insecure,这样会生成一个无密码的 root 账号,再启动服务就不会报错了。第一次初始化很重要,别跳过。

7. 让项目更好看的小功能:库存预警、流水回溯与扩展思路

7.1 三个投入小见效快的功能

图书进销存系统做到“能跑”容易,做到“好看”需要几个细节功能。我最推荐加三个:库存预警、流水追溯、自动生成单据号。

库存预警做起来很简单:book 表已经有safety_stock字段,后端写一个定时任务,每天扫描“库存小于安全值”的图书,生成预警记录。前端可以在首页放一个通知列表,或者直接展示红色状态。这个功能成本极低,但演示、答辩时非常直观,评委会觉得你真在考虑业务问题。

流水追溯则是把第 3 节设计的 stock_log 表用起来。前端在每个图书的库存详情里加一个“库存流水”按钮,点击后弹出表格,展示这本书每一次的入库、出库、盘点和当前余量。这个东西比空口讲“我做了库存表”有说服力得多。还有单据号自动生成:入库单号建议用IN + 年月日 + 三位流水号的格式,比如IN20250617001,生成时用数据库唯一索引兜底,避免并发重复。这功能很基础,但几乎所有的正式进销存系统都这么设计,你提前做了,就是比别人多一个亮点。

7.2 后续扩展方向与一点个人经验

图书进销存系统后续可以扩展的方向不少:比如对接商品条码扫码枪,出入库直接扫码;再比如接入简单的图表统计,用 ECharts 展示每月的进销趋势和毛利情况;也可以把采购审批流程加进来,让采购单经过管理者审核后再入库。这些都是很好的演进方向,但别在一开始就铺开做,容易把自己累垮。

我自己的习惯是,每完成一个模块,先把数据库里的数据导出来看一眼:入库单有没有?明细有没有?库存变了没有?流水有没有?这四张表对上了,这个模块就是真做完了。项目做得好不好,很多时候不取决于技术多炫,而取决于核心链路是否经得起追问。拿图书进销存来说,只要你能把“一张采购入库单从录入到库存增加再到流水追溯”完整讲清楚,再讨论一下并发扣库存的方案,这个项目就已经是优质完成了。

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

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

立即咨询