从农机站的仓库里回来那天,我手里攥着十几张写满出入库记录的纸质台账,心里只有一个念头:这套农业物资管理得用系统做。当时正好接了一个开发任务,要做一套基于 java+springboot+easyui+html+maven+mysql 的农业物资管理系统,需求不复杂,但业务细节极其琐碎——种子、化肥、农药、农机配件的出入库,供应商往来,库存预警,月底盘点。这篇文章就是那次完整开发过程的复盘,我会把技术选型、数据库设计、后端业务逻辑、前端EasyUI整合、部署上线中所有能说的细节都铺开,包括踩过的坑。如果你正打算做一个类似的管理系统,或者只是想把Spring Boot和EasyUI这套组合跑通,这篇东西应该能让你少走不少弯路。
1. 立项背景:农业物资管理的业务痛点与系统目标
1.1 手工台账到底有多痛
农业物资管理和一般商品库存管理最大的不同在于:物资种类杂、计量单位乱、季节性波动强。种子按袋算,化肥按吨算,农药按瓶算,农机配件更是按“个”算,同一个玉米种子的不同批次还对应不同的种植区域。很多乡镇农技站和中小型农业合作社,过去就是靠一张Excel加一个本子撑着。
我调研时看到最典型的场景:仓管员早上发出去两袋化肥,晚上在本子上记一笔;月末想盘点,发现本子上的数字和实际库存对不上,因为中间还有过临时借调、损耗和退库。更要命的是,库存低于安全线没人知道,等农户来买才发现没货,供应商又得临时送货,一来一回耽误农时。
这些痛点归结起来就是:物资台账不透明、出入库记录不可追溯、库存预警缺失、统计报表靠人工拼凑。系统的目标不是搞一个“全智能数字化平台”,而是先把账记清楚,把流程固化下来,让每个操作都有记录,每次进出都有凭证。
1.2 业务角色与功能边界
系统定位为内部管理系统,用户集中在管理员、仓管员和普通员工三个角色。管理员维护供应商、物资分类、用户账号,查看全部报表;仓管员负责入库、出库、盘点、调拨;普通员工只能查询库存和申请领用。这个权限划分不需要做到特别细的粒度,只要菜单级能控制住就行。
功能边界上,我一开始就和需求方确认了几条硬性规则:入库必须有采购单号或者来源说明,出库必须有领用人信息,库存一旦出现负数系统直接拒绝操作。这几条规则看起来简单,但在后续数据库设计和接口实现中,直接决定了事务和唯一约束怎么加。
2. 技术选型:为什么是Spring Boot和EasyUI这套组合
2.1 后端选型:Spring Boot带来的开发效率提升
关于后端框架,争议从来都很大。有人上来就推荐Spring Cloud微服务,但一个总预算可能只有几十人天的农业内部管理系统,微服务完全是给自己找麻烦。Spring Boot最核心的价值在于“默认配置 + 内嵌容器”:不需要再费劲配置Tomcat,不用写一堆XML,maven引入spring-boot-starter-web就能跑起来。
版本选择上我用了Spring Boot 2.3.4.RELEASE,搭配JDK1.8。这个组合的稳定性经过太多次验证了,网上能找到的资料也最多。我也试过Spring Boot 3.x,但JDK升级到17之后,一些老版MySQL驱动和EasyUI后端的兼容性反而要多处理几处,对于这个项目完全没必要追新。
2.2 前端选型:为什么用EasyUI而不是Vue
这里多说几句。现在随便搜一个管理系统的开源项目,前端基本都是Vue + Element UI,或者React + Ant Design。但我最终选的是EasyUI,理由非常实际:
第一,这个系统的使用频率不高,并发量极低,但页面数量却不少,光是基础数据的增删改查就有十多个界面。EasyUI这种基于jQuery的服务端渲染UI库,一个datagrid标签加一个toolbar就能拼出完整的CRUD页面,不需要写大量JavaScript状态管理代码。
第二,农业系统的使用环境很多是内网,电脑配置还不一定好。EasyUI是纯JS + CSS静态资源,不依赖Node构建链,拷贝到static目录就能用,对于部署环境非常友好。
第三,也是最关键的一点:后期接手的开发者可能不会前端工程化,但看EasyUI的HTML+JS相对容易。一个项目的寿命往往取决于能否被后续维护的人看懂,这一条对我来说比技术多新更重要。
当然,EasyUI也有明显的短板:界面观感停留在2015年左右的风格,复杂联动交互写起来很别扭。如果项目对UI美观和交互体验要求高,那我不会推荐它。但就农业物资管理这种表单密集、逻辑固定的场景,它反而是“杀鸡用牛刀正好”的选择。
2.3 数据存储:MySQL的适用性
MySQL选用理由没什么好说的,稳定、轻量、社区生态强。唯一要注意的是版本,8.0之后认证插件默认是caching_sha2_password,有些老版本的客户端连接会报SSL连接错误,所以我在连接串里明确加了useSSL=false和allowPublicKeyRetrieval=true,这一点在后文部署部分还会细讲。
2.4 Maven在构建链路里的作用
Maven在这里面的角色是“项目骨架管理员”。它管理了所有依赖版本,统一了构建生命周期。我在pom.xml里固定了spring-boot-starter-parent版本,配合阿里云镜像仓库,mvn clean package一把就能打出可执行jar包。Maven不复杂,但用不好真的很误事——最常见的就是依赖冲突。我的经验是:优先使用Spring Boot官方BOM已经管理版本的依赖,不额外指定子依赖版本,冲突概率会小很多。
3. 数据库设计:农业物资核心表结构与字段思路
3.1 三大核心表:物资分类、库存台账、出入库流水
数据库我设计了八张表,但真正支撑业务的是三张:物资分类表、库存台账表和出入库流水表。其他如供应商表、用户表、采购单表都是挂在这三张主表上的附属信息。
先看物资分类表,结构非常简单:
CREATE TABLE `material_category` ( `id` int NOT NULL AUTO_INCREMENT, `category_name` varchar(50) NOT NULL, `parent_id` int DEFAULT NULL, `sort_order` int DEFAULT 0, `status` tinyint DEFAULT 1, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;之所以预留parent_id,是为了将来做二级分类。农业物资的分层特别明显:一级是种子、化肥、农药、农机配件,二级里种子还得分玉米种、水稻种、蔬菜种。实际用的时候,如果只有两级,甚至可以直接用两个字段来存,不必搞无限级递归。
库存台账表,这是整个系统的核心,字段设计上做了不少冗余。
CREATE TABLE `material_stock` ( `id` int NOT NULL AUTO_INCREMENT, `material_code` varchar(30) NOT NULL, `material_name` varchar(100) NOT NULL, `category_id` int NOT NULL, `category_name` varchar(50) DEFAULT NULL, `specification` varchar(50) DEFAULT NULL, `unit` varchar(20) DEFAULT NULL, `stock_qty` decimal(12,2) NOT NULL DEFAULT 0, `safe_qty` decimal(12,2) DEFAULT 0, `supplier_id` int DEFAULT NULL, `supplier_name` varchar(100) DEFAULT NULL, `warehouse` varchar(50) DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_material_code` (`material_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里我故意冗余了category_name、supplier_name,就是为了查询列表时少做连表操作。管理系统的列表查询频率极高,库存表又是高频查询对象,把常用维度直接冗余进去,代价是更新时要多写一处维护逻辑,但对MySQL来说是值得的。
出入库流水表:
CREATE TABLE `material_record` ( `id` bigint NOT NULL AUTO_INCREMENT, `material_code` varchar(30) NOT NULL, `material_name` varchar(100) NOT NULL, `record_type` tinyint NOT NULL, -- 1入库 2出库 3盘盈 4盘亏 `qty` decimal(12,2) NOT NULL, `stock_before` decimal(12,2) NOT NULL, `stock_after` decimal(12,2) NOT NULL, `operator` varchar(50) NOT NULL, `remark` varchar(255) DEFAULT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;注意,这条流水表里存了业务发生前后的库存量,这在排错的时候特别有用。要是有一天库存数据对不上,只需要看流水里的stock_before和stock_after就能定位是哪一笔操作出了问题。
3.2 库存冗余字段与事务一致性设计
有了上面三张表,出入库操作就涉及两个事务要求:一是更新material_stock中的库存数量,二是往material_record中插入一条流水。这两个动作必须放在同一个事务里。
我把具体逻辑写在Service层,用@Transactional注解保证要么全成功要么全失败。这里有一个细节:事务内不要捕获异常后再吞掉,一定要让RuntimeException继续往外抛,否则Spring的事务代理根本感知不到,数据就会出现“库存没扣但流水多了”的脏情况。这一点是我以前踩过的坑,后面还会单独说。
关于冗余字段的一致性,我的做法是在入库时,如果库存记录不存在,就新增一条material_stock记录并带入material_name、category_name这些冗余字段;如果已存在,则更新这些字段,保证因为目录调整或名称变更导致的信息同步。
3.3 库存预警和统计报表的SQL实现
库存预警其实就是一条普通查询:
SELECT material_code, material_name, stock_qty, safe_qty FROM material_stock WHERE status = 1 AND stock_qty <= safe_qty;我在系统首页的一个数据面板上定时刷新这个查询,把低于安全库存的物资用红色标出。其实加一个消息通知也不难,但农业场景下,仓管员每天打开系统看一次就行,不需要短信推送。
月度进销存报表的核心SQL是按物资分组,用条件聚合统计一个时间段内的入库总量和出库总量:
SELECT material_code, material_name, SUM(CASE WHEN record_type = 1 THEN qty ELSE 0 END) AS total_in, SUM(CASE WHEN record_type = 2 THEN qty ELSE 0 END) AS total_out FROM material_record WHERE create_time BETWEEN #{startTime} AND #{endTime} GROUP BY material_code, material_name;这个实现方式很简单,数据量大了以后可能跑得慢,但按农业物资系统的体量,一年也就几万条流水,加个create_time的联合索引就够用了。我在这张流水表上建的索引是(material_code, create_time)。
4. 后端实现:Spring Boot分层架构与业务开发细节
4.1 项目结构与Maven依赖清单
项目采用的是标准的分层结构:
com.example.agri ├── controller // 接口层 ├── service // 业务逻辑层 ├── mapper // MyBatis映射接口 ├── model // 实体类 │ ├── entity // 数据库实体 │ ├── dto // 请求传输对象 │ └── vo // 返回视图对象 ├── config // 配置类 └── common // 工具类、统一返回、异常处理pom.xml里核心依赖这样配置:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.3.4.RELEASE</version> </parent> <dependencies> <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.1.4</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.2.13</version> </dependency> </dependencies>为什么用MyBatis不用JPA?因为库存管理系统的SQL场景很多,尤其是统计报表,MyBatis写原生SQL更顺手,也更容易控制分页和复杂查询。PageHelper插件可以直接在Mapper查询前设置分页参数,对EasyUI的datagrid非常友好。
4.2 统一返回结果与全局异常处理
接口返回格式必须从一开始就定好。我定义了一个轻量的Result类,结构很简单:
{ "code": 200, "msg": "操作成功", "total": 0, "data": {} }所有接口,无论是成功还是失败,都返回这个格式。这样前端的EasyUI处理逻辑只有一个分支,不用每个页面都去解析不同的数据格式。
全局异常处理用@RestControllerAdvice,我捕获了所有异常,返回code=500,msg对应异常信息。但这里有个需要注意的细节:不要把异常堆栈直接暴露给前端,msg里只放面向用户的提示,真正的堆栈打到日志里。否则仓管员看到一串英文报错会直接懵掉,还容易泄露系统信息。
4.3 核心业务:入库、出库、盘点接口的实现
入库和出库是系统的核心。我以出库为例,讲一个完整的实现链路。
Controller层:
@RestController @RequestMapping("/api/stock") public class StockController { @Autowired private StockService stockService; @PostMapping("/out") public Result out(@RequestBody StockOutReq req) { stockService.outStock(req); return Result.success(); } }Service层真正干活的地方:
@Service public class StockServiceImpl implements StockService { @Override @Transactional(rollbackFor = Exception.class) public void outStock(StockOutReq req) { MaterialStock stock = stockMapper.selectByCode(req.getMaterialCode()); if (stock == null) { throw new BusinessException("物资不存在"); } if (stock.getStockQty().compareTo(req.getQty()) < 0) { throw new BusinessException("库存不足"); } BigDecimal stockBefore = stock.getStockQty(); BigDecimal stockAfter = stockBefore.subtract(req.getQty()); int updateRows = stockMapper.decreaseStock(req.getMaterialCode(), req.getQty()); if (updateRows == 0) { throw new BusinessException("扣减失败,请刷新后重试"); } MaterialRecord record = new MaterialRecord(); record.setMaterialCode(stock.getMaterialCode()); record.setMaterialName(stock.getMaterialName()); record.setRecordType(2); record.setQty(req.getQty()); record.setStockBefore(stockBefore); record.setStockAfter(stockAfter); record.setOperator(req.getOperator()); record.setRemark(req.getRemark()); recordMapper.insert(record); } }这里一个容易忽略的关键点:扣减库存时用的是条件更新语句,Mapper里的SQL不是先查再更新,而是直接在UPDATE语句中带上stock_qty >= 传入数量这个条件。
UPDATE material_stock SET stock_qty = stock_qty - #{qty}, update_time = NOW() WHERE material_code = #{materialCode} AND stock_qty >= #{qty}这样即使两个请求同时出库,也不会出现超扣。返回的updateRows为0就意味着库存已经被其他请求改掉了。这种“乐观锁式”的条件更新,比在Service里先select再比较更可靠,也省了一次数据库交互。
4.4 用户认证与权限控制
这个系统最简单实用的方案是拦截器加Session,而不是引入Spring Security。我在LoginInterceptor里校验用户是否登录,再根据请求路径前缀判断菜单权限。比如菜单id是1开头的是管理员专属,普通员工访问直接被挡住。
代码不复杂:
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { User user = (User) request.getSession().getAttribute("user"); if (user == null) { response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"未登录\"}"); return false; } return true; } }对于内部管理系统,这个程度已经够了。我不建议在这个体量里引入复杂的OAuth2流程,维护成本远大于收益。
5. 前端集成:EasyUI与Spring Boot的接口对接实践
5.1 DataGrid和Form怎么和后端交互
EasyUI的核心组件就是datagrid。它默认往后端传page和rows参数,返回JSON里需要total和数据列表。我后端刚好用PageHelper,一个PageInfo就能适配。
一个标准的库存列表页面长这样:
<table id="dg" class="easyui-datagrid" style="width:100%;height:100%" >mybatis: configuration: map-underscore-to-camel-case: true这样数据库的material_code就自动映射成materialCode,前端直接能用,不需要写一堆自定义转换。
5.2 搜索、分页、下拉框的实现
搜索区域我用的也是EasyUI的layout布局,一个form里放几个输入框加一个查询按钮。点击查询时,把form表单的字段收集起来,调datagrid的load方法重新加载数据:
function doSearch() { $('#dg').datagrid('load', { materialName: $('#txtSearchName').val(), categoryId: $('#cbCategory').combobox('getValue') }); }EasyUI的combobox加载物资分类时,URL指向后端接口:
$('#cbCategory').combobox({ url: '/api/category/list', valueField: 'id', textField: 'categoryName', onLoadSuccess: function() { // 默认选中第一个 var data = $(this).combobox('getData'); if (data.length > 0) { $(this).combobox('select', data[0].id); } } });我实际使用中发现,combobox的加载时间非常早,如果后端没有启动好,页面打开会有一个空下拉框,所以我在页面显示前先promise一下接口延时,或者干脆让用户手动刷新分类数据。这个小体验问题没有特别好的根治办法,但至少要在接口异常时给个提示,避免看上去像bug。
5.3 接口联调中的三个高频问题
第一是JSON日期格式。EasyUI的datagrid显示日期字段时,默认按字符串输出。如果后端把Date直接序列化,前端拿到的是“2024-01-01T00:00:00.000+00:00”这种带时区的格式。我在application.yml里统一配置了:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8第二是跨域问题。本地开发时前端在8080,后端在9090,直接用Ajax请求会跨域。我在config里写了一个CORS过滤器,放行开发环境的跨域请求,生产环境改成同源就把过滤器关掉。
第三是EasyUI自带的属性覆盖问题。比如form表单自动校验是用validatebox,如果input的class写成了easyui-textbox,onChange事件会失效。这个属于EasyUI的使用细节,我前期至少被坑过两次,建议写代码时统一控件风格,不要混用easyui-textbox和原生input。
6. 项目构建与部署:Maven配置、打包上线与常见错误
6.1 本地环境搭建:JDK、MySQL、Maven的那些坑
这套系统从零搭建环境,我遇到的第一个大坑是JDK版本。Spring Boot 2.3.4官方支持JDK 8到13,但实际用JDK 11编译时,某些反射相关的代码会出警告,所以我直接锁定了JDK 8。这个事情看似简单,但很多新人在第一步就会踩坑,尤其是电脑上同时装了好几个JDK版本,IDE里Project Structure和Maven的JAVA_HOME对不上,构建时就会报“invalid source release”。
第二个坑是MySQL 8的驱动和连接串。老项目模板里写的driverClassName是com.mysql.jdbc.Driver,这个类在MySQL 8里已被废弃,直接用会启动报错。正确写法是:
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/agri_db?useUnicode=true&characterEncoding=utf8&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai username: root password: 123456allowPublicKeyRetrieval=true这个参数特别关键,MySQL 8默认认证插件对某些客户端要求交换公钥,不设置直接报错。
第三是Maven依赖下载慢或者失败的问题。解决方案就是配置阿里云镜像,在settings.xml里加:
<mirror> <id>aliyunmaven</id> <mirrorOf>central</mirrorOf> <url>https://maven.aliyun.com/repository/central</url> </mirror>但注意,只对central仓库配置镜像就够了,不要用mirrorOf=*,否则一些第三方仓库也会被强制走阿里云,反而出问题。
6.2 打包部署:jar包方式运行与生产环境配置
开发环境直接用idea运行,部署时用Maven打包。我执行的是:
mvn clean package -Dmaven.test.skip=true然后拿到target目录下的jar包,复制到服务器:
java -jar agri-system-0.0.1.jar --spring.profiles.active=prod生产环境的application-prod.yml和本地配置最关键的区别是数据源密码。我建议生产中不要用明文密码,至少用jasypt加密。当然,这个系统如果内部使用,实在嫌麻烦可以先把密码写进环境变量,例如:
export DB_PASSWORD='yourpassword'然后在配置里写password: ${DB_PASSWORD}。这样至少不会把明文密码直接提交到Git仓库。
生产环境还需要注意的一点是端口和防火墙。Spring Boot默认8080,但服务器上可能有多个系统,我改成9090避免冲突。前端EasyUI页面都是静态资源,放在jar包的static目录里一起发布,不需要单独部署Nginx也可以。不过如果有多个静态资源目录,或者想用域名访问子路径,还是建议用Nginx做一层反向代理。
6.3 上线后必须处理的两个运维细节
一是日志。默认的Spring Boot控制台日志在重启后清空,出了问题根本找不到记录。我在生产配置里加了Logback的滚动文件输出,按天生成日志文件,保留三十天:
logging: file: name: logs/agri-system.log二是时区。服务器默认时区如果不是Asia/Shanghai,数据库又存了本地时间,会出现前后端显示时间差八小时的问题。我在MySQL连接串里显式指定serverTimezone,同时启动jar时加上-Duser.timezone=Asia/Shanghai,双保险。
7. 经验总结:开发周期、踩坑记录与后续扩展
7.1 从0到1的时间线和人员配置
这个项目我是带一个前端能力一般的伙伴共同完成的,实际用时不到三周。拆解一下:需求确认和数据库设计两天,后端基础框架搭建和物资CRUD四天,出入库流水和库存预警逻辑三天,前端EasyUI页面整合四天,测试改bug两到三天,部署上线两天。时间大头浪费在EasyUI的细节调优上,如果你对这套UI不熟,建议先在官方demo站看一遍datagrid和form的样例再动手。
7.2 我踩过的几个典型坑
第一坑:事务不生效。我最初在出库方法上写了@Transactional,但同事在方法内部新起了一个子线程去写流水,子线程的事务和主线程分离了,结果主方法正常回滚,子线程里插入的流水却留下了。后来把所有数据库操作都放进同一个接口方法里,不在子线程访问数据库,问题才解决。小系统真没必要引入多线程去处理同步操作,徒增复杂度。
第二坑:EasyUI的reload和loadData语义混淆。datagrid的reload是按当前参数重新请求后端,loadData是填充页面已有的数据数组。我第一次把筛选后的数据用loadData填进去,结果翻页时还是会请求所有数据,整个筛选就失效了。记住一点:任何查询和刷新都用load方法传参数。
第三坑:库存负数。这个问题在设计阶段就规避了,但上线后还是出现了,原因是数据库里库存数字段的默认值是0,却允许插入负数。我发现问题后,在表结构上直接加了无符号约束,从数据库层面杜绝负数。解决方案就是:
ALTER TABLE material_stock MODIFY stock_qty decimal(12,2) UNSIGNED NOT NULL DEFAULT 0;这样就算代码逻辑有漏洞,数据库也能兜住路。后来我还顺手给safe_qty也加了约束。
第四坑:Excel导出中文乱码。这个是通用问题,EasyUI的datagrid导出Excel时,我用POI生成文件,文件名有中文,HTTP响应头必须设置Content-Disposition为UTF-8编码,否则导出文件名乱码。代码是:
String fileName = URLEncoder.encode("库存报表.xls", "UTF-8"); response.setHeader("Content-Disposition", "attachment; filename=\"" + fileName + "\"");7.3 可扩展方向
这套系统的可扩展空间其实挺大。比如每次出入库都必须手工填报,比较麻烦,以后可以对接手持扫码枪,直接扫物资条码完成出入库。再比如库存低于安全线时可以增加消息推送模块,接入企业微信或者短信。统计报表也可以做得更直观,引入图表库,不过EasyUI的生态不足,到了这一步建议换前端框架。还有一个比较实用的方向是增加“物资批次”的概念,因为农药、化肥保质期很重要,需要批次管理,这就需要在现有表结构上再增加batch表,出入库时绑定批次号。
我个人在实际开发中的体会是:这个项目真正有难度的地方不在某个框架的语法,而在于如果让仓管员愿意用、觉得用起来比本子方便。所以我在界面细节上花了不少心思,比如主动记录操作人、操作时间,表格默认按最近更新时间排序,搜索时支持物资名称的模糊匹配。这些体验优化比加一个花哨的功能更实在。如果你正在做类似的项目,一定要多问问实际使用的人他们的操作习惯,千万别在办公室里闭门造车。