最近好几个读者私信我,都是在做“基于Java+SpringBoot+SSM零售与仓储管理系统”这个题目。坦白讲,这类题目在毕业设计和课程设计里出现的频率极高,因为它踩中了 Java 后端开发最核心的几个技术栈:SpringBoot、Spring、SpringMVC、MyBatis,且业务场景非常贴近实际——零售和仓储是几乎所有实体行业都绕不开的环节,能做的功能点足够丰富,也容易扩展。但它又不像纯电商系统那样依赖复杂的支付、秒杀逻辑,入手门槛适中,适合用来展示自己对 JavaWeb 全流程的掌握程度。
我花了两周时间完整把这个系统从零搭了一遍,包括商品管理、库存变动、采购入库、销售出库、盘点、报表统计、角色权限控制这些模块。本文会把我实际开发过程中的技术选型思路、数据库设计、关键代码实现、以及踩过的坑全部整理出来,尤其是那些代码写完后才发现的问题。如果你正在做或打算做这个项目,这篇应该能帮你省不少时间。
1. 为什么是SpringBoot+SSM:项目整体设计与思路拆解
1.1 技术选型的真实考虑
先聊最核心的一个问题:为什么这个题目偏偏是“SpringBoot+SSM”,而不是单独写 SpringBoot,或者单独写 SSM?
原因很简单。SSM 指的是 Spring + SpringMVC + MyBatis 三个框架的组合,这套组合在 2016 年到 2019 年之间是 Java Web 后端开发的主流标配。Spring 管对象、SpringMVC 管接口路由、MyBatis 管数据库操作,分工明确。但纯 SSM 需要写大量的 XML 配置文件,比如 spring.xml、springmvc.xml、mybatis-config.xml,还要手动配置数据源、事务管理器、扫描包路径,项目还没写一行业务代码,光是配置就能折腾你好几天。
SpringBoot 的出现就是来解决这个痛点的。它通过“约定大于配置”的方式,把 SSM 中大量的手动配置封装成了自动装配,你只需要引入一个 spring-boot-starter-web,再写一个启动类,一个能跑起来的 Web 项目就完成了。SpringBoot 不是替代 SSM,而是把 SSM 的整合过程自动化了。所以“SpringBoot+SSM”这个说法,本质上就是用 SpringBoot 作为项目骨架,底层依然运行着 SpringMVC 和 MyBatis。
我见过不少同学纠结:“到底用 SpringBoot 还是用纯 SSM?”我的建议是,毕业设计直接用 SpringBoot 就好。第一,开发效率高,不用把时间耗在 XML 配置上;第二,面试时 SpringBoot 是必问项,你用了它就有话聊;第三,SpringBoot 内置 Tomcat,部署时一个 jar 包直接跑,比打 war 包丢到外部 Tomcat 简单太多。但要注意,你仍然需要把 SSM 的原理搞清楚,尤其是 SpringMVC 的执行流程和 MyBatis 的代理机制,因为面试官一定会问底层。
1.2 功能模块划分与业务流程
零售与仓储管理系统,说到底要解决的核心问题只有一个:货从哪来、货在哪、货去哪了、还剩多少。所有功能都是围绕这四句话展开的。
先看“货从哪来”。这是采购模块的职责,采购员创建采购单,选择供应商、商品、数量,采购单审核通过后生成入库单,仓库管理员执行入库操作,库存增加,同时生成一条入库流水。这里有个细节很容易被忽略:采购单和入库单的关系要处理清楚。我建议做成“采购单审核后自动生成入库单”,而不是让两个模块各存各的数据,否则数据对不上,后期盘点会非常痛苦。
再看“货在哪”。这是仓储模块的职责,包括库位管理、库存查询、库存调拨、库存盘点。库存表是整个系统的核心,它的数据不是随便写的,而是由每一次入库、出库操作经过事务处理后累加或扣减得到的。所以我在设计时强制规定:不允许直接修改库存表的库存数量,只能通过入库单、出库单、盘点单这些凭证来驱动库存变化。这是这个系统最关键的约束。
再看“货去哪了”。这是销售模块的职责,零售门店创建销售单,选择商品、数量、付款方式,销售单提交后自动扣减库存,同时生成出库流水。如果是零售场景,还可以加一个会员模块,记录会员信息和消费积分。我这次把会员功能也加了,因为零售系统如果只有销售单,数据维度太单薄,答辩时不好延伸。
最后是“还剩多少”。这是报表模块的职责,包括库存预警、销售统计、采购统计。库存预警的思路是:在商品表或库存表里设置一个安全库存阈值,每次库存变动后检查一次,低于阈值的商品在预警列表里展示出来。销售统计则可以按日、按月维度做聚合查询。
我把系统整体拆成了七个功能模块:商品管理、采购管理、销售管理、库存管理、报表统计、供应商与客户管理、系统管理。其中系统管理负责用户、角色、权限,支撑整个系统的安全访问。这个模块划分是参考了真实商业系统里最常见的结构,既不会过于复杂到做不完,也不会简单到像课堂作业。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库设计:零售仓储系统的地基
2.1 表结构设计的核心思路
数据库设计是所有管理系统里最值得花时间的地方。表设计得好,后面写代码一路畅通;表设计得烂,业务逻辑越写越拧巴。
我这次一共设计了十二张表:用户表 sys_user、角色表 sys_role、菜单权限表 sys_menu、用户角色关联表、角色菜单关联表、商品分类表 category、商品信息表 product、供应商表 supplier、客户表 customer、采购单表 purchase_order、采购单明细表 purchase_order_item、入库单表 stock_in、入库单明细表 stock_in_item、销售单表 sale_order、销售单明细表 sale_order_item、库存表 stock、库存流水表 stock_log。数量看着多,其实分组很清晰。
商品表 product 是商品基本信息载体,字段包括商品编号、商品名称、分类ID、规格型号、单位、条码、安全库存阈值、状态等。商品编号我建议用业务编号而非自增ID,比如“SP202506001”这种格式,这样在打印单据、扫码枪录入时更友好。实现方式是在插入前通过查询当天已有记录数生成一个唯一编号。
库存表 stock 是整个系统的核心表,字段包括主键ID、商品ID、当前库存数量、锁定库存数量、预警阈值、更新时间。我特别加了“锁定库存”字段,这是为了处理“订单提交但未付款”的场景——防止用户提交订单后商品被别人买走。不过如果你不做电商秒杀这类高并发业务,锁定库存也不是必须的,保留一个当前库存、一个预警阈值就够用。
表与表之间的关联关系要注意:单据主表和明细表是典型的一对多关系。比如销售单 sale_order 记录一次销售的整体信息,包括单号、客户ID、销售日期、总金额、状态、操作人等;sale_order_item 记录这张单子里每个商品的单价、数量、小计金额。为什么非要拆两张表?因为一张销售单可能包含多个商品,如果全部平铺在一张表里,单头信息和明细数据会重复存储,数据冗余严重,而且会导致后续扩展时极其被动——比如想给销售单加一个“配送员”字段,如果只有一张大宽表,你得改一堆重复数据。
采购模块同理,purchase_order 和 purchase_order_item。再加上入库单和出库单,你可能会问:入库单能不能直接用采购单代替?我的回答是可以,但不要偷这个懒。采购单是业务单据,描述的是“我们和供应商之间的交易”;入库单是作业凭证,记录的是“仓库实际收到了什么”。两者关注点不同,如果业务复杂,一张采购单可以分多次部分入库,这时候单表根本没法表达。虽然毕设里不一定做到分批入库,但把这两张表分开设计,答辩时你可以把这个“部分入库”的场景讲出来,很有说服力。
2.2 库存扣减与并发控制
库存扣减是这个系统里最重要的业务点,也是最容易写出 Bug 的地方。
先说最简单的错误写法。有些同学直接在 Service 层里这样写:查库存 -> 判断库存是否充足 -> 执行 update 扣减。单用户操作时没问题,但如果有两个销售请求同时进来,比如库存只剩一件商品,两个请求都查到了库存为1,都判断“充足”,然后先后执行扣减,最终库存就变成了-1。这就是典型的超卖问题。
解决办法有几种,我这次用了“乐观锁”方案。在库存表里加一个 version 字段,每次更新库存时带上 where version = 旧版本号,并且 set version = version + 1。如果更新影响的行数为0,说明数据已经被其他事务改过了,本次操作需要重试或直接失败返回。代码逻辑大概是这样的:
java复制// 在事务内执行
int result = stockMapper.deductStock(productId, quantity, version);
if (result == 0) {
throw new BusinessException("啊哦,手慢了,库存变化了,请重新下单");
}
对应 XML 里的 update 语句:
sql复制UPDATE stock
SET quantity = quantity - #{quantity}, version = version + 1
WHERE product_id = #{productId}
AND version = #{version}
AND quantity >= #{quantity}
注意最后这个 quantity >= #{quantity} 条件,它同时在数据库层面挡住了库存不足的情况,实现真正安全的扣减。这个方法被我反复测试过,多线程并发扣减库存时不会出现负数。
另外,所有涉及库存变动的操作都必须放在事务里。SpringBoot 中加一个 @Transactional 注解是最简单的,但你要知道它的两个隐藏坑:第一,事务默认只在 RuntimeException 中回滚,如果你在业务里 try-catch 把异常吞掉了,事务是不会回滚的;第二,@Transactional 只对 public 方法生效,如果同一个类内部调用另一个被 @Transactional 修饰的方法,事务会失效,因为 Spring 的 AOP 代理默认只拦截外部调用。我在开发时遇到了第二种情况,排查了快一个小时才反应过来。
3. 核心功能实现:从搭建到跑通的完整过程
3.1 快速搭建SpringBoot+SSM项目骨架
搭建步骤其实不复杂,我整理成五步。
第一步,创建项目。推荐直接用 Spring Initializr(start.spring.io)生成 SpringBoot 项目,Java 版本选择 1.8,SpringBoot 版本选择 2.7.x 系列。为什么不用最新的 3.x?因为 SpringBoot 3.x 强制要求 JDK 17,很多公司的老项目还在 JDK 8 环境,而且 3.x 里 javax 包名改成了 jakarta,网上的很多资料还是基于旧版写的,你照着抄很容易踩坑。毕设求稳,用 2.7.x 是最省心的。
第二步,引入依赖。核心依赖有四个:
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.3.1</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.20</version>
</dependency>
MyBatis 这里我特意使用了 mybatis-spring-boot-starter 而不是 mybatis-plus,因为题目要求的是 SSM 框架,用 MyBatis 原生写法更贴合题意。但如果你确实想提高开发效率,MyBatis-Plus 也完全可以用,只要在文档里说明它是 MyBatis 的增强插件即可。
第三步,配置文件。在 application.yml 里配置数据源、MyBatis、日志:
yaml复制server:
port: 8080
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/retail_storage?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: root
type: com.alibaba.druid.pool.DruidDataSource
mybatis:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.example.retail.entity
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
map-underscore-to-camel-case 这个配置强烈建议打开,它能把数据库里的 product_name 字段自动映射到 Java 实体类的 productName 属性,省掉一大堆手写 resultMap 的麻烦。
第四步,创建包结构。我习惯按“controller / service / mapper / entity / common”五层分包。controller 只做参数接收和结果返回,不写业务逻辑;service 层写业务逻辑,需要给方法加 @Transactional 注解;mapper 是 MyBatis 的接口层,只定义方法签名,SQL 写在 XML 里。common 包放统一返回结果类、全局异常处理器、工具类。
第五步,写一个统一返回结果类。这是很多人不爱做但是很有必要的步骤。我定义了一个 Result 类,包含 code、message、data 三个字段。所有接口都返回这个对象,前端拿到之后先判断 code 是否为200,再处理数据。这样做的核心好处是异常处理和正常返回的格式完全统一,前端不用为每一种接口错误写不同的处理逻辑。
3.2 前端方案与接口风格
题目里没有强制指定前端,这是给你留的发挥空间。
我这次选择了 Vue2 + Element UI 的前端方案,通过 axios 调用后端接口,前后端完全分离。为什么用 Vue2 而不用 Vue3?因为 Vue2 + Element UI 的生态最成熟,网上资料多,遇到问题搜起来快,对毕设选手最友好。前端项目默认在 8081 端口运行,通过 vite 或 webpack 配置代理,把 /api 开头的请求转发到后端的 8080 端口,解决跨域问题。
接口风格我用的是 RESTful 风格。商品管理模块的接口路径大概是这样的:
GET /api/product/list:分页查询商品列表GET /api/product/{id}:查询商品详情POST /api/product:新增商品PUT /api/product:更新商品信息DELETE /api/product/{id}:删除商品
路径里都带 /api 前缀,好处是统一拦截权限时方便,nginx 路由转发时也灵活。
前后端分离的核心优势是把前端开发和后端开发的职责彻底解耦。你可以在完全不依赖前端的情况下,用 Postman 或 Apifox 测完所有后端接口,然后让前端同学(或你自己)直接用现成的接口文档对接。我在开发时是后端先完成,再逐页面前端联调的,整体节奏很顺畅。
3.3 权限控制与登录认证
这个系统的权限控制我用的是轻量方案:JWT(JSON Web Token)+ 拦截器。没用 Spring Security 或 Shiro,因为对毕设来说那两个框架的学习曲线比较陡峭,而且配置复杂,出了问题不好排查。JWT 的好处是服务端不需要存储会话信息,登录成功后服务端签发一个 token 返回给前端,前端在后续请求的请求头里带上 Authorization: Bearer token,后端拦截器验证 token 的合法性,从中解析出用户 ID 和角色,再判断是否有权限访问当前接口。
登录流程很简单:用户输入用户名密码,后端校验通过后,用 userId、username、roleId 生成 token,token 里我设置了过期时间为24小时。拦截器里通过 HandlerInterceptor 实现,在 preHandle 方法中先放行登录接口,其余接口全部校验 token,校验失败的返回 401 状态码。
菜单权限的粒度我是控制到按钮级别的。虽然这有一点点过度设计,但答辩时很加分。实现方式是在数据库里维护菜单表,每个菜单项有一个权限标识字符串,比如 system:user:add,用户登录后根据角色查询到拥有的权限标识集合,后端在接口上用自定义注解校验,前端则根据集合控制按钮是否显示。
3.4 几个核心业务代码的实现逻辑
先说“查询商品列表”。这是几乎每个页面都会用到的功能,我实现成了分页+多条件模糊查询。参数包括:页码 pageNum、每页条数 pageSize、商品名称 keyword、分类ID categoryId。用 PageHelper 插件实现分页,这是 MyBatis 生态里最常用的分页插件,用法很简单:
java复制PageHelper.startPage(pageNum, pageSize);
List<ProductVO> list = productMapper.selectProductPage(keyword, categoryId);
PageInfo<ProductVO> pageInfo = new PageInfo<>(list);
PageHelper.startPage 之后紧跟第一条 SQL 查询就会自动拼接 LIMIT 语句,返回的 PageInfo 里包含了总条数、总页数、当前页数据等完整分页信息。
再介绍“销售出库”的完整流程。这是我写的最长的一个 Service 方法,步骤如下:创建销售单主记录,状态为“已提交”;遍历销售单明细,逐个商品检查库存是否充足;调用乐观锁 SQL 扣减库存;记录库存流水;更新销售单状态为“已完成”;如果商品库存低于预警阈值,插入一条预警提醒记录。整个方法加 @Transactional(rollbackFor = Exception.class),任何一个环节抛异常,前面所有的扣减操作全部回滚,保证数据一致性。
“库存盘点”功能我也做了,业务流程是:创建盘点单,选择盘点库位,系统自动把该库位的账面库存和商品列表加载出来,盘点员填写实际盘点数量,然后系统自动计算盈亏数量和盈亏金额。确认之后提交,系统执行盘盈或盘亏操作:盘多了就生成一条入库流水,盘少了就生成一条出库流水,同时修改库存表数量。这个过程我通过一个事务方法实现,避免出现“库存改了但流水没生成”的数据不一致问题。
4. 开发中真实踩过的坑与排查技巧
4.1 SpringBoot版本过高引发的连锁问题
很多同学起步时有个习惯,全家桶依赖版本都选最新的。我一开始也是这样,但很快就被教育了。
我最初图新鲜选了 SpringBoot 3.2 版本,结果问题接二连三。首先 javax.servlet 下的类全部变成 jakarta.servlet,网上搜到的很多旧教程代码直接编译报错;其次,SpringBoot 3 要求 JDK 17 起步,而我本机 JDK 是 8,为此还装了一套 JDK17;最后,很多第三方 starter 组件对 SpringBoot 3 的兼容还不完善,整合时总是莫名其妙报错。折腾一晚上后我果断换回 SpringBoot 2.7.18,世界清净了。毕设最重要的是稳定跑通,不是追求最新版本。
这里也顺带回应一下热搜里那个“springboot版本太高”的梗:版本选择的核心原则是“你熟悉的、资料最多的、生态最稳的”。SpringBoot 2.7.x 目前是全网资料最丰富的版本,大量公司生产环境也还在用,拿它做毕设没有任何问题。
4.2 JDK版本与Lombok引发的编译错误
我项目里用了 Lombok 来简化实体类代码,通过 @Data 注解自动生成 getter/setter。这个库本身很好用,但如果你换了 JDK 版本,或者 IDE 里的 Lombok 插件和项目用的版本不一致,就会遇到热搜里提到的那个报错:“You aren't using a compiler supported by lombok, so lombok will not work”。
这个问题的本质是 Lombok 通过注解处理器在编译阶段修改语法树,不同 JDK 版本的内部结构和 Lombok 版本不完全兼容,高版本 JDK(尤其是 17、21)需要更高版本的 Lombok 才能支持。排查步骤很简单:检查 pom.xml 里 Lombok 的版本,如果用的是 1.18.20 且 JDK 是 17,升级到 1.18.30 基本就能解决。另外,如果你的项目是从 JDK8 环境用 IDE 打开后切换到了 JDK17,记住在 IDE 里同时更新项目的 SDK 设置和编译级别,否则也会出现怪异的编译问题。
4.3 SpringBoot循环依赖问题的经典场景
循环依赖指的是两个 Bean 互相注入,比如 A 依赖 B,B 又依赖 A。SpringBoot 2.6 版本之后默认禁止了循环依赖,启动时直接报错。我之前写的 Service 层里就有这种耦合:库存服务 StockService 调用了出库服务 OutStockService,而出库服务又反向调用了 StockService 的方法,导致项目启动时抛了 The dependencies of some of the beans in the application context form a cycle 异常。
解决办法有两个方向。第一个是单纯解除 A 对 B 的依赖,比如把两个 Service 公用的方法抽取到一个新的服务类里,双方都只依赖这个公共类。第二个是在某个字段上加 @Lazy 注解,延迟其中一个 Bean 的注入,不过这只是暂缓方案,治标不治本。我实际采用的是重构拆分方案,因为循环依赖本身就是代码设计有坏味道的信号,而不是一个需要“解决”的报错。
4.4 内存溢出:OutOfMemoryError排查实录
开发阶段最让人头疼的报错之一就是“java: OutOfMemoryError: insufficient memory”,尤其是在用 Maven 编译或者运行测试的时候。我遇到过一次编译期报这个错,原因是本机内存本来就不大,IDE、MySQL、Redis、前端 dev server 全部同时开着,内存被占满了,Maven 编译器拿不到足够的堆内存来执行编译。
排查思路分三层。第一层,确认是不是物理内存真的不足,看任务管理器里内存占用率,如果长期 90% 以上,先关掉几个不用的应用。第二层,如果是 IDE 自身内存不足,调整 IDEA 的 VM 参数,在 Help -> Change Memory Settings 里把 Heap 调大。第三层,如果是 Maven 编译插件的内存不足,在 pom.xml 的 maven-compiler-plugin 里配置 fork 和 memoryInitialSize 参数:
xml复制<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<fork>true</fork>
<memoryInitialSize>512m</memoryInitialSize>
<memoryMaximumSize>1024m</memoryMaximumSize>
</configuration>
</plugin>
运行期内存溢出则是另一回事。比如报表模块里查询销售统计时,我没做分页直接把全表数据查出来做内存聚合,数据量一大就撑爆了。正确做法是尽量把聚合逻辑下推到 SQL 里,用 GROUP BY 和 SUM 函数计算,而不是查出全部记录来用 Java 循环。
4.5 打包部署层面的小技巧
项目完成后,部署也是一道坎。后端我采用的是 Maven 打成可执行 jar 包的方式,在 IDEA 右侧 Maven 面板先执行 clean 再执行 package,然后到 target 目录下找到 jar 包,命令行执行 java -jar retail-system.jar 运行。这里必须要提醒的是:如果项目用的是 JDK8 编译,运行机器也必须安装 JDK8,否则会报 UnsupportedClassVersionError。
热搜词里提到“SpringBoot项目打包到 Docker Desktop”,这确实是个好扩展方向。我写了一篇简单的 Dockerfile:
dockerfile复制FROM openjdk:8-jre-alpine
COPY retail-system.jar /app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app.jar"]
然后执行 docker build -t retail-system . 和 docker run -d -p 8080:8080 retail-system,就能在 Docker 容器里跑起来。容器化部署对毕设来说是很好的加分项,但前提是你本机 Docker 能正常启动。如果 Docker Desktop 启动后一直卡在 Docker Engine running 的界面,多半是虚拟化没开或者 WSL2 配置问题,建议先去 BIOS 里确认 CPU 虚拟化是开启状态。
4.6 常见问题速查表
我把开发过程中比较典型的几个问题整理成了一张速查表,方便你遇到类似情况时快速定位:
| 问题现象 | 根本原因 | 快速解决方法 |
|---|---|---|
| 项目启动报循环依赖异常 | SpringBoot 2.6+禁止循环引用 | 重构Service层,消除双向依赖 |
| MyBatis 接口找不到SQL报错 | mapper XML 位置配置错误 | 检查 application.yml 中 mapper-locations 路径 |
| 数据库时间字段差8小时 | 时区问题 | JDBC URL 增加 serverTimezone=Asia/Shanghai |
| 前端请求接口跨域失败 | 前后端端口不同 | 配置跨域过滤器或前端代理 |
| 商品库存被扣成负数 | 并发控制缺失 | 使用乐观锁+事务 |
| SpringBoot 2.7打包后启动端口冲突 | 8080被占用 | 使用 lsof -i:8080 查看并结束占用进程 |
| Lombok 编译报错 | 版本与JDK不兼容 | 升级 Lombok 到 1.18.30+ |
5. 写在最后的个人经验
这个项目从构思到完整跑通,我前后用了大约两周,其中一半时间花在数据库设计和业务逻辑梳理上,真正写代码的时间并不多。这也是我想对正在做毕设的读者强调的一点:不要在 SQL 写不出来的时候才去想表怎么建,一定要先花时间把表结构吃透,反复推演一遍业务场景。库存怎么入库、怎么出库、怎么盘点,把这些问题在纸上画一遍,再动手建库,这时候你会发现写代码就是纯粹的体力活。
如果你想在这个项目上做扩展,我有几个具体的方向可以参考:一是接入 Redis 做热点商品的库存缓存,提升并发吞吐;二是引入消息队列处理销售出库后的异步通知,比如短信通知客户;三是增加多仓库支持,在库存调拨时跨仓库移动商品;四是用 ECharts 生成更丰富的可视化报表页面。这些方向都不算难,但每一项都能让项目在答辩时拉满印象分。
最后再分享一个小经验:全程用 Git 做代码管理,每完成一个模块就 commit 一次。我这次是每个功能模块一个 commit,信息写清楚改了什么。到后期调整代码时,你可以随时回滚到之前某个稳定版本。有一次我重构库存扣减逻辑把代码改崩了,当时内心是崩溃的,但靠 git reset 轻松回到上一个正常版本,瞬间治愈。这种习惯在项目里养成了,对以后正规工作也是加分项。
