高校实验室的危化品管理,说大不大,说小不小,但每一瓶试剂背后都牵着一份安全责任。早些年实验室管理员最怕两件事:一是月底盘点时台账数量跟实物对不上,二是学生领用三乙基铝这类试剂时,说不清上次那瓶用在哪里、还剩多少。我做的这个前后端分离的高校危化试剂仓储系统,就是为了把这些线下流程彻底搬上线——从试剂采购入库、领用申请、教师审批、库存扣减,到归还登记、报废记录,全程电子化留痕。整套系统基于SpringBoot+Vue+MyBatis+MySQL开发,适合课程设计、毕业设计参考,也适合刚接触前后端分离项目想找一个完整案例练手的开发者。
下面我把这套系统的需求拆解、技术选型、数据库设计、核心业务实现和部署过程完整写出来,包括我实际开发中踩过的坑和优化过的细节。
1. 高校危化品管理场景剖析:这个系统到底在解决什么问题
1.1 传统手工台账的三个致命痛点
高校实验室的属性比较特殊,它不像企业仓库那样有专职库管和物流系统支撑,多数实验室的试剂管理由实验员或研究生兼职负责。这就带来第一个痛点:台账记录完全依赖人工,试剂名称写错一个字,或者CAS号漏填,月底盘点时就是一笔糊涂账。
第二个痛点是流程不规范。按照规定,剧毒、易制爆类危险化学品必须双人双锁、双人领用,领用时要有审批记录、用途说明。但在实际操作中,很多实验室就是一本登记本,谁领了什么试剂签个字就走,事后追溯难度极大。真出了问题,谁拿着这本登记本都说不清楚。
第三个痛点是库存状态不透明。某个课题组要用三氯甲烷,仓库里到底还有多少?什么时候采购的?是否过期?这些问题不是跑一趟仓库翻台账就能立刻答出来的,于是经常出现重复采购积压资金,或者临到实验时发现试剂已经用完的尴尬局面。
1.2 系统的核心业务边界:什么该做,什么不该做
在做这个项目之前,我先给自己划了一条业务边界,免得需求无限扩张导致项目烂尾。
必须做好的核心闭环是:建档-入库-领用审批-出库-归还-报废。具体来说:
- 危化品基础档案由管理员统一维护,包括名称、CAS号、危险类别、规格、存放位置、库存上下限;
- 采购到货后做入库登记,生成入库流水;
- 学生或实验人员提交领用申请,填写用途、用量、预计归还日期;
- 指导教师在线审批,超量或高风险领用由管理员二次审核;
- 审批通过后库房出库,系统扣减库存并生成出库记录;
- 归还时做数量核验,损耗部分单独登记;
- 过期或变质试剂走报废流程,生成处置记录。
不该做的部分我也明确划掉了:不做硬件物联网设备直连(温湿度传感器、门禁联动),因为要对接的硬件型号五花八门,工作量不可控;不做复杂的财务报表,只保留简单的出入库台账导出。明确边界之后,前后端开发就有了清晰的验收标准,整体项目大概花了两周半的业余时间做完。
2. 技术栈选型:为什么是SpringBoot+Vue+MyBatis+MySQL这一套
2.1 前后端分离的价值,不只是"看起来高级"
很多人一听到前后端分离就觉得是为了赶时髦,其实放在这个场景里,分离架构有实实在在的好处。危化品仓储系统涉及的角色天然分散——教师要在手机上审批领用申请,实验员在实验室电脑上做入库登记,管理员在办公室查看库存台账。前后端分离之后,前端只是一个静态资源包,部署在Nginx或者任意Web服务器上,后端是一套无状态API服务,天然适合这种多终端访问的形态。
另外,前后端分离还能把接口文档作为团队协作的契约。我在开发时先用在线文档把接口定义清楚,前端mock数据开始写页面,后端专心实现业务逻辑。两边并行推进,不互相等待,这也是单人开发时保持效率的手段。
2.2 后端:SpringBoot+MyBatis的组合逻辑
SpringBoot选得很果断,理由也没什么争议。它内置了Tomcat,一个java -jar命令就能把服务跑起来,省掉以前配置Web.xml、Spring容器那一大堆麻烦。对于这种中小型管理系统,SpringBoot的自动配置已经把90%的样板代码消化掉了,开发者只需要关注业务逻辑。
持久层我没有选JPA或MyBatis-Plus,而是用了原生MyBatis。原因很简单:这类仓储系统的核心是出入库流水和各种统计报表,SQL往往带多表关联、分组聚合、条件拼接,MyBatis把SQL掌握在开发者自己手里,写起来透明可控。例如统计某个危险等级化学品的月度领用量,一条group by语句就能完成,用JPA反而要折腾Criteria API。MyBatis-Plus固然方便,但自带的那套CRUD抽象在这个场景里有点偏重,而且一旦涉及复杂查询,还是要手写XML和注解SQL,不如直接上手原生MyBatis干净。
2.3 前端:Vue生态的成熟与务实
前端选择了Vue 2.6加Element UI。我知道有些朋友会用Vue 3 + Element Plus,但考虑到课程设计和毕业设计场景下的资料丰富程度,以及学校机房Node环境的兼容性,Vue 2 + Element UI依然是稳妥且多数教程覆盖的路线。Vue的双向绑定和组件化机制,让入库登记、领用申请这类表单密集型页面写起来非常顺手;Element UI自带表格、弹窗、分页、日期选择器,省去造轮子的时间。
期间我也对比过若依这样的脚手架框架,确实开箱即用,但前提是你能接受它的代码风格和项目约束。自己做这个项目就是希望把每一步的来龙去脉讲清楚,所以我选择从零搭建而非直接套用脚手架。
3. 数据库设计:用一张流水表贯穿所有库存业务
3.1 整体表结构规划
数据库设计是这类系统的地基,我前前后后修改了三版,最终定下来六张核心表:
sys_user:用户表,保存账号密码和角色信息;chemical_info:化学品档案表,维护试剂基本信息;stock_record:出入库流水表,每一笔入库、出库、归还、报废都记录在这里;apply_record:领用申请表,保存审批流的状态流转;approval_record:审批记录表,完整记录每一步审批人、审批意见和时间;sys_config:系统配置表,存放默认审批阈值等参数。
这里最核心的设计决策是:不要把库存数量作为单独字段一直翻来覆去地改,而是把库存计算建立在流水表之上。也就是说,chemical_info表里有一个current_stock字段作为缓存,但它不是每次操作直接改数字,而是通过插入一条stock_record流水,再根据流水动态计算并更新缓存库存。这样任何一笔数量变动都能在流水表中找到依据,回溯问题时有据可查。
3.2 核心表设计细节
用户表字段不复杂,但要特别注意密码存储方式,我用了bcrypt加密而非MD5。有些项目为了演示方便用MD5,但放在涉及安全管理的系统里实在不应该。
化学品档案表是重中之重,字段包含:cas_no、chemical_name、danger_class(危险等级)、specification、unit(单位)、location(存放位置)、min_stock、max_stock、lifecycle_status。其中cas_no是化学品的唯一身份标识,我加了唯一索引。danger_class我用枚举存储,比如剧毒品、易制爆品、易燃液体、腐蚀品等,后端的枚举类负责做转换。这个字段在MySQL里存的是varchar,配合MyBatis的TypeHandler,可以在Java代码里直接把它当成枚举对象使用,具体映射逻辑写一个DangerClassTypeHandler即可。
出入库流水表的字段设计决定了系统能回答什么问题:
CREATE TABLE stock_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, chemical_id BIGINT NOT NULL, record_type VARCHAR(20) NOT NULL, -- IN/OUT/RETURN/SCRAP quantity DECIMAL(10,3) NOT NULL, before_stock DECIMAL(10,3), after_stock DECIMAL(10,3), operator_id BIGINT NOT NULL, apply_id BIGINT DEFAULT NULL, remark VARCHAR(255), create_time DATETIME NOT NULL );before_stock和after_stock这两个字段很多仓管系统会省略,我却建议保留。它们的价值在于:任何一笔流水你都能立刻看出操作前后的库存变化,连计算都不用做。配合record_type,可以很容易实现双向追溯——从库存台账点进某瓶试剂,能看到它完整的一生:什么时候进来的、被谁领走过、归还过多少、最终怎么处理的。
3.3 设计时容易忽略的细节
数据库方面有几个细节,我是在开发中才补齐的。
第一个细节是DECIMAL而不是FLOAT。危化品用量经常是0.25L、500mL这种带小数的量,FLOAT存在二进制浮点误差,累计库存时间长了会出现0.0001的偏差。用DECIMAL(10,3)虽然牺牲了一点灵活性,但精确性有保障。
第二个细节是时间字段的类型统一。我全部使用DATETIME,后端实体用LocalDateTime接收,避免java.util.Date的时区序列化问题。MySQL 8的驱动默认时区是UTC,如果连接串里不指定serverTimezone=Asia/Shanghai,所有时间都会差8小时。这个坑我后面部署的时候还会再提。
第三个细节是物理外键不要加。我所有的表关联都只是逻辑上的外键,比如stock_record.chemical_id指向chemical_info.id,但数据库层面没有创建FOREIGN KEY约束。原因很简单:一旦加了物理外键,测试数据清理时要先删子表再删主表,顺序颠倒就报错。逻辑外键配合业务层的事务控制,对这类系统来说已经足够。
4. 后端核心业务:登录鉴权、库存流水与审批状态机
4.1 登录鉴权与角色权限设计
后端模块划分我按照业务边界分了四个包:sys(用户与权限)、chemical(化学品档案)、stock(出入库流水)、apply(领用审批)。登录这一块用的是经典JWT方案。
流程是这样的:用户提交账号密码,服务端校验通过后签发一个JWT令牌返回给前端;前端把令牌存进localStorage,每次请求在Authorization头带上;后端拦截器统一解析令牌,把当前用户信息放进ThreadLocal供业务层使用。JWT的好处是无状态,不需要在服务端保存会话,前后端分离部署之后,接口层可以水平扩展,不用考虑Session共享的问题。
角色模型我设计了三种:ADMIN(管理员)、TEACHER(指导教师)、USER(普通实验人员)。权限控制没有引入Spring Security的复杂规则,而是用拦截器加自定义注解解决。比如出库接口要求ADMIN角色,就写一个@RequireRole("ADMIN")注解挂在Controller方法上,拦截器里用反射读取注解判断。这样做代码量少,逻辑直白,也方便二次开发时按需调整。
4.2 出入库的并发控制
出入库操作有一个典型的并发安全场景:两个实验员同时领用某一种试剂,都在事务里先查库存、判断够不够、再扣减。如果都用最朴素的"先查后扣",极端情况下两个人读到的都是"还有3L",各自扣完2L,实际库存变成负1L。这个隐患在单机开发环境几乎不会暴露,并发一上来就现出原形。
处理方案有两种。一种是用SQL里的UPDATE ... WHERE quantity >= #{need}做原子条件更新,影响行数为0说明库存不足,直接抛出业务异常回滚事务。另一种是给化学档案表加一个乐观锁版本号字段,每次更新前比较版本号。我在这个项目里选了前者,因为出入库操作本身就是单行更新,一条原子更新语句比版本号方案更省事。
@Transactional public void stockOut(Long chemicalId, BigDecimal quantity, Long operatorId) { int updated = stockMapper.reduceStock(chemicalId, quantity); if (updated == 0) { throw new BusinessException("库存不足,操作已取消"); } // 插入流水记录 StockRecord record = StockRecord.of(chemicalId, quantity, RecordType.OUT); stockMapper.insertRecord(record); }核心就是reduceStock那一条更新语句:
UPDATE chemical_info SET current_stock = current_stock - #{quantity} WHERE id = #{chemicalId} AND current_stock >= #{quantity}这里再强调一个容易被忽略的点:库存扣除和流水插入必须在同一个事务里,否则会出现"库存扣了但流水没记"或反之的不一致状态。
4.3 审批流程的状态机设计
领用审批是这个系统和普通库存系统拉开差距的地方,因为危化品领用不能像普通文具一样直接扣库存,必须走一条完整的审批链路。
我在apply_record表里用status字段管理状态流转,取值定义如下:
PENDING_TEACHER:等待指导教师审批;PENDING_ADMIN:教师已同意,等待管理员复核;APPROVED:审批通过,等待出库;REJECTED:审批驳回;FINISHED:已完成出库,流程终结。
状态机的转移规则就两条:普通化学品领用,教师同意后直接PENDING_ADMIN,管理员复核即可以出库;涉及剧毒品或超过设定阈值(比如单次领用超过500mL),教师同意后必须进入PENDING_ADMIN,管理员还要额外填写复核意见才能通过。这个阈值的数值我放在了sys_config表里,而不是写死在代码里,方便实验室根据上级检查要求灵活调整。
审批记录表approval_record的作用是审计留痕。每个状态节点的操作人都必须落一条记录,记录谁在什么时间做了什么决定、批语是什么。这项功能平时看起来冗余,但一旦上级安全检查调阅使用记录,这些完整数据就是实验室管理响应的底气。
4.4 库存预警怎么实现最省力
库存预警的常规方案是用定时任务每天扫一遍所有化学档案,把低于下限或者接近上限的试剂找出来,生成一条预警消息。我第一期也是这么做的,后来发现一个问题:预警消息的实时性不够,管理员早上看到的统计数字到了下午就可能过时。
这里我换了个思路,预警不在定时任务里做,而是在两个时机触发:其一,每次出库扣减库存成功后,立刻检查当前库存是否低于下限,低于则向管理员发送站内提醒;其二,每次入库之后检查是否高于上限,提示"库存过多,请评估是否调整采购计划"。这样预警就变成了业务链路中的自然环节,不用单独跑定时任务,实时性也更好。当然,如果系统目前还没有接入消息推送渠道,那就在登录后的主界面加载一个"库存预警列表"的接口,把低于下限的试剂全部列出来,效果一样。
5. Vue前端:页面组织、路由守卫与核心交互
5.1 前端项目的模块划分
前端部分我用的标准Vue CLI创建的项目,目录结构划分如下:
src/api/:按后端模块拆分的接口请求封装;src/router/:路由配置与导航守卫;src/store/:Vuex保存用户登录态和全局信息;src/views/:页面组件,按业务模块建子目录;src/components/:通用组件,比如档案选择器、库存警示标签;src/utils/request.js:封装axios实例,统一处理token和错误提示。
我对axios做了一层薄封装,请求拦截器里从localStorage取token塞进请求头,响应拦截器里统一处理HTTP 401跳转到登录页、HTTP 500弹出错误提示。这一步很重要,否则每个页面都要自己写一堆重复的异常处理代码。
5.2 路由守卫控制页面访问权限
前端路由不是简单的"登录后都能看",需要按角色控制。我的做法是路由配置里给每个meta加roles字段,然后在全局导航守卫里做判断:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (!token && to.path !== '/login') { next('/login'); return; } const userRole = store.state.user.role; if (to.meta.roles && !to.meta.roles.includes(userRole)) { next('/403'); return; } next(); });这种方式相较于动态路由简单很多,适合这种菜单数量可控的管理系统。如果以后要做一个大型的微前端或者多租户平台,再考虑从后端拉取路由表动态生成也不迟。
5.3 领用申请页面的交互细节
领用申请是使用频率最高的页面,交互上我特别注意了两点。第一点是化学品选择器必须支持按名称和CAS号模糊搜索,因为用户不一定记得全名,更多时候只记得"那个银白色的瓶装试剂"。第二点是填用量时要联动显示该试剂当前的库存,如果申请量超过当前库存,前端直接给出红色预警,不再允许提交。这些看起来不起眼的小交互,恰恰是系统好不好用的关键。
入库登记页面则采用了类似购物车的设计:管理员可以一次性添加多行试剂,最后一并提交。每一行需要填名称、CAS号、规格、危险等级、数量、存放位置。表单提交前统一做一次非空校验,校验逻辑我放在前端而不是后端重复写,后端只做最终的数据合法性快照。
数据看板页面是盘点统计的入口,用了几张卡片展示今日出入库笔数、低库存试剂数量、待审批申请数,下面配了几个Element UI的表格展示最近出入库流水。我一开始想做ECharts图表,后来觉得在系统V1.0里表格反而信息更准确,图表留到二期再做。
6. 部署配置与上线:Nginx托管前端,SpringBoot作为后端服务
6.1 本地运行环境的准备
这套系统要用到的本地环境:JDK 1.8以上(我用的是8)、Maven 3.6、Node.js(建议14以上)、MySQL 8.0。前端依赖安装用npm install,如果网络不好或版本冲突频繁,可以考虑换淘宝镜像源。后端依赖统一在pom.xml里管理,核心依赖是spring-boot-starter-web、mybatis-spring-boot-starter、mysql-connector-java、jjwt。
数据库连接配置里面最容易出错的就是MySQL 8的驱动类名和时区参数。驱动类名是com.mysql.cj.jdbc.Driver,而且连接串中一定要带上serverTimezone=Asia/Shanghai。我见过很多新手在这一步报SSL连接错误或Public Key Retrieval is not allowed,都是因为连接串少了useSSL=false&allowPublicKeyRetrieval=true这两个参数。完整配置如下:
spring: datasource: url: jdbc:mysql://localhost:3306/chemical_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver项目里附带一个init.sql脚本,按顺序建库、建表、插入管理员初始账号和演示数据。执行的时候注意先切到目标库再执行建表语句,避免建到默认库里。
6.2 构建与服务器部署
前端构建:
npm run build构建产物在dist目录下,这就是一个纯静态文件集合。后端的构建更简单:
mvn clean package -DskipTests生成的可执行jar包名形如chemical-warehouse-1.0.0.jar。有两种部署姿态可以选择:一是直接用内置Tomcat运行jar包,这是SpringBoot推荐方式;二是打war包丢到独立Tomcat的webapps目录下。我更推荐第一种,既省资源又少一层配置。
Nginx配置要点是用location /api/把接口请求反向代理到后端jar服务上,同时把前端dist目录作为根路径。这是前后端分离部署最关键的一步:
server { listen 80; server_name your.domain.com; root /usr/share/nginx/chemical-front; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }上面location /里的try_files规则解决了Vue Router的history模式刷新404问题。如果不想配这个,也可以改用hash模式,URL会多个#号,但没有服务端配置负担。有洁癖的朋友建议直接上history模式加上面的配置。
后端jar包的启动命令我是这么跑的:
nohup java -jar chemical-warehouse-1.0.0.jar --spring.profiles.active=prod > app.log 2>&1 &配置上注意生产环境的数据源账号密码不要跟开发环境共用,可以单独建一个application-prod.yml,用启动参数激活对应profile。
6.3 系统初始化与验收清单
部署完成后,不要急着演示,先按验收清单走一遍:管理员能否登录;新增化学品档案后能否入库;库存低于下限时是否出现预警;提交领用申请后教师端能否看到待办;教师同意后能否出库扣库存;归还登记后库存是否恢复;导出台账Excel是否正常。
我在实际部署时就遇到过一次奇怪的现象:前端页面能打开但登录接口一直报404。排查了一会儿才发现是Nginx的/api/代理路径没有生效,原因是build的时候前端请求底路径带了/context-path,而后端没有配置对应的server.servlet.context-path,两边路径对不上。这类前后端分离联调的问题,排查思路永远是先看浏览器Network面板里的实际URL,再顺着请求链路一层层找,不要瞎猜。
7. 开发过程踩过的坑和优化建议
7.1 MyBatis的一级缓存与列表查询的血泪教训
MyBatis默认开启一级缓存,同一个SqlSession内相同查询参数会命中缓存。听起来是好事,但我在做库存台账列表分页时发现一个问题:如果在一个事务里先查询了某个化学品的档案信息,随后另一条业务代码插入了出入库流水,再执行同一条查询,返回的竟然还是旧数据。原因就是一级缓存没有自动失效。解决方式有两个,要么把后续插入操作和查询操作放到不同事务,要么在关键查询的select标签上添加flushCache="true"。同时我也关闭了二级缓存,因为这类仓储系统的数据实时性要求高,多一级缓存意味着多一处遗漏失效的可能。
7.2 前端Element UI的表格渲染性能
危化品台账最多的时候有一万多条记录。Element UI的el-table直接渲染一万行会明显卡顿,尤其是带自定义列和行内按钮的表格。解决办法有两个方向:数据量可控的情况下,后端分页默认每页20条;数据量不可控且确实需要滚动加载一万行时,改用el-table的虚拟滚动方案。对高校实验室的体量,后端分页已经足够,我最终选择了分页加简单搜索的方案,实测翻页稳定在300ms以内。
7.3 关于源码学习的建议
拿到完整源码之后,建议先不要急着启动项目。我个人的学习路径是:先打开init.sql把表结构和初始数据看一遍,理清业务关系;再跟着数据库表看后端controller-service-mapper三层的路径划分;最后才启动前端页面,把界面操作和后端日志对照起来看。这样跳过了很多"代码能跑但不知道在跑什么"的低效阶段。
另外不建议去网上找工具做jar包反编译来看内部逻辑,效果差且浪费精力。项目本身附带全部源码,遇到不明白的地方直接用IDE跳转到定义,比任何反编译工具都清晰。
7.4 可扩展的方向
这套系统目前还没有接消息推送,如果实验室要求审批消息实时通知到教师手机,可以对接企业微信或者钉钉的机器人Webhook,在审批状态变更的地方调用一个消息推送服务即可。文档存证方面,化学品的安全技术说明书(MSDS)和采购单据目前只是简单的文件路径存字段,如果要完善,可以引入MinIO做对象存储,chemical_info表加一个附件URL字段,前端上传组件做个文件名回显,这个扩展大概一天能完成。
我自己在实际使用中的体会是,做一个管理系统最难的不是某个技术点,而是把所有业务节点串联起来形成闭环。危化品仓储系统这个项目,恰恰能让你把SpringBoot事务、MyBatis复杂查询、JWT鉴权、前端路由守卫、Nginx部署这些知识点全部串起来练一遍,这也是我推荐拿它做学习项目的原因。如果你正在找前后端分离的实战案例,直接拿这套东西跑一遍,比看十篇零散的框架教程都管用。