
1. 这道毕设题目到底难在哪为什么每年都有人做其实每年到了毕业设计选题季仓库管理系统都是出现频率最高的几个题目之一。原因很直白功能边界清楚需求不用猜Spring Boot的生态资料又多随便搜一下就是一堆开源项目可以参考。但正因为做的人多答辩老师的要求也被抬高了——如果你只是把登录、增删改查、分页搜索凑一起大概率只能拿到一个中规中矩的分数。我手上这个项目是基于Spring Boot的网格仓管理系统表面看和普通仓库管理系统差不多核心都是入库、出库、库存查询但多了一个网格仓的概念。网格仓不是什么新东西本质上就是按区域网格划分的小型前置仓常见于社区团购、同城零售这类场景。每个网格仓服务周边若干个社区仓里有货架、有库位仓和仓之间还会发生调拨。这就意味着系统除了常规的仓库作业还得考虑到多仓数据隔离、任务分配、进度汇总这类实际问题。这个题目的受众很明确课程设计一个学期内能跑通功能点够交差文档写得出来毕业设计需要在课程设计基础上提升比如加权限、加报表、加事务控制让答辩时有东西可以讲以后想走Java后端方向正好借这个项目把Spring Boot MyBatis MySQL这套主流技术栈完整走一遍。我当时接手这个题目的时候判断它的核心难点不在能不能写出来而在能不能写得像个正经系统。管理系统的本质是数据的一致性和流程的闭环比如出库了库存必须减、入库了库存必须加、调拨过程中不能出现两边库存同时多出来。这些业务逻辑写起来不难但很容易在细节上翻车。这篇文章我就按自己的实际开发顺序来复盘从数据库设计到核心模块实现再到打包部署和答辩准备把我踩过的坑一并讲清楚。2. 技术栈选型Spring Boot MyBatis为什么是最稳妥的组合2.1 为什么不选SSH甚至不选JPA很多刚开始接触Java Web的同学可能还听过SSHStruts Spring Hibernate这个老掉牙组合。我只能说2024年了再用SSH做毕设不仅IDE装插件的时候费劲而且网上资料少得可怜出了问题你连报错都搜不明白。Spring Boot之所以成为事实标准核心就是约定优于配置内嵌Tomcat、自动装配、Starter机制让你把精力放在业务逻辑而不是XML配置上。JPA/Hibernate不是不好但对于毕设场景它有个很尴尬的问题你写实体类的时候很容易迷失在映射关系里。仓库管理系统有大量多表关联查询比如查一张入库单要带出商品名称、供应商名称、操作人、仓库名称用JPA的双向关联写起来又绕又难调试。而MyBatis的思路就粗暴直接——SQL你自己写结果映射你指定查出来是什么直接给你。对于习惯了写SQL的同学来说这简直是降维打击。2.2 项目目录结构分层清晰是答辩的第一张牌我见过很多同学的项目Service层和Controller层混在一起业务逻辑写在Controller里一个方法几百行。答辩老师只要打开你的源码看一眼目录结构就已经给项目打了初步分。我这次的项目结构如下com.example.gridwarehouse ├── controller // 接口层只做参数接收和响应封装 │ ├── LoginController.java │ ├── StockController.java │ ├── InboundController.java │ ├── OutboundController.java │ └── TransferController.java ├── service // 业务层事务边界都在这层 │ ├── StockService.java │ ├── InboundService.java │ └── ... ├── mapper // MyBatis的Mapper接口 │ ├── StockMapper.java │ └── ... ├── entity // 数据库对应的实体类 ├── vo // 视图对象用于给前端返回组装好的数据 ├── config // 拦截器、CORS配置等 ├── common // 统一返回结果Result、异常处理 └── utils // 工具类这里有个细节很多教程喜欢把返回结果直接返回Map或者干脆返回一个JSON字符串这很业余。我在common包下定义了一个统一的Result类大概长这样Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(操作成功); result.setData(data); return result; } public static T ResultT error(String message) { ResultT result new Result(); result.setCode(500); result.setMessage(message); return result; } }统一响应结构的好处很多前端判断是否有异常只需要看code后端报错时错误信息也是一致的。毕设答辩时你可以直接说我通过统一返回结构约定的方式降低了前后端联调的沟通成本——这种话术比我用了RESTful API有说服力得多。2.3 MyBatis配置与分页插件的选择项目里我用的依赖核心就这几个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency分页我不推荐手写LIMIT去算offset太容易出错。我直接用PageHelper一个拦截器搞定分页。配置如下pagehelper: helper-dialect: mysql reasonable: true # 页码超出范围时自动归到边界比如查第100页但总共只有3页会自动查最后一页 support-methods-arguments: true这里有个坑PageHelper的reasonable: true虽然好用但你必须在Mapper方法调用之前设置PageHelper.startPage()而且紧跟其后的那条查询必须是你要分页的那条中间不能穿插别的查询否则分页会错乱。这个我后面详细说。3. 数据库设计网格仓的核心表结构和字段取舍3.1 实体关系梳理先画好表和表之间的线写代码之前我把整个系统的实体关系捋了一遍。一个网格仓管理系统的核心实体大概有这些实体核心字段说明用户用户名、密码、角色、所属网格仓ID角色分管理员、网格员、仓管员网格仓仓编码、仓名称、所属区域、地址、负责人每个仓有独立的库位供应商名称、联系人、电话入库来源商品编码、名称、分类、规格、单位、预警阈值单品管理库存仓库ID、商品ID、库存数量、锁定数量一仓一商品一条记录入库单单号、供应商、仓库、状态、入库时间主表入库单明细入库单ID、商品ID、数量、单价子表出库单单号、客户/渠道、仓库、状态、出库时间主表出库单明细出库单ID、商品ID、数量子表调拨单调出仓、调入仓、商品ID、数量、状态仓库间调拨库存流水商品ID、仓库ID、变动类型、变动前数量、变动后数量、关联单号核心留痕表这里最需要重点考虑的是主表明细表的模式。比如入库单和入库单明细前者记录单据整体信息单号、总数量、操作人、状态后者记录每个商品入库的明细商品、数量、单价。这是几乎所有ERP系统的通用设计因为单据本身是业务凭证不能像流水账一样删改所有变更都要有迹可循。3.2 库存表和流水表两个容易设计错的地方先说库存表。最经典的设计是CREATE TABLE stock ( id BIGINT AUTO_INCREMENT PRIMARY KEY, warehouse_id BIGINT NOT NULL COMMENT 网格仓ID, product_id BIGINT NOT NULL COMMENT 商品ID, quantity INT NOT NULL DEFAULT 0 COMMENT 在库数量, locked_quantity INT NOT NULL DEFAULT 0 COMMENT 锁定数量出库单审核后先锁定, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, UNIQUE KEY uk_wh_product (warehouse_id, product_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意我加了一个locked_quantity字段。这个字段是用来处理出库单已提交但还没实际发货这个场景的。举例用户创建了一张出库单这时候如果直接把库存减掉万一出库单被取消还得把库存恢复而且容易出问题。更合理的做法是创建出库单时把对应商品的数量锁定quantity不变locked_quantity增加实际发货确认时再扣减quantity和locked_quantity。这个设计放在答辩里讲老师会高看你一眼。而库存流水表是很多初学同学完全没想到要做的。没有流水表意味着你无法回答这个商品为什么少了10件这个问题。我设计的流水表大概是CREATE TABLE stock_flow ( id BIGINT AUTO_INCREMENT PRIMARY KEY, product_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, change_type TINYINT NOT NULL COMMENT 1-入库 2-出库 3-调拨出 4-调拨入 5-盘盈 6-盘亏, change_quantity INT NOT NULL COMMENT 变动数量正数增加负数减少, before_quantity INT NOT NULL COMMENT 变动前库存, after_quantity INT NOT NULL COMMENT 变动后库存, business_no VARCHAR(64) NOT NULL COMMENT 关联业务单号, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_product (product_id), KEY idx_warehouse (warehouse_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;核心思路是记录变动前和变动后的快照。很多人只记一个变动数量那也不够因为追溯的时候你没法还原当时的库存环境。把before和after都记录就能完整还原任意时刻的库存状态。3.3 数据库DDL之外初始化数据有多重要数据库设计完之后别急着写代码先把初始化数据写好。比如管理员账号、几个网格仓、十几种常见的商品、一个供应商。初始化数据有两个作用一是后期写接口联调时不用反复造数据二是生成数据库文档、写论文时可以直接引用。我习惯把初始化SQL放在项目的resources/db/data.sql里然后用Spring Boot的spring.sql.init.modealways在启动时自动执行或者干脆用Navicat手动导入。对毕设项目来说不建议用Flyway这种版本管理工具——它虽然专业但复杂度超标答辩老师不一定关心这个。你只需要保证拿到项目的人能一条命令运行起来就好。4. 核心模块实现从登录认证到出入库的完整业务逻辑4.1 登录与权限拦截尽量简单但要有设计感登录认证这块毕设项目一般不引入Spring Security或Shiro理由是太重了学起来费劲答辩时也容易讲不清楚。我选择的是最传统的方案登录成功后在Session里存用户信息用一个HandlerInterceptor拦下需要登录的接口。Component public class LoginInterceptor 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) { // 未登录返回501状态码前端根据状态码跳转到登录页 response.setContentType(application/json;charsetutf-8); response.getWriter().write(JSON.toJSONString(Result.error(未登录或登录已过期))); return false; } // 将用户信息放入request方便后续controller取用 request.setAttribute(loginUser, loginUser); return true; } }然后注册拦截器并排除登录接口和静态资源Configuration public class WebConfig implements WebMvcConfigurer { Resource private LoginInterceptor loginInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns(/**) .excludePathPatterns(/user/login, /css/**, /js/**, /images/**, /doc.html); } }关于权限控制我不建议在拦截器里写太复杂逻辑。我的做法是登录用户分为三个角色系统管理员、网格仓管理员、网格员。系统管理员可以看所有网格仓的数据网格仓管理员只能看自己所在仓的数据网格员只能操作出入库和盘点。这个数据范围的控制其实是在SQL层面实现的——查询时根据登录用户的warehouse_id带条件。比如SELECT * FROM inbound_order WHERE ( #{role} ADMIN OR warehouse_id #{warehouseId} )这样至少保证不同角色的人登录上去看到的业务数据范围是不同的。这个功能很多毕设没有你加了就是加分项。4.2 入库流程从页面表单到库存增加中间有哪些校验入库的业务逻辑不复杂但要注意的点很多。当网格仓管理员提交一张入库单时后端应该做这几件事校验供应商是否存在校验商品编码是否合法创建入库单主表记录状态为待审核创建入库单明细记录保存每个商品的数量和单价审核通过后遍历明细逐条更新库存表——商品存在则增加quantity不存在则插入新记录逐条写入库存流水表。这里第4和第5步必须在一个事务里完成。很多人写到这里就是for循环挨个update但忘了加Transactional。这个注解如果不加万一第5个商品更新库存成功了、第6个商品插入商品ID不存在抛异常前5个商品的库存就白加了而且没有任何提示。我在Service层的方法是这样组织的Transactional(rollbackFor Exception.class) public void approveInboundOrder(Long orderId) { // 1. 查询入库单主表校验状态 InboundOrder order inboundOrderMapper.selectOrderById(orderId); if (order null || !0.equals(order.getStatus())) { throw new BizException(单据不存在或当前状态不允许审核); } // 2. 查询明细列表 ListInboundOrderItem items inboundOrderMapper.selectItemsByOrderId(orderId); if (items.isEmpty()) { throw new BizException(入库单没有明细无法审核); } // 3. 逐条更新库存 for (InboundOrderItem item : items) { int updated stockMapper.addQuantity(item.getProductId(), order.getWarehouseId(), item.getQuantity()); if (updated 0) { // 因为stock表有(warehouse_id, product_id)唯一索引不存在时需要插入 stockMapper.insertStock(order.getWarehouseId(), item.getProductId(), item.getQuantity()); } // 4. 记录流水 stockMapper.insertFlow(item.getProductId(), order.getWarehouseId(), 1, item.getQuantity(), beforeQuantity, afterQuantity, order.getOrderNo()); } // 5. 更新单据状态为已审核 inboundOrderMapper.updateStatus(orderId, 1); }这里有个细节值得拿出来讲为什么用addQuantity的返回值去判断库存记录是否存在。因为数据库层面有uk_wh_product唯一索引在高并发下如果你先查询是否存在再决定插入还是更新中间可能被别的请求插队导致唯一索引冲突报错。而UPDATE语句会受行锁影响返回0就说明没有这条记录此时再INSERT这是更稳妥的做法。4.3 出库流程锁定库存和扣减库存两段式处理出库我采用的是两段式思路其实和电商下单类似第一步创建出库单时锁定库存。此时把stock表里locked_quantity增加同时quantity不变。这样不会影响其他商品的正常售卖/出库。UPDATE stock SET locked_quantity locked_quantity #{quantity} WHERE warehouse_id #{warehouseId} AND product_id #{productId} AND quantity #{quantity}注意WHERE quantity #{quantity}这个条件——如果库存不够UPDATE的返回结果是0此时直接抛出库存不足的异常。这个写法比先查库存再判断更安全也减少了一次数据库查询。第二步确认发货时扣减库存。此时把quantity和locked_quantity同时减少。UPDATE stock SET quantity quantity - #{quantity}, locked_quantity locked_quantity - #{quantity} WHERE warehouse_id #{warehouseId} AND product_id #{productId} AND locked_quantity #{quantity}同样的如果返回0则说明锁定数量不够说明业务状态已经错乱需要记录日志并人工介入。这个细节很关键因为锁定不够和库存不够是两种完全不同的异常前者属于系统逻辑bug后者属于正常业务拒绝。4.4 盘点模块盘盈盘亏怎么生成差异记录盘点模块是很多仓库管理系统里容易被轻视的部分但它是体现你系统完整度的试金石。我的盘点流程创建一个盘点单选择某个网格仓系统自动带出该仓所有商品的账面库存数量盘点人录入实际盘点数量提交后系统逐一比对如果差异不为0生成盘盈或盘亏记录并直接调整库存所有差异都写入库存流水表变动类型是5盘盈或6盘亏。这里比较讲究的是**盘点期间库存是否允许变动**的问题。现实中盘点应该冻结库存但毕设如果做冻结逻辑会非常痛苦。我的简化方案是允许盘点期间库存变动但盘点记录的是提交瞬间的账面数并在盘点报告中标注盘点期间可能发生的出入库差异不作为盘点差异——这属于业务上合理、技术上简单的取舍。答辩的时候你可以说考虑到实际业务中盘点通常安排在业务量小的时段所以我没有做冻结而是通过流水表记录校验差异这样既诚实又显示了你的思考。5. 网格仓特有的业务调拨、统计看板和权限数据范围5.1 仓间调拨怎么避免两边同时多货网格仓系统比单仓系统多了调拨模块。很多同学以为调拨就是A仓减、B仓加但真正的问题是跨仓事务。在单机MySQL下两个仓库的数据都在同一个库所以一个Transactional方法里先减后加其实没问题。但在真实的分布式场景两个仓可能是不同的物理库那就需要消息队列或者分布式事务了。考虑到毕设的定位我用的是同一个库内的多仓调拨核心逻辑是生成一张调拨单然后在一个事务内完成调出仓扣减少数量调入仓增加数量 记录两个仓的流水。Transactional(rollbackFor Exception.class) public void confirmTransfer(Long transferId) { TransferOrder order transferMapper.selectById(transferId); if (order null || !0.equals(order.getStatus())) { throw new BizException(调拨单不存在或状态错误); } // 调出仓扣减 int outResult stockMapper.addQuantity(order.getOutWarehouseId(), order.getProductId(), -order.getQuantity()); if (outResult 0) { throw new BizException(调出仓库存不足); } // 调入仓增加 int inResult stockMapper.addQuantity(order.getInWarehouseId(), order.getProductId(), order.getQuantity()); if (inResult 0) { stockMapper.insertStock(order.getInWarehouseId(), order.getProductId(), order.getQuantity()); } // 写两条流水调拨出、调拨入 stockMapper.insertFlow(order.getProductId(), order.getOutWarehouseId(), 3, -order.getQuantity(), ...); stockMapper.insertFlow(order.getProductId(), order.getInWarehouseId(), 4, order.getQuantity(), ...); // 更新调拨单状态 transferMapper.updateStatus(transferId, 1); }实际开发时我在这里踩过一个坑调出仓库存扣减成功后如果调入仓库存增加时插入新记录失败整个事务会回滚——因为Transactional会把两个操作看作一个原子操作。但如果我没有加Transactional库存就被扣了但没加到别的仓这就是严重的数据不一致。所以所有涉及库存变动的多步操作一律要加事务注解这是毕设做管理系统最基本也是最重要的纪律。5.2 首页统计看板让老师一眼看到项目的完成度很多毕设的管理系统首页只放一张欢迎图太浪费了。你的仓库管理系统首页完全可以做成一个统计看板显示今日入库单数、入库商品总件数今日出库单数、出库商品总件数库存预警商品数量库存低于阈值的商品各网格仓库存量TOP5排名近7天出入库趋势图。趋势图后端只需要返回一个按日期聚合的结果SELECT DATE(create_time) AS day, COUNT(*) AS order_count, SUM(quantity) AS total_quantity FROM outbound_order_item oi JOIN outbound_order o ON oi.order_id o.id WHERE o.create_time DATE_SUB(CURDATE(), INTERVAL 7 DAY) GROUP BY DATE(create_time) ORDER BY day前端用ECharts展示成柱状图或者折线图效果非常直观。我做这个看板花了一个晚上但答辩时的效果是值的——老师一打开系统不用点任何菜单就看见数据在动第一印象分就上来了。5.3 数据范围权限SQL层面隔离比代码层面硬隔离更实用前面提到过网格仓管理系统的用户分属不同仓。为了避免网格员A看到网格仓B的数据这种低级问题我统一在Mapper的SQL里拼条件。怎么拼我定义了一个BaseContext工具类从登录Session里取当前用户public class UserContext { public static Long getCurrentWarehouseId() { // 从ThreadLocal获取当前请求的登录用户 User user UserHolder.getUser(); if (user ! null !ROLE_ADMIN.equals(user.getRole())) { return user.getWarehouseId(); } return null; // null表示管理员不过滤 } }然后查询一批数据时在Mapper XML里统一处理select idselectStockList resultTypeStockVO SELECT s.*, p.name AS productName, p.spec AS productSpec FROM stock s JOIN product p ON s.product_id p.id where if testwarehouseId ! null AND s.warehouse_id #{warehouseId} /if if testproductName ! null and productName ! AND p.name LIKE CONCAT(%, #{productName}, %) /if /where /select这样做的优点是前端代码不用关心当前用户是谁的仓后端统一兜底。缺点是你必须在每个Mapper方法里都记得传warehouseId否则就会出错。我在实际开发时给自己立了一个规矩凡是涉及多仓数据的查询第一个参数永远是warehouseId写SQL的第一个条件永远是warehouseId。这条纪律后来帮我省了很多麻烦。6. 从零到部署环境准备、常见报错和打包演示6.1 环境准备清单项目保证能跑起来的前提是我自己在干净的机器上装过一遍这很重要。我给同学演示时的环境清单组件版本安装方式JDK1.8官网下载或OpenJDKMaven3.6.3IDEA自带或单独安装MySQL5.7 / 8.0本地安装或DockerIDEA2023.x 社区版即可官网下载前端模板Thymeleaf 或 Vue根据需要选择我建议后端接口用统一Result结构返回JSON前端不管是用Vue还是用Thymeleaf模板都能接。如果你的前端基础一般建议直接用Thymeleaf服务端渲染因为这样后端一个Controller方法就能返回一个完整页面模板引擎渲染不需要额外部署前端工程。我这次项目为了方便用的是前后端分离的方式但是Vue工程也只是build完之后放进后端的static目录让Spring Boot直接托管这样部署的时候还是只有一个jar包。6.2 数据库连接配置别在连接串上栽跟头有一些同学在别人项目基础上改最容易出现的问题是数据库连接不上。我用的配置文件如下spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/grid_warehouse?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456重点注意serverTimezoneAsia/Shanghai不加会报时区错误characterEncodingutf8不加会导致中文字段乱码MySQL 8.x 的驱动类必须是com.mysql.cj.jdbc.Driver老版本写com.mysql.jdbc.Driver会直接报ClassNotFound如果你用的是MySQL 8.0以上的版本驱动依赖用mysql-connector-j而不是mysql-connector-java。6.3 项目打包与答辩演示技巧打包部署很简单在IDEA右侧Maven面板执行package然后在target目录下生成一个jar包。执行java -jar grid-warehouse-0.0.1-SNAPSHOT.jar答辩演示时一定要提前准备干净的演示数据不要现场输入一大堆测试数据。我当时的做法是先在数据库里准备好2个网格仓、5个供应商、20种商品、若干出入库单据演示时先展示首页看板带出统计数字然后走一遍完整入库流程创建入库单、审核、库存增加、流水生成再走一个出库流程创建出库单、锁定库存、确认出库、库存扣减最后展示一个库存预警页面说当商品库存低于预警阈值时系统会在首页进行提示一边说着一边把某一商品库存改低刷新页面。这样演示5分钟能讲完核心内容每个操作都有画面、有数据变化老师几乎挑不出大毛病。7. 我踩过的坑和给后人的忠告7.1 事务传播一个方法内互相调用到底哪个加了事务我在写盘点模块的时候遇到过一个问题盘点提交方法submitCheck(CheckOrder)内部会调用adjustStock()来调整库存我在adjustStock()上加了Transactional但调用submitCheck的外部方法其实也在一个事务里。MyBatis的Mapper方法每个都是独立的事务如果不注意事务边界会出现盘点状态更新成功但库存调整失败的情况。我后来统一了纪律事务注解只加在Service层的对外方法上内部方法不加。比如CheckService.submitCheck()负责把整个流程串起来用Transactional包裹内部的updateStock()只是普通方法不单独开启事务。这样事务边界只有一个入口逻辑上最清晰。7.2 外键约束我为什么把外键全部去掉刚建表的时候我也是规规矩矩加了FOREIGN KEY后来每写一个业务功能都被外键烦到——删除父表记录时子表有引用删不掉新增明细时外键顺序错了报错。经历几次删库重来之后我决定彻底去掉物理外键只保留逻辑外键也就是在应用层保证关联关系。这也是很多实际项目的做法外键约束会严重影响插入性能和复杂业务的灵活性。当然这个取舍答辩时可以说因为库存表和高频表的写入性能需要优化所以不建物理外键改由Service层业务校验。7.3 万字文档不要把源码代码往文档里拷贝最后说说文档。很多同学写文档的时候把Controller代码贴两百行真的毫无意义。老师的期望是看到需求分析、功能设计、数据库设计、核心逻辑说明、测试报告而不是代码粘贴复制。我写文档时是按照这个目录写的项目背景与研究意义需求分析功能性需求 非功能性需求系统总体设计架构图、功能模块图数据库设计ER图、表结构说明系统详细设计与实现按模块讲入库、出库、调拨、盘点系统测试功能测试用例表格总结与展望。每章配上几张截图首页看板、入库单列表、ECharts图表一份1万字的毕业设计文档就非常充实了。这里我可以给一个实用建议截图一定要真实不要P图。有些同学为了好看用PS把数字改掉结果一放大字体都对不齐老师一眼就能看出来。8. 最后想说的项目做完之后别急着删工程仓库管理系统虽然是最典型的CRUD项目但它把增删改查这件事做得非常完整和闭环。做完这个项目你对Spring Boot的依赖注入、MyBatis的SQL动态拼接、事务控制、拦截器、参数校验、统一异常处理、分页插件、ECharts接入这些常用技能会有一个整体的认知后面再学微服务、Redis缓存、消息队列很多思路都能复用上。我最后再分享一个小技巧在项目的README里把自己的运行步骤写清楚。我见过太多同学交付的工程包压缩包一解压就是一堆代码没有阅读理解项目都跑不起来。我习惯写一个README内容包括JDK版本、MySQL导入方式、配置文件修改点、运行命令、默认管理员账号密码。这看似和学习无关但对指导老师、对答辩委员、对下一个接手的人来说都很友好。而且你以后找工作把项目挂到GitHub上README写得好面试官也会多给一些好感分。如果这个项目你已经做完了我建议你趁热打铁把里面入库审核后再加库存改成用Redis做分布式锁控制库存扣减看看高并发场景下你怎么处理超卖的问题。把这一步做完你的Spring Boot水平又会上去一个台阶。