1. 项目概述:一套农事管理系统到底管什么
在农业信息化项目里,农事管理系统属于出现频率极高的那一类。我最近给一个种植合作社做技术方案时,对方第一诉求就是“把每天的农事活动记录下来,年底能算清楚每个地块的成本”。这类需求在国内农业园区、家庭农场、农资门店里非常普遍,而且业务逻辑不算复杂,特别适合用 SpringBoot + Vue3 + MyBatis 这套前后端分离方案落地。
一套完整的农事管理系统,核心是围绕“地块、作物、人、农资、时间”这五要素做串联。功能上通常包含基础信息维护(地块档案、作物品种、员工账号)、农事记录(种植、施肥、施药、灌溉、采收)、农资管理(入库、出库、库存预警)、农情记录(病虫害、异常天气)、统计报表(按地块/作物/时间的用工与农资成本)。听起来功能很多,但实际上每个模块的数据关系都不深,用关系型数据库存表、用 Vue 组件做表单和列表,开发起来非常顺手。
适配的人群也很明确:如果你是刚学完 Java 后端和 Vue 前端的开发者,想找一个综合度适中的实战项目练手,那这套系统的模块划分、接口设计、权限控制都值得参考;如果你在农业公司或者涉农软件公司工作,需要快速搭一套内部管理工具,那这套方案的架构和代码可以直接复用,再根据业务微调字段就行。相比纯电商、纯 OA 系统,农事管理系统业务术语多、行业特征强,写简历和做项目展示时辨识度也更高。
我在这篇文章里,不打算只贴源码结构,而是把“为什么这么设计”也讲清楚。从数据库建模、后端接口分层,到前端 Vue3 的组合式 API 写法、部署时的常见问题,全部过一遍。看完之后,你自己复刻一套同类系统,或者在现有源码上改业务,都会轻松很多。
2. 架构设计与关键选型:为什么是这套组合
2.1 技术栈的取舍逻辑
先回答一个很多人纠结的问题:SpringBoot、Vue3、MyBatis 这个组合,现在还算不算主流?
算。尤其是涉农类管理系统,甲方往往不追求极致性能,更看重系统易用、好维护、代码有资料可查。SpringBoot 自带内嵌服务器,配置简化到近乎“开箱即用”,一个 jar 包就能跑起来;MyBatis 保留了手写 SQL 的灵活性,遇到复杂的多表统计报表,可以精确控制查询逻辑,不会像 JPA 那样在性能和可读性之间纠结;Vue3 配合 Composition API 做中后台页面非常舒服,表格、表单、弹窗这些高频组件写起来比 Vue2 的 Options API 更清晰。
数据库选 MySQL 的理由更直观:免费、稳定、运维资料多。农场或者合作社如果要招第三方维护,MySQL 的门槛比 PostgreSQL 低很多。实际项目中我还见过直接用 MariaDB 的,兼容性也没问题,但默认推荐 MySQL 8.x。
前端工程化工具选 Vite 而不是 Vue CLI,主要看重启动速度和依赖安装体验。Vite 基于 esbuild 预构建依赖,项目稍微大一点,热更新速度优势非常明显。组件库方面,我习惯用 Element Plus,表格、表单、日期选择器、树形控件基本都是现成的,对农事记录这种“表单密集型”应用特别友好。
2.2 前后端分离的职责划分
前后端分离在这个项目里的意义,不只是“代码分两个仓库”,更重要的是请求链路的清晰化。前端只负责渲染和交互,所有数据都通过 HTTP 接口获取;后端只暴露 JSON 数据接口,不关心页面长什么样。
项目里的工程结构大致是这样:
farm-manage ├── backend # SpringBoot 后端 │ ├── controller # 接收请求,参数校验 │ ├── service # 业务逻辑层 │ ├── mapper # MyBatis 数据访问层 │ ├── entity # 数据库实体 │ ├── dto # 请求/响应参数封装 │ ├── config # 跨域、拦截器、全局异常处理 │ └── utils # JWT、日期处理等工具类 └── frontend # Vue3 前端 ├── src │ ├── api # 接口请求封装 │ ├── views # 页面组件 │ ├── components # 通用组件 │ ├── router # 路由配置 │ ├── store # Pinia 状态管理 │ └── utils # 请求拦截、权限判断后端统一返回Result<T>结构体,包含 code、message、data 三个字段。前端 axios 拦截器处理 token 附加和错误提示,后端拦截器统一校验 JWT。这样每新增一个功能,两边只需要按照约定格式写接口和调用,不用反复沟通数据格式。
2.3 权限模型设计
农事系统的用户角色一般不会太复杂,我常做的有三种:管理员、农场员工、普通查看者。管理员可以管理地块、员工、农资、查看所有报表;农场员工能够录入农事记录、领用农资;查看者只读,适合给公司老板或者合作农户开账号。
权限校验做在拦截器里,用一个注解标记接口需要的角色,比如@RequireRole("ADMIN"),拦截器读取 JWT 里的角色字段做判断。不用引入专门的权限框架,因为系统的角色和权限点很少,引入太重反而增加学习成本。
3. 数据库建模:一张表怎么设计,才能既好查又不出错
3.1 核心表关系
一套农事管理系统的表通常控制在 8 到 12 张以内,没必要过度设计。我最常用的核心表如下:
| 表名 | 作用 | 关键字段 |
|---|---|---|
sys_user | 用户账号 | id, username, password, real_name, role |
land_block | 地块档案 | id, block_name, area, soil_type, responsible_person |
crop_info | 作物品种 | id, crop_name, growth_days, remarks |
farm_record | 农事记录主表 | id, block_id, crop_id, record_type, work_date, worker, cost |
record_detail | 农事记录明细 | id, record_id, material_id, quantity |
material_info | 农资档案 | id, material_name, spec, unit, stock_quantity |
material_in | 农资入库单 | id, material_id, quantity, price, in_date, supplier |
material_out | 农资出库单 | id, material_id, quantity, out_date, use_block |
pest_record | 病虫害记录 | id, block_id, pest_name, level, discover_date, solution |
operation_log | 操作日志 | id, user_id, action, detail, create_time |
其中最关键的是farm_record和record_detail这对主从表结构。一条“施肥记录”会包含施了什么肥、每亩用量多少,而多种肥料可能在同一次农事操作中出现,所以用明细表存储农资消耗,方便后续统计成本。主表存公共信息,明细表存多行条目,这种设计也符合业务习惯。
3.2 字段设计的几个细节
地块表里的soil_type推荐用字典值而不是自由文本。虽然“沙土”“黏土”“壤土”看上去只是几个字,但在报表筛选和前端下拉框里,自由文本会导致同一土质出现多种写法,统计结果出现偏差。
农事记录表里的work_date建议用 date 类型,不要用 datetime。农事记录按天粒度记录足够,如果我们精确到时分秒,不仅录入麻烦,而且按日分组查询还要额外写转换函数。真正需要精确时间的是操作日志,那里用 datetime。
农资库存字段stock_quantity不要只依赖前端计算。入库增加、出库减少都必须放在后端事务里做,假如前端拿到库存后直接展示,多人同时操作时会出现库存不一致。在 MySQL 中,扣减库存的 update 语句最好带上库存充足的判断,比如:
UPDATE material_info SET stock_quantity = stock_quantity - #{quantity} WHERE id = #{materialId} AND stock_quantity >= #{quantity};这条 SQL 利用数据库行锁保证原子性,比“先查后减再更新”安全很多。
3.3 索引与冗余字段
farm_record表最常见的查询是按地块和时间范围过滤。我建的索引组合是(block_id, work_date),这样“某地块某年所有农事记录”的查询走覆盖索引,速度非常快。区块数据到百万级别前,这种索引完全够用。
另外,我在farm_record表里冗余了block_name和crop_name这种冗余字段。虽然三范式要求减少冗余,但在农事系统这种读多写少的场景,冗余字段能帮我们减少一次表和表的关联查询。列表页展示时直接取冗余名称,不 join,接口响应也能快不少。
4. 核心模块实现:农事记录、农资库存与预警
4.1 农事记录模块的接口设计
农事记录模块是整个系统的核心,相关的接口一般有新增、分页查询、详情、删除、按地块统计成本。用一个简单的分页查询接口示例,可以看清整个后端链路。
Controller 层只做参数接收和结果返回:
@RestController @RequestMapping("/api/farm/record") public class FarmRecordController { @Resource private FarmRecordService farmRecordService; @PostMapping("/page") public Result<PageResult<FarmRecordVO>> page(@RequestBody FarmRecordQuery query) { PageResult<FarmRecordVO> result = farmRecordService.queryPage(query); return Result.success(result); } @PostMapping("/save") public Result<Void> save(@RequestBody @Valid FarmRecordSaveDTO dto) { farmRecordService.saveRecord(dto); return Result.success(); } }Service 层处理明细和主表的写入,使用@Transactional保证主表和明细要么都成功,要么都失败:
@Override @Transactional(rollbackFor = Exception.class) public void saveRecord(FarmRecordSaveDTO dto) { FarmRecord record = new FarmRecord(); BeanUtils.copyProperties(dto, record); farmRecordMapper.insert(record); if (CollectionUtils.isNotEmpty(dto.getDetails())) { for (RecordDetail detail : dto.getDetails()) { detail.setRecordId(record.getId()); recordDetailMapper.insert(detail); } } }前端调用时,用axios封装请求方法:
export function getRecordPage(data: RecordQuery) { return request.post('/farm/record/page', data) } export function saveRecord(data: RecordSaveDTO) { return request.post('/farm/record/save', data) }列表页的查询条件建议至少包含:地块(下拉选择)、农事类型(下拉)、时间段(日期范围)。这三个条件基本覆盖了农场日常管理的常见筛选项,再加一个关键词搜索冗余字段就行。
4.2 农资库存模块的事务处理
农资库存业务比较特殊,容易出“脏数据”问题。比如同一批农药,一边领用出库,一边采购入库,如果不在事务里操作,库存就会出现负数或者虚高。
我平时把入库和出库做成两个独立的 Service 方法,各自打开事务:
@Transactional(rollbackFor = Exception.class) public void inbound(MaterialInDTO dto) { materialMapper.increaseStock(dto.getMaterialId(), dto.getQuantity()); materialInMapper.insert(dto); } @Transactional(rollbackFor = Exception.class) public void outbound(MaterialOutDTO dto) { int result = materialMapper.decreaseStockWithCheck(dto.getMaterialId(), dto.getQuantity()); if (result == 0) { throw new BizException("库存不足,无法出库"); } materialOutMapper.insert(dto); }这里的decreaseStockWithCheck就是前面提到的那条带库存判断的 SQL。这比“先查出库存量,再在 Java 里判断、再 update”更可靠,避免了并发下的超卖问题。
前端农资模块的页面上,库存列表需要显示“当前库存”和“单位”,最好再加一个低库存阈值,这样库存不足时表格的数值变成红色提示。
4.3 农情预警:用定时任务实现简易自动提醒
很多系统把“预警”做得太重,对农事项目来说,简单的规则提醒反而更实用。我常用的预警包括:库存低于阈值、连续 7 天无农事记录、病虫害记录超过一定次数。
实现方式是在 SpringBoot 里加一个@Scheduled定时任务,每天定时扫描数据库:
@Component public class WarnTask { @Resource private MaterialMapper materialMapper; @Resource private FarmRecordMapper farmRecordMapper; @Scheduled(cron = "0 0 8 * * ?") public void stockWarn() { List<MaterialInfo> lowStockList = materialMapper.selectLowStock(); for (MaterialInfo info : lowStockList) { // 生成预警消息,保存到 warn_message 表 } } @Scheduled(cron = "0 30 8 * * ?") public void recordWarn() { List<Long> blockIds = farmRecordMapper.selectNoRecordBlocks(7); // 按地块生成预警 } }预警功能并不复杂,关键是规则要明确,不能“什么都提醒”,否则时间一长就变成骚扰。库存阈值在农资档案表里加了safe_stock字段,让管理员自己设定,更符合实际场景。
4.4 统计报表:SQL 直接聚合还是后端内存聚合
统计报表是农事系统的高频需求,通常包括“按月份统计农事次数”“按地块统计农资成本”“按作物统计产量”。如果用户只查一年的数据,数据量不大,用 SQL 聚合就可以,查询逻辑简单且效率高。
比较典型的语句:
SELECT DATE_FORMAT(work_date, '%Y-%m') AS month, COUNT(*) AS record_count FROM farm_record WHERE work_date BETWEEN #{start} AND #{end} GROUP BY DATE_FORMAT(work_date, '%Y-%m') ORDER BY month;前端拿到结果后,直接用 ECharts 的折线图或者柱状图展示。如果分组逻辑特别复杂,比如同时按地块和月份分组,再考虑在 Java 内存里做二次聚合。这里建议遵循一个原则:第一版先让 SQL 完成 90% 的统计,剩余复杂计算再交给代码,不要一开始就把所有数据查出来在内存里算。
5. 开发环境搭建与部署实战
5.1 本地开发环境准备
这套系统本地启动需要的软件比较常规:
| 软件 | 版本建议 | 用途 |
|---|---|---|
| JDK | 17 及以上 | 运行 SpringBoot |
| Maven | 3.8+ | 依赖管理、后端构建 |
| Node.js | 18 LTS 以上 | 运行前端工程 |
| MySQL | 8.0+ | 数据库 |
| Redis | 可选 | 存储验证码或会话,本项目可不依赖 |
后端application.yml里的数据库连接配置要注意时区参数:
spring: datasource: url: jdbc:mysql://localhost:3306/farm_manage?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.DriverMySQL 8 的驱动类是com.mysql.cj.jdbc.Driver,不是早期的com.mysql.jdbc.Driver,不少新人在这里报错,直接换成 8 的驱动类即可。
前端启动进入项目目录后执行:
npm install npm run dev默认启动端口设在 5173,后端设在 8080。前后端本地联调时,我在前端.env.development文件里配置了代理:
VITE_API_BASE_URL=/api同时在vite.config.ts里设置 proxy,把/api转发到后端地址:
server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }这样前端页面里请求/api/farm/record/page,实际转发到http://localhost:8080/api/farm/record/page,避免跨域。
5.2 生产环境部署步骤
生产部署推荐前后端独立部署。后端编译打包:
mvn clean package -DskipTests生成的 jar 包用java -jar farm-manage.jar启动。如果机器内存不大,可以加-Xms256m -Xmx512m参数限制占用。
前端先构建:
npm run build构建产物在dist目录。然后把这个目录上传到服务器,配置 Nginx 静态文件服务和接口反向代理:
server { listen 80; server_name yourdomain.com; root /opt/farm-frontend/dist; index 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; } location / { try_files $uri $uri/ /index.html; } }注意location /api/里代理转发时,路径保持完整,Nginx 会把/api/farm/record/page原样转发到后端,后端 Controller 中的@RequestMapping("/api/farm/record")才能正确匹配。我在做历史项目时遇到过有人把/api给去掉了,结果后端 404,排查半天才发现是路径前缀问题。
5.3 使用 Docker 简化部署
如果服务器环境复杂,比如一台机器上要部署多个 Java 项目,推荐用 Docker。后端镜像的 Dockerfile 很简洁:
FROM openjdk:17-jdk-slim COPY farm-manage.jar /app/farm-manage.jar WORKDIR /app EXPOSE 8080 ENTRYPOINT ["java", "-jar", "farm-manage.jar"]前端用 Nginx 镜像配合上面的配置即可。用 Docker 的好处是环境隔离,避免服务器上 Node、JDK、MySQL 版本互相干扰,而且回滚版本只需要切换镜像标签。
6. 常见问题排查与避坑建议
6.1 高频问题速查表
我在复现、调优这类系统时,整理了一份高频问题表,几乎每次都能用到:
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 前端请求接口报 404 | 代理路径前缀和后端 Controller 路径不一致 | 检查 Nginx 或 Vite 的 location 是否保留了/api |
| 后端报日期转换错误 | 前端传了String,后端期望Date | 在 DTO 字段上加@JsonFormat(pattern = "yyyy-MM-dd") |
| MyBatis 查询返回字段为 null | 数据库字段下划线,实体属性驼峰,未开启驼峰映射 | application.yml配置map-underscore-to-camel-case: true |
| 接口 401 循环跳转 | 前端 axios 拦截器对 401 处理不当,一直重复请求 | 拦截 401 时只跳转一次登录页,避免请求重新发 |
| 农资库存出现负数 | 出库未做库存检查 | 将 update 带stock_quantity >= #{quantity}条件,并开启事务 |
| 数据接口响应慢 | 列表页大量关联查询,未走索引 | 检查farm_record.block_id是否有索引,必要时增加冗余字段 |
6.2 时区问题
农事管理系统里的日期很容易踩时区的坑。后端服务器默认时区是 UTC,MySQL 也是 UTC 时,前端用户看到的日期会比实际情况晚 8 小时。一个稳妥的做法是在 JDBC 连接串明确写serverTimezone=Asia/Shanghai,同时后端 Jackson 序列化日期时,统一格式化:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai前端展示时,对后端返回的时间字符串不要再做new Date()转换并叠加本地时区,直接用字符串展示。免得用户在浏览器里看到的日期莫名其妙又多了一天。
6.3 JWT 登录与权限踩坑
登录模块用的是 JWT,令牌生成时把用户 id 和角色放进载荷,前端存在 localStorage。后端拦截器每次请求读取请求头里的Authorization字段,解析后把用户信息放入ThreadLocal。
踩过的一个坑:前端 axios 响应拦截器在收到 401 时,如果不清除旧的 token 并跳转登录页,而是直接刷新当前页面,页面会循环刷新。我的处理方式是:
http.interceptors.response.use( response => response.data, error => { if (error.response?.status === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(error) } )这里要避免在跳转登录页时带上redirect参数后再次触发请求。另外,token 建议设置 12 小时过期,太短会让农忙时节忘密码的老用户体验很差,太长又带来安全隐患,12 小时是平衡点。
6.4 大列表分页的隐藏性能问题
农事记录一旦录入多了,分页查询也要注意性能。MyBatis 分页用 PageHelper 时,count 查询会在每张关联表上都执行一次,效率并不高。我后来改成在 Service 层先查询主表数据,再根据主表 id 批量查明细,减少 join。
首页的最近农事动态不展示太多列,只展示地块名、农事类型、日期、操作人,这种轻度查询走默认索引即可,不要一上来全字段查询。给前端返回的 VO 和数据库的 entity 尽量分开,避免把内部字段全部暴露出去,也方便接口裁剪。
7. 二次开发思路:这套源码还能怎么扩展
农事管理系统做完基础版本之后,扩展方向很多,我见过比较有代表性的三个方向。
第一个方向是接入物联网设备数据。地块里部署的温湿度传感器、土壤墒情仪,通过 MQTT 上报到后台,在农事记录页旁边展示实时环境数据,形成“环境监测 + 农事操作”的闭环。这对大棚种植场景尤其有吸引力,客户会愿意为实时数据能力付更高的预算。
第二个方向是增加移动端适配。农场员工在田间地头录入农事信息,不可能扛着电脑,所以移动端或者移动优先的页面设计很重要。最省成本的方案是前端使用响应式布局,让现有 Vue3 页面在手机端勉强可用;体验更好的方案是用 uniapp 重写一套移动端,接口完全复用后端。
第三个方向是农资溯源与二维码结合。给每批农资入库时生成一个二维码,出库扫码记录去向,消费者扫描后可以看到农产品生产过程中用了哪些农资、什么时间用的、用量多少。这种溯源能力在高端农产品销售里很有价值,也可以作为系统的增值模块。
这些扩展都不会推翻现有架构,因为前后端分离的本质就是业务逻辑和数据接口稳定,表现层和新增接入层都可以灵活变化。
我个人在实际操作中的体会是,农事管理系统这类项目,技术难度不高,难在把农业术语和业务流程理解透,然后转化为表结构和代码逻辑。代码可以复制,但对业务的思考没法复制。你如果用这套源码做练习,建议不看别人的代码自己先画一遍数据模型,再开始写接口,当你发现自己的设计和主流源码的思路一致时,这套系统才算真的吃透了。
最后再分享一个小技巧:写农事记录的日期筛选时,前端用日期范围组件,组件默认会把开始日期设置为当天 00:00:00,结束日期设置为当天 00:00:00,这样会导致当天的记录被过滤掉。很多人第一次开发都会踩到。后端的查询条件最好把结束日期加一天,或者前端把结束日期设置为 23:59:59。这类细节往往比那些复杂的架构设计更容易决定一个项目体验的好坏。