
从农机站的仓库里回来那天我手里攥着十几张写满出入库记录的纸质台账心里只有一个念头这套农业物资管理得用系统做。当时正好接了一个开发任务要做一套基于 javaspringbooteasyuihtmlmavenmysql 的农业物资管理系统需求不复杂但业务细节极其琐碎——种子、化肥、农药、农机配件的出入库供应商往来库存预警月底盘点。这篇文章就是那次完整开发过程的复盘我会把技术选型、数据库设计、后端业务逻辑、前端EasyUI整合、部署上线中所有能说的细节都铺开包括踩过的坑。如果你正打算做一个类似的管理系统或者只是想把Spring Boot和EasyUI这套组合跑通这篇东西应该能让你少走不少弯路。1. 立项背景农业物资管理的业务痛点与系统目标1.1 手工台账到底有多痛农业物资管理和一般商品库存管理最大的不同在于物资种类杂、计量单位乱、季节性波动强。种子按袋算化肥按吨算农药按瓶算农机配件更是按“个”算同一个玉米种子的不同批次还对应不同的种植区域。很多乡镇农技站和中小型农业合作社过去就是靠一张Excel加一个本子撑着。我调研时看到最典型的场景仓管员早上发出去两袋化肥晚上在本子上记一笔月末想盘点发现本子上的数字和实际库存对不上因为中间还有过临时借调、损耗和退库。更要命的是库存低于安全线没人知道等农户来买才发现没货供应商又得临时送货一来一回耽误农时。这些痛点归结起来就是物资台账不透明、出入库记录不可追溯、库存预警缺失、统计报表靠人工拼凑。系统的目标不是搞一个“全智能数字化平台”而是先把账记清楚把流程固化下来让每个操作都有记录每次进出都有凭证。1.2 业务角色与功能边界系统定位为内部管理系统用户集中在管理员、仓管员和普通员工三个角色。管理员维护供应商、物资分类、用户账号查看全部报表仓管员负责入库、出库、盘点、调拨普通员工只能查询库存和申请领用。这个权限划分不需要做到特别细的粒度只要菜单级能控制住就行。功能边界上我一开始就和需求方确认了几条硬性规则入库必须有采购单号或者来源说明出库必须有领用人信息库存一旦出现负数系统直接拒绝操作。这几条规则看起来简单但在后续数据库设计和接口实现中直接决定了事务和唯一约束怎么加。2. 技术选型为什么是Spring Boot和EasyUI这套组合2.1 后端选型Spring Boot带来的开发效率提升关于后端框架争议从来都很大。有人上来就推荐Spring Cloud微服务但一个总预算可能只有几十人天的农业内部管理系统微服务完全是给自己找麻烦。Spring Boot最核心的价值在于“默认配置 内嵌容器”不需要再费劲配置Tomcat不用写一堆XMLmaven引入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的HTMLJS相对容易。一个项目的寿命往往取决于能否被后续维护的人看懂这一条对我来说比技术多新更重要。当然EasyUI也有明显的短板界面观感停留在2015年左右的风格复杂联动交互写起来很别扭。如果项目对UI美观和交互体验要求高那我不会推荐它。但就农业物资管理这种表单密集、逻辑固定的场景它反而是“杀鸡用牛刀正好”的选择。2.3 数据存储MySQL的适用性MySQL选用理由没什么好说的稳定、轻量、社区生态强。唯一要注意的是版本8.0之后认证插件默认是caching_sha2_password有些老版本的客户端连接会报SSL连接错误所以我在连接串里明确加了useSSLfalse和allowPublicKeyRetrievaltrue这一点在后文部署部分还会细讲。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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;之所以预留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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里我故意冗余了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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意这条流水表里存了业务发生前后的库存量这在排错的时候特别有用。要是有一天库存数据对不上只需要看流水里的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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.3.4.RELEASE/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.1.4/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.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我捕获了所有异常返回code500msg对应异常信息。但这里有个需要注意的细节不要把异常堆栈直接暴露给前端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;charsetUTF-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 iddg classeasyui-datagrid stylewidth: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.00000:00”这种带时区的格式。我在application.yml里统一配置了spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8第二是跨域问题。本地开发时前端在8080后端在9090直接用Ajax请求会跨域。我在config里写了一个CORS过滤器放行开发环境的跨域请求生产环境改成同源就把过滤器关掉。第三是EasyUI自带的属性覆盖问题。比如form表单自动校验是用validatebox如果input的class写成了easyui-textboxonChange事件会失效。这个属于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?useUnicodetruecharacterEncodingutf8useSSLfalseallowPublicKeyRetrievaltrueserverTimezoneAsia/Shanghai username: root password: 123456allowPublicKeyRetrievaltrue这个参数特别关键MySQL 8默认认证插件对某些客户端要求交换公钥不设置直接报错。第三是Maven依赖下载慢或者失败的问题。解决方案就是配置阿里云镜像在settings.xml里加mirror idaliyunmaven/id mirrorOfcentral/mirrorOf urlhttps://maven.aliyun.com/repository/central/url /mirror但注意只对central仓库配置镜像就够了不要用mirrorOf*,否则一些第三方仓库也会被强制走阿里云反而出问题。6.2 打包部署jar包方式运行与生产环境配置开发环境直接用idea运行部署时用Maven打包。我执行的是mvn clean package -Dmaven.test.skiptrue然后拿到target目录下的jar包复制到服务器java -jar agri-system-0.0.1.jar --spring.profiles.activeprod生产环境的application-prod.yml和本地配置最关键的区别是数据源密码。我建议生产中不要用明文密码至少用jasypt加密。当然这个系统如果内部使用实在嫌麻烦可以先把密码写进环境变量例如export DB_PASSWORDyourpassword然后在配置里写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.timezoneAsia/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表出入库时绑定批次号。我个人在实际开发中的体会是这个项目真正有难度的地方不在某个框架的语法而在于如果让仓管员愿意用、觉得用起来比本子方便。所以我在界面细节上花了不少心思比如主动记录操作人、操作时间表格默认按最近更新时间排序搜索时支持物资名称的模糊匹配。这些体验优化比加一个花哨的功能更实在。如果你正在做类似的项目一定要多问问实际使用的人他们的操作习惯千万别在办公室里闭门造车。