简介:基于SSM的实验室耗材管理系统源码是一份面向计算机专业毕业设计选题的完整项目,以Java为后端、JSP为前端,搭配MySQL数据库,实现学生、教师、管理员三类角色的权限管理,涵盖登录认证、个人资料修改、实验耗材信息查询、申请审核与删除等核心功能,适合用于高校实验室耗材信息化管理场景。资源包共2个文件,包含项目源码压缩包与完整的SQL数据库文件,总大小11.65MB,导入后即可运行,免去自行建库与配置环境的繁琐过程。目前已有68人学习下载,说明其在毕设圈子中有一定参考热度。通过这套源码,读者可以快速掌握SSM框架下的分层开发思路、JSP页面的交互设计以及权限控制的具体实现,同时可直接在此基础上二次扩展,满足课程设计或毕业论文的演示需求,整体实用性强。
1. 实验室耗材管理系统:为什么这类 SSM 源码总被拿来当毕设和实训起点
实验室的耗材盘点一旦靠人肉记 Excel,月底对不上账几乎是必然的——有人领了没登记,有人借走忘了还,采购回来的试剂放哪个柜子也没人说得清。所谓「基于 SSM 的实验室耗材管理系统源码」,本质就是解决这一摊子事:用 Spring 管业务对象、SpringMVC 接页面请求、MyBatis 操作 MySQL,把耗材的入库、领用、退回、库存预警和台账查询做成一套能多人同时用的 Web 系统。SSM 不是新东西,但它在教学和实训场景里依然是最稳的起点:依赖少、层次清楚、每一条数据流都能从 JSP 页面追到 SQL 语句。这篇就是按「这套系统能不能用、怎么搭、坑在哪、值不值得投入」来写,读者不管是拿它做课程设计还是打算改造成真实的小型管理系统,照着下面的路径都能落地。
2. SSM 技术选型:这套源码的骨架凭什么能扛住耗材管理这种小系统
2.1 从 Spring、SpringMVC 到 MyBatis:三层各自管什么
SSM 是 Spring + SpringMVC + MyBatis 的缩写,三个框架在耗材管理系统里分工非常明确。Spring 是容器,负责创建 Service、Mapper 这些对象,并把事务控制、数据源这些横切逻辑挂到业务方法上。SpringMVC 是 Web 层框架,专门处理「浏览器发来的请求该由哪个 Controller 方法接住、返回什么视图」。MyBatis 则是持久层框架,把 Java 方法和 SQL 语句做一个映射,让开发者不用写一堆 JDBC 样板代码。
为什么这类系统用 SSM 而不是 Spring Boot?一个很现实的原因是教学和源码阅读场景的需要。Spring Boot 的自动配置会把大量细节藏起来,Controller 里的注解看着很清爽,但新手很难理解「请求是怎么走到 Mapper 的」。SSM 恰恰相反,Spring 容器配置文件里每一个<bean>、每一个<property>都是显式的,团队成员拿到源码后,顺着配置就能看懂整个调用链。对耗材管理这种业务复杂度不高、但数据关系绕的系统来说,这种「所有连接点都摆在明面上」的风格反而更好维护。
从数据流的角度看,一次「实验室人员领用两盒枪头」的操作会依次经过:JSP 页面提交表单 → SpringMVC 的 HandlerMapping 找到对应的 Controller 方法 → Controller 调用 Service 接口 → Service 里通过 Mapper 接口执行两条 SQL,一条插入领用记录,一条扣减库存 → 执行结果再一层层返回给页面。如果这一串链路里没有 Spring 的事务管理,第二条 SQL 失败第一条也会留下脏数据,所以选型的关键不在框架本身,而在于事务开关和连接池是否配得干净。
2.2 用 Maven 组织源码的最小工程结构:每个目录放什么
拿到一份 SSM 耗材管理系统的源码,第一件事不是急着改代码,而是先确认 Maven 结构是否完整。常见的做法是把工程拆成标准的父子结构,放在src/main/java下的包名按业务分层:controller、service、mapper(或者叫dao)、entity、common,配置文件集中在src/main/resources下,JSP 页面放在src/main/webapp/WEB-INF/views。为什么要强调 WEB-INF 目录?因为放在这里的 JSP 不能被浏览器直接访问,必须经过 Controller 转发,这既是安全习惯,也是很多初学者容易忽略的规范。
下面这份依赖清单是跑通这套系统的最小集合,版本号的组合是我实际验证过比较稳的搭配:
<properties> <spring.version>5.2.9.RELEASE</spring.version> <mybatis.version>3.5.6</mybatis.version> <mysql.version>8.0.28</mysql.version> </properties> <dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>${mybatis.version}</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.0.6</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>${mysql.version}</version> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> </dependencies>把依赖配置放properties里是值得养成的习惯,后续升级某一个框架版本不用满文件找。mybatis-spring这个桥接包非常关键,没有它 MyBatis 的 SqlSessionFactory 无法被 Spring 容器管理。javax.servlet-api标记为provided是因为 Tomcat 自带 Servlet 容器实现,打包时不需要带进去,否则和 Tomcat 自带的类冲突。
这一份依赖组合的兼容边界要记清楚:Spring 5.2.x 需要 JDK 8 或以上,MySQL 驱动 8.x 跟 MySQL 5.7 服务器也能连,但如果你的数据库是 MySQL 5.6 或更早,建议把驱动降到 5.1.x,否则会报认证协议不兼容。
2.3 一份能跑起来的 Spring 配置:数据源、事务、扫描的取舍
SSM 源码里最常见的配置文件是三个:spring-context.xml(容器核心)、spring-mvc.xml(Web 层)、mybatis-config.xml(MyBatis 设置项)。有些项目会合并成两个,但核心逻辑是一样的。我一般习惯把容器配置和 MVC 配置分开,这样在排查「事务不生效」或「请求 404」时,能快速定位是哪个配置文件出了问题。
先看容器配置文件里 5 个必配的 bean:
<context:property-placeholder location="classpath:jdbc.properties"/> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"> <property name="driverClassName" value="${jdbc.driver}"/> <property name="url" value="${jdbc.url}"/> <property name="username" value="${jdbc.username}"/> <property name="password" value="${jdbc.password}"/> <property name="initialSize" value="5"/> <property name="maxActive" value="20"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> <property name="configLocation" value="classpath:mybatis-config.xml"/> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.example.lab.mapper"/> </bean> <tx:annotation-driven transaction-manager="transactionManager"/> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean>mapperLocations指向 XML 映射文件所在目录,MapperScannerConfigurer负责把接口扫描成 Spring 里的 bean,这两者缺一效果就是「启动不报错,一调用就报空指针」。tx:annotation-driven的位置也有讲究,它必须能被 Spring 扫描到,通常放在这个文件里即可,不需要额外配置<context:component-scan>指向它的包。
Druid 连接池的initialSize和maxActive是根据实验室场景调的:同时在线人数一般不超过 30,所以连接池没必要开太大,maxActive=20足够;initialSize=5保证系统启动后就有可用连接,避免第一个请求进来才初始化连接导致页面卡顿。
3. 数据库设计先行:耗材台账、出入库与领用记录的三条数据主线
3.1 核心表拆分:耗材档案、库存快照与流水账为什么要分开
耗材管理系统在数据库设计上和普通商品管理系统有一个非常大的区别:耗材的规格和计量单位极其混乱。同样是「手套」,有的按「盒」入库,领用时却按「只」发放;一些化学试剂还有批号和有效期,需要先过期先出库。如果把所有字段塞进一张表,后续统计会非常痛苦。所以这个系统的表必须拆成三层:基础档案、库存快照、业务流水。
基础档案层是tb_material,保存耗材的名称、规格、单位、类别、预警下限这些「不会经常变化」的信息。库存快照层是tb_stock,每个耗材一条记录,保存当前可用量、占用量和所在库位。业务流水层是tb_stock_flow,每一次入库、领用、退回、报废都追加一条流水的记录,包含变更量、变更类型、操作人和时间。
为什么不能只保留一张库存表?因为一旦出现「账面数量是 0 但柜子里还有三盒」的情况,没有流水你根本查不出是入库漏登记还是领用多扣了。流水账的存在价值是给库存表提供对账依据,任何一次库存数据的修改都必须能在流水里找到对应的记录。这也是这套系统在功能上区别于普通 CRUD Demo 的关键设计。
3.2 建表 SQL 与字段说明:批次、有效期、预警下限怎么落表
下面这张建表语句是这个系统的核心,我按实际跑通的表结构简化了部分字段:
CREATE TABLE `tb_material` ( `id` int(11) NOT NULL AUTO_INCREMENT, `material_code` varchar(32) NOT NULL COMMENT '耗材编号', `name` varchar(64) NOT NULL COMMENT '耗材名称', `specification` varchar(64) DEFAULT NULL COMMENT '规格型号', `unit` varchar(10) NOT NULL COMMENT '最小领用单位', `category_id` int(11) DEFAULT NULL COMMENT '类别ID', `min_stock` decimal(10,2) DEFAULT 0.00 COMMENT '预警下限', `status` tinyint(4) DEFAULT 1 COMMENT '1启用 0停用', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_material_code` (`material_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `tb_stock` ( `id` int(11) NOT NULL AUTO_INCREMENT, `material_id` int(11) NOT NULL, `batch_no` varchar(32) DEFAULT NULL COMMENT '批次号', `expire_date` date DEFAULT NULL COMMENT '有效期', `quantity` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '当前可用量', `location` varchar(32) DEFAULT NULL COMMENT '库位', `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_material_id` (`material_id`), KEY `idx_expire_date` (`expire_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `tb_stock_flow` ( `id` int(11) NOT NULL AUTO_INCREMENT, `material_id` int(11) NOT NULL, `batch_no` varchar(32) DEFAULT NULL, `change_type` tinyint(4) NOT NULL COMMENT '1入库 2领用 3退回 4报废', `change_qty` decimal(10,2) NOT NULL COMMENT '变动数量,正数增加负数减少', `before_qty` decimal(10,2) NOT NULL COMMENT '变动前可用量', `after_qty` decimal(10,2) NOT NULL COMMENT '变动后可用量', `operator_id` int(11) NOT NULL COMMENT '操作人ID', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_material_id` (`material_id`), KEY `idx_create_time` (`create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;tb_material和tb_stock是一对多的关系,同一个耗材编号可以对应多个批次,不同批次的有效期不同,这就解决了「同一种耗材两批货过期时间不一样」的问题。tb_stock_flow里的before_qty和after_qty是我额外建议的字段,很多基础版源码没有这两个字段,但加上后能直接在页面上展示「本次变动前后分别是多少」,对账时一眼就能看出哪条记录异常。
change_qty用正负号区分增减是一个易用性设计。如果改成两个字段in_qty和out_qty,统计月度总入库时还得写CASE WHEN判断,正负号让所有流水可以统一SUM(change_qty),SQL 简洁得多。quantity字段类型用decimal(10,2)而不是float,是为了避免浮点精度问题——耗材库存一旦出现0.0000004这种尾巴,月底盘点的对不上号就成了玄学。
3.3 业务流程与状态设计:入库、领用、退回的状态机
耗材的库存状态流转没有订单系统那么复杂,但有一个边界必须想清楚:领用操作发生之后,如果实验人员发现领错了要退回,系统是要「直接加回库存」还是「走一条反向流水」?我的做法是反向流水。退回本身也是一条独立流水,change_type记为 3,change_qty为正数。这样做的原因是审计需要,实验室管理规范通常会要求「谁退回、什么时候退回、退了多少」有迹可循,直接改库存会破坏流水账的完整性。
入库流程则要和采购订单关联。最简单的设计是:创建入库单 → 选择耗材和供应商 → 填写数量和批次号 → 提交后同时写入tb_stock(若无该批次则新建记录)和tb_stock_flow。这里要注意,同一次入库如果包含多个批次,不能写一条流水带过,每个批次都要有一条流水记录,否则后续按批次出库时库存数据会错乱。
业务状态上,耗材本身只有启用/停用两种状态,但建议给「入库单」加一个状态字段(草稿、已入库、已作废),源码里如果是两张表直接「写入就算入库」的简版,后续接采购审批流程时需要改动的地方会比较大。如果你打算在这个源码上做二次开发,优先补的就是这个状态机。
4. 核心功能落地:从登录鉴权到库存流水,哪几段代码最值得抄
4.1 登录拦截与权限校验:拦截器配置的两种写法
实验室耗材管理系统最基础却最容易被忽视的功能是登录拦截。很多源码 Demo 里的页面能直接访问,管理员的按钮和普通实验人员完全一样,这在真实场景里是接受不了的。我用 SpringMVC 拦截器做了一层登录校验,配置如下:
<mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/**"/> <mvc:exclude-mapping path="/login"/> <mvc:exclude-mapping path="/css/**"/> <mvc:exclude-mapping path="/js/**"/> <mvc:exclude-mapping path="/images/**"/> <mvc:interceptor> <bean class="com.example.lab.interceptor.LoginInterceptor"/> </mvc:interceptor> </mvc:interceptor> </mvc:interceptors>这个配置的核心在exclude-mapping:登录页、静态资源必须放行,其余接口都要经过拦截器。如果漏了静态资源放行,页面加载 CSS 和 JS 时会全部被拦下,表现成「页面光秃秃的,功能点了没反应」。
拦截器对应的 Java 类里,判断逻辑很直白:
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object user = request.getSession().getAttribute("loginUser"); if (user == null) { response.sendRedirect(request.getContextPath() + "/login"); return false; } return true; } }这段代码的边界条件在于:判断的是 Session 里有没有loginUser这个属性,而不是用户密码对不对。密码校验发生在登录 Controller 里,一旦通过就session.setAttribute("loginUser", user)。这里的sendRedirect要带上request.getContextPath(),否则部署到带项目名的路径下会跳转到错误地址。权限细分则是在 Service 层判断角色的,比如管理员才能执行入库操作,普通人员只能领用,这种逻辑不适合写在拦截器里,因为拦截器只认 URL 不认业务对象。
4.2 库存查询与预警 SQL:让列表页一次查出「可用量」和「预警状态」
耗材列表页的核心查询是:每个耗材当前有多少库存、是不是低于预警线。如果每个耗材都单独查一次库存,列表页 50 个耗材就得发 50 次 SQL。我一般用一条 JOIN 查询解决:
<select id="selectStockList" resultType="map"> SELECT m.id, m.material_code, m.name, m.specification, m.unit, m.min_stock, IFNULL(SUM(s.quantity), 0) AS total_quantity, CASE WHEN IFNULL(SUM(s.quantity), 0) <= m.min_stock THEN 'low' ELSE 'normal' END AS stock_status FROM tb_material m LEFT JOIN tb_stock s ON s.material_id = m.id WHERE m.status = 1 GROUP BY m.id, m.material_code, m.name, m.specification, m.unit, m.min_stock ORDER BY stock_status DESC, m.material_code </select>这段 SQL 有两个值得说明的细节。一是LEFT JOIN而不是INNER JOIN,因为有些耗材档案建好了但还没入库,库存为 0 也要显示在列表里,INNER JOIN会把这些耗材查丢。二是GROUP BY后面的字段必须把查询里所有非聚合字段都列全,MySQL 5.7 及以后版本对ONLY_FULL_GROUP_BY模式的检查非常严格,漏一个就报错。
stock_status的CASE WHEN写法让页面可以直接根据这个值决定要不要把某一行标红。<=在 MyBatis XML 里要写成<=是新手最容易忽略的转义问题,直接写<会被 XML 解析器当成标签开头报错,这一点几乎每个用 SSM 的人都会踩一次。
4.3 领用出库的事务写法:扣减库存与写流水必须同生共死
领用耗材是这套系统里事务边界最清晰的操作——插入一条领用流水、扣减库存,是一个完整事务。如果先扣库存后写流水,流水写失败会导致账实不符;如果先写流水后扣库存,扣减失败则会出现「流水显示领走了但库存没少」。两个操作必须包在同一个事务方法里:
@Service public class StockServiceImpl implements StockService { @Autowired private StockFlowMapper stockFlowMapper; @Autowired private StockMapper stockMapper; @Override @Transactional(rollbackFor = Exception.class) public void consumeMaterial(ConsumeRequest req) { // 1. 查询当前库存(带行锁,防止并发领用) Stock stock = stockMapper.selectByMaterialIdForUpdate(req.getMaterialId()); if (stock == null || stock.getQuantity().compareTo(req.getQuantity()) < 0) { throw new RuntimeException("库存不足"); } // 2. 写流水:变动前数量是当前库存,变动后是本数量减领用量 StockFlow flow = new StockFlow(); flow.setMaterialId(req.getMaterialId()); flow.setChangeType(2); flow.setChangeQty(req.getQuantity().negate()); flow.setBeforeQty(stock.getQuantity()); flow.setAfterQty(stock.getQuantity().subtract(req.getQuantity())); flow.setOperatorId(req.getOperatorId()); stockFlowMapper.insert(flow); // 3. 扣减库存 stockMapper.decreaseQuantity(req.getMaterialId(), req.getQuantity()); } }selectByMaterialIdForUpdate是行级锁的关键,对应的 SQL 是SELECT * FROM tb_stock WHERE material_id = #{materialId} FOR UPDATE。不带FOR UPDATE时,两个实验人员同时领用同一种耗材,可能都查到库存充足,然后都扣减成功,导致库存变成负数。加上行锁后,第二个请求会等第一个事务提交再执行查询,从而读到最新的库存值。
@Transactional(rollbackFor = Exception.class)这里特别注意:Spring 默认只对RuntimeException回滚,如果你在方法里try...catch包住异常,或者抛出一个自定义的CheckedException,事务可能不会回滚。所以要么统一用RuntimeException,要么显式声明rollbackFor。库存比较用compareTo是因为quantity是BigDecimal,直接用<=会比较数值时触发类型转换问题。
5. SSM 运行中的几个高频坑:静态资源 404、事务不回滚与字段映射
5.1 静态资源 404:JS、CSS 全被 DispatcherServlet 劫持了
现象:项目能启动,登录页也能打开,但页面没有任何样式,F12 控制台里全是 css、js 文件的 404。
原因:SpringMVC 的前端控制器 DispatcherServlet 把/路径下的所有请求都拦了,包括静态资源。如果配置里没有放行静态资源,Tomcat 默认的 DefaultServlet 就不会处理这些文件。
解决:在 spring-mvc.xml 里加一行资源映射,告诉 SpringMVC 哪些目录下的文件直接交给容器处理:
<mvc:resources mapping="/static/**" location="/static/"/>注意mapping和location的路径要对应,如果你的页面里引用的是css/style.css,而文件放在webapp/css/下,那映射就是<mvc:resources mapping="/css/**" location="/css/"/>。如果项目是前后端分离,后端只出接口,这条配置就不需要了。
5.2 事务不回滚:catch 吞掉异常后,库存扣了流水却丢了
现象:领用接口偶尔出现「库存少了,但领用记录查不到」的情况,或者反过来流水有记录但库存没扣减。
原因:Service 方法里用了try { ... } catch (Exception e) { e.printStackTrace(); }把异常吞掉了。Spring 事务是通过 AOP 代理实现的,代理只能感知到从方法里抛出的异常,异常被 catch 住,事务通知就认为方法正常结束,直接提交。
解决:去掉吞异常的写法,让异常抛出到事务代理层。如果确实要在方法内记录日志,记录完后要throw new RuntimeException(e)重新抛出。我之前给一个系统排查这个问题时,发现代码里甚至还有catch (Exception e) { return "error"; }的写法,所有错误都被当成正常返回处理了,事务形同虚设。
5.3 字段映射翻车:下划线列名与驼峰属性的黑匣子
现象:数据库表字段是create_time,Java 实体属性是createTime,查询结果里这一列总是 null,其他字段正常。
原因:MyBatis 默认不会自动把下划线列名映射成驼峰属性名。如果 resultMap 没有显式定义该字段的映射关系,这个字段就映射不上。
解决:在 mybatis-config.xml 里开启全局驼峰映射:
<settings> <setting name="mapUnderscoreToCamelCase" value="true"/> </settings>这个配置开启后,create_time会自动和createTime对应。但要注意:它只对「列名转属性名」生效,反过来写 SQL 时,#{createTime}这种参数传值不受影响。如果你使用了@Param注解传参,参数名要跟 SQL 里的#{xxx}保持一致,这是另一个容易混淆的点。
5.4 版本兼容与部署细节:JDK、Tomcat、依赖版本一换就错
现象:同一份源码在某台电脑上跑得好好的,换一台电脑部署,启动时报ClassNotFoundException或NoSuchMethodError。
原因:最常见的是 JDK 版本和 Tomcat 版本不匹配。Spring 5.x 要求 JDK 8+,如果装在 JDK 7 的环境里,启动时 Spring 容器初始化会报错;Tomcat 10 及以后版本把javax.servlet包改成了jakarta.servlet,用javax.servlet写的源码在 Tomcat 10 上直接报 ClassNotFound。
解决:我的建议是固定一套经过验证的组合,不要追求最新。JDK 1.8 + Tomcat 8.5 或 9.0 + Spring 5.2.x + MyBatis 3.5.x 是这套源码最稳妥的环境。另外,Druid 连接池的版本和 MySQL 驱动版本也要配套,MySQL 8.0 驱动连接 MySQL 8.0 没问题,但如果数据库是 5.7,驱动用 8.x 反而可能报Public Key Retrieval is not allowed,这时在 JDBC URL 后面加?allowPublicKeyRetrieval=true&useSSL=false可以解决。
5.5 日期与金额精度:两个看着小、出问题很隐蔽的类型坑
现象:页面显示领用时间是2025-01-13 20:15:32.0,多了一个.0;或者库存数字变成9.999999999999这种奇怪的结果。
原因:日期多.0是因为 MyBatis 把数据库的DATETIME类型映射成了Timestamp,JSON 序列化时默认带毫秒。库存数字异常是数据库字段用了float或double,这类浮点类型在计算时会有精度损失。
解决:日期字段建议在实体里用String接收,配合 MyBatis 的jdbcType=TIMESTAMP转换,或者配置 Jackson 的日期格式为yyyy-MM-dd HH:mm:ss。金额和库存字段统一用BigDecimal+ 数据库decimal(10,2)。在代码层面约定「所有涉及金额、数量的计算都走 BigDecimal 的 add/subtract,不用原生+-」,可以避免大多数精度问题。
6. 最后一步:把库存拆成「流水汇总」模式,对账不再靠玄学
如果你已经把这个系统跑通了,我给你一个值得进一步改造的建议:把tb_stock表的「当前库存」改为「流水汇总计算」的模式。具体做法是,每次查询库存时不再直接读tb_stock.quantity,而是用SUM(tb_stock_flow.change_qty)按material_id分组汇总。这样做的直接收益是:任何一次异常操作都会导致账实不符,而你可以用一条 SQL 找出不一致的记录:
SELECT s.material_id, s.quantity AS stock_table_qty, IFNULL(SUM(f.change_qty), 0) AS flow_calc_qty FROM tb_stock s LEFT JOIN tb_stock_flow f ON f.material_id = s.material_id GROUP BY s.material_id, s.quantity HAVING stock_table_qty != flow_calc_qty;这条 SQL 跑出来有结果,就说明库存表和流水账之间存在脏数据,再顺着tb_stock_flow按时间排查是哪个操作导致的。批量领用场景下,如果一次选了 5 种耗材,正确的做法是把 5 次扣减和 5 条流水都放在一个事务里,任何一条失败整体回滚。
我个人的习惯是,在系统上线前用模拟数据跑一次完整的「入库 100 → 领用 30 → 退回 5 → 报废 2」流程,然后用这条对账 SQL 验证每一步的数字变化。如果对账单能始终对上,这套系统才算真的可用。这套源码方向值不值得投入?如果你需要的是一个能多人使用的真实台账工具,SSM 版完全够用;如果你要做成微服务或对接消息队列,那才需要换技术栈。把流水和对账这套思路吃透,比换框架更值钱。希望帮到你。
本文还有配套的精品资源,点击获取