我刚把一个农事管理系统从零搭到上线,前后端分离,后端SpringBoot 2.7 + MyBatis,前端Vue3 + Element Plus,数据库选的MySQL 8.0。整个项目做下来,最有感触的一点是:农事管理看着是“传统行业”,但真下手做系统,踩的坑一点都不比互联网项目少,尤其是字段设计、权限粒度、农事记录的灵活度这几块,稍不注意就会返工。这篇就把完整的设计思路、核心实现、扣细节的地方一次讲清楚,适合正在做农业信息化项目、或者想拿SpringBoot+Vue3练手但不想做“图书管理系统”这类 demo 的朋友参考。
1. 项目整体设计与技术选型
1.1 为什么是前后端分离 + SpringBoot + Vue3
先说结论:农事管理系统本质上是一个“多角色、多流程、数据实时性要求高”的业务系统,前后端分离是当前最适合的架构。前端负责交互和展示,后端只提供API,两者通过JSON通信,好处是后续要接小程序、App、或者给第三方开放接口时,后端完全不用动。
SpringBoot选2.7而不是3.x,原因很实际:2.7生态最稳,MyBatis、PageHelper、各种第三方starter兼容性都验证过,JDK8跑起来毫无压力。如果用SpringBoot 3.x,JDK最低要求17,很多服务器环境还得升级,不划算。
Vue3 + Element Plus是当前前端的主流组合,Composition API写业务逻辑比Options API清爽很多,尤其是农事记录这种“表单字段动态变化”的场景,ref、reactive配合v-model处理起来非常直接。
MyBatis选它不选MyBatis-Plus,我承认是有点“自找麻烦”,但好处是SQL完全可控。农事系统里有大量多表关联查询和统计报表,MyBatis的XML里写原生SQL,调优和理解成本都更低。MyBatis-Plus虽然开发快,但遇到复杂JOIN和动态条件拼装,反而要绕很多弯。
1.2 系统核心角色与功能模块拆解
农事管理系统不是简单的“记录种了什么”,我在梳理需求时把它拆成了五个核心角色和六大模块。
角色这块,系统里有管理员、农艺师、基地操作员、仓库管理员、普通农户。管理员管人和配置,农艺师制定种植方案和农事计划,操作员执行并上报,仓管管农资出入库,农户只读自己地块相关的数据。角色不同,菜单和按钮权限都不同。
功能模块我规划为:
- 地块档案管理:维护基地、地块的基础信息,包括面积、土壤类型、灌溉条件、当前种植状态等;
- 种植计划管理:按地块制定种植计划,包括作物品种、预计播种时间、预计收获时间、负责人;
- 农事作业记录:记录播种、施肥、浇水、打药、除草、收获等日常农事操作,这是数据量最大、最重要的一块;
- 农资管理:农药、化肥、种子的入库、出库、库存预警;
- 采收与库存管理:记录每次采收量、质量等级、入库去向;
- 数据看板:用图表展示各地块的种植情况、农事频次、投入品使用趋势等。
在实际开发中,我没有一上来就写代码,而是先用两周梳理流程,找实际做农业的老师傅聊了聊,发现他们最痛的不是“记不住种了什么”,而是“没法定量追溯”。所以设计上每个地块的每笔农事操作都有完整的时间、投入品用量、操作人信息,后续追溯时可以精确到“某天某人在这块地用了多少毫升某农药”。
1.3 关键非功能性设计
除了业务功能,我额外做了几个非功能性的设计,很多人做项目容易忽略:
- 数据字典化:地块状态、作业类型、投入品类型等全部做成数据字典,前端下拉选项从接口动态获取,避免硬编码。
- 操作日志:关键操作(删除、修改、审批)都记录操作日志,含操作人、时间、IP、操作内容。
- 逻辑删除:所有业务表都带
deleted字段,采用逻辑删除而不是物理删除,保护数据可追溯。 - 接口统一返回结构:
Result<T>封装,含code、message、data三个字段。状态码统一,前端可以根据code做全局拦截。
设计这块我的经验是:先把字典、返回结构、异常处理、权限模型这“四梁八柱”搭好,后面加功能会很顺手。否则边写边补,代码会越写越乱。
2. 数据库设计:农事系统的表结构到底怎么建
2.1 核心表设计与关系
数据库设计是整个项目的基础,我前前后后改了三版。第一版字段太少,无法支撑统计;第二版过度设计,把地块和传感器设备都关联起来,逼得自己写一堆没用的代码;第三版才稳定下来,核心表一共12张:
sys_user:用户表sys_role、sys_user_role:角色及用户角色关联farm_base:基地表farm_land:地块表plant_plan:种植计划表work_order:农事作业记录表material_info:农资信息表material_stock:库存表material_inout_record:出入库流水表harvest_record:采收记录dict_type、dict_data:数据字典相关
地块和基地是父子关系,种植计划挂在具体地块上,农事作业严格关联地块和种植计划。农资库存独立管理,但每次农事作业可以引用农资记录,这样就能算出“某块地用了多少农资”。
这里要特别注意:农事作业记录表的设计不能太死板。农事类型有播种、施肥、浇水、打药、除草,每个类型的字段差异很大。我采用“主表 + 扩展字段”的方式,主表存公共字段(地块ID、作业类型、作业时间、操作人、备注),另加几个可空字段(农资ID、用量、面积、作业方式),如果后续需要更复杂的自定义字段,再扩展JSON字段。
为了让你直观感受设计思路,我贴一下work_order的核心建表语句:
CREATE TABLE `work_order` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', `land_id` bigint NOT NULL COMMENT '地块ID', `plan_id` bigint DEFAULT NULL COMMENT '关联种植计划ID', `order_type` varchar(32) NOT NULL COMMENT '作业类型:seed/fertilize/water/pesticide/weed/harvest', `work_date` date NOT NULL COMMENT '作业日期', `work_time` varchar(64) DEFAULT NULL COMMENT '作业时段:上午/下午/全天', `operator_id` bigint NOT NULL COMMENT '操作人ID', `material_id` bigint DEFAULT NULL COMMENT '关联农资ID(施肥/打药时必填)', `dosage` decimal(10,2) DEFAULT NULL COMMENT '用量(kg/亩 或 毫升/亩)', `work_area` decimal(10,2) DEFAULT NULL COMMENT '作业面积(亩)', `weather` varchar(32) DEFAULT NULL COMMENT '天气情况', `remark` varchar(500) DEFAULT NULL COMMENT '备注', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `deleted` tinyint NOT NULL DEFAULT '0' COMMENT '逻辑删除 0-正常 1-删除', PRIMARY KEY (`id`), KEY `idx_land_id` (`land_id`), KEY `idx_order_type` (`order_type`), KEY `idx_work_date` (`work_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='农事作业记录表';三个索引是必要的,因为后续统计报表基本围绕“地块+时间+作业类型”三个维度来查。如果漏掉联合索引,数据量到几万条时查询就会变慢。
2.2 可追溯性与数据一致性设计
农事系统最核心的诉求是可追溯,也就是“每一批农产品来自哪块地、用过什么药、什么时候收的”。为了实现这个目标,我做了一个关键设计:批次号机制。
每次播种时系统生成一个批次号(如A-2025-03-001),之后该地块所有农事操作、施肥打药、采收都关联这个批次。采收记录里带上批次号,后续如果做农产品溯源,扫一下二维码就能查出整个生长周期的农事记录。这个功能在需求评审时用户特别强调,做进去之后项目价值提升了一个档次。
数据一致性方面,农资库存需要保证不超卖、不超领。我用的方案是:出库时开启事务,先检查库存量是否充足,充足则扣减库存并写入库流水,不充足则抛异常回滚。订单量不大,不需要分布式锁,数据库的行锁足够。
-- 扣减库存时使用乐观的库存校验 UPDATE material_stock SET stock_quantity = stock_quantity - #{qty}, update_time = NOW() WHERE material_id = #{materialId} AND stock_quantity >= #{qty};如果UPDATE影响行数为0,说明库存不足,业务层直接抛异常。这个写法比“先SELECT再UPDATE”更安全,因为它在一条SQL里完成了检查和扣减,避免并发下超卖。
2.3 数据库设计的几个教训
这里单独说几个我踩过的坑,你们设计表的时候能避开就避开:
utf8mb4是必须的,MySQL的utf8其实不是真正的UTF-8,存不了emoji和一些生僻汉字,农忙备注里经常有人打特殊符号,用utf8mb4最保险。- 时间字段必须用
datetime或timestamp并设置默认值,避免代码里手动new Date()导致时区不一致。 - 不要用
name做唯一索引。我一开始给地块名称加了唯一索引,结果发现不同基地下可以有同名地块,后来改成联合唯一索引(base_id + name)。 - 金额和用量字段用
decimal(10,2),别用float/double,否则累计统计会出现精度误差。 - 所有表带上
create_time、update_time、deleted三个基础字段,这是通用约定,虽然写起来啰嗦,但后面好处很大。
3. 后端核心实现:SpringBoot + MyBatis的正确打开方式
3.1 项目结构:约定优于配置
后端包结构我按“业务模块”划分,而不是按“技术类型”划分,这样多人协作时不容易冲突:
com.farm.system ├── common // 通用类,Result、异常、工具类 ├── config // 配置类,WebMvc、拦截器、跨域等 ├── security // JWT、权限相关 ├── module │ ├── land // 地块模块:controller/service/mapper │ ├── plan // 种植计划模块 │ ├── work // 农事作业模块 │ ├── material // 农资模块 │ └── dashboard // 数据看板模块 └── utils // 工具类这样的好处是,新来的同事想找“农事作业的接口”,直接进module/work目录就行,不用在一个巨大的controller包下翻几百个文件。
3.2 JWT登录鉴权:从Filter到Interceptor
农事系统不需要像互联网应用那样支持万人并发,但权限控制必须严格。登录鉴权我用的方案是:Login接口签发JWT Token,前端存在localStorage,每次请求放在Authorization头里,后端用拦截器校验Token合法性。
@Component public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录接口和静态资源 if (request.getRequestURI().contains("/auth/login")) { return true; } String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { String jwtToken = token.substring(7); try { Claims claims = Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(jwtToken) .getBody(); request.setAttribute("userId", claims.get("userId")); request.setAttribute("roleCode", claims.get("roleCode")); return true; } catch (Exception e) { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"登录已过期\"}"); return false; } } response.setStatus(401); return false; } }Token里我存了userId和roleCode,这样在后续业务中不需要频繁查用户表就知道当前角色。不过要注意一点:角色变更后Token里的角色信息不会实时更新,如果系统里有“修改角色后强制下线”的需求,需要额外做Token版本控制。我在项目里给用户表加了一个token_version字段,每次修改角色就自增,校验时比对版本号,简单有效。
按钮级权限我使用自定义注解@RequirePermission("land:add")+ AOP实现,权限码存在sys_role_menu表里。相比Spring Security的@PreAuthorize,这种方式更轻量,写起来也更直观。
3.3 MyBatis动态SQL:多条件查询的正确写法
农事记录查询场景很典型:前端要支持地块筛选、作业类型筛选、时间范围筛选、操作人筛选,而且这些条件任意组合、可选可不填。这种场景用MyBatis的动态SQL最合适。
<select id="selectWorkOrderPage" resultType="com.farm.module.work.dto.WorkOrderVO"> SELECT w.id, w.work_date, w.order_type, w.dosage, w.work_area, l.land_name, b.base_name, u.real_name AS operator_name, m.material_name FROM work_order w LEFT JOIN farm_land l ON w.land_id = l.id AND l.deleted = 0 LEFT JOIN farm_base b ON l.base_id = b.id AND b.deleted = 0 LEFT JOIN sys_user u ON w.operator_id = u.id AND u.deleted = 0 LEFT JOIN material_info m ON w.material_id = m.id AND m.deleted = 0 <where> w.deleted = 0 <if test="landId != null and landId != ''"> AND w.land_id = #{landId} </if> <if test="orderType != null and orderType != ''"> AND w.order_type = #{orderType} </if> <if test="startDate != null and startDate != ''"> AND w.work_date >= #{startDate} </if> <if test="endDate != null and endDate != ''"> AND w.work_date <= #{endDate} </if> <if test="operatorId != null and operatorId != ''"> AND w.operator_id = #{operatorId} </if> </where> ORDER BY w.work_date DESC, w.create_time DESC </select>这里几个关键点:
- 所有关联表都加
deleted = 0条件,防止逻辑删除的数据干扰查询结果; - 时间范围的比较用的是
>=和<=,XML里不能直接用>和<,否则解析报错; - 排序用
work_date和create_time双字段,确保同一天多条记录的顺序稳定,分页时不会出现重复数据。
分页我用的PageHelper,搭配SpringBoot非常简单,只要在Mapper查询前调用PageHelper.startPage(pageNum, pageSize),返回结果就是PageInfo。不过需要注意:PageHelper只对紧跟其后的第一条查询生效,如果中间不小心多执行了一条SQL,分页就失效了。
3.4 事务控制:哪些场景必须加@Transactional
农事系统里的事务控制,我总结下来有三个典型场景:
- 农资出库:扣减库存+写入库流水,必须同一个事务,否则可能出现流水有了但库存没扣的情况;
- 创建种植计划:插入计划+初始化该地块状态,两步要同时成功或失败;
- 批量导入农事记录:Excel导入时逐条解析、逐条insert,中间任何一条失败都要回滚,避免“半截数据”入库;
做法上保持克制,只在真正需要的地方加@Transactional。一开始我有种“把所有Service方法都加上事务才安全”的错觉,结果系统在高频查询场景下性能受影响。事务会持有数据库连接,加多了等于白白占用连接池资源。
@Transactional默认只对RuntimeException回滚,像FileNotFoundException这种受检异常默认不会回滚。所以我统一封装了自定义异常BusinessException extends RuntimeException,业务出错时直接抛它,避免事务不生效的坑。
3.5 文件上传:种植图片和农事记录附件
农事记录经常需要上传现场照片,我实现了本地存储方案。配置上传路径、允许的图片格式、单文件大小限制(5MB内),前端用el-upload上传,后端接收后重命名为UUID防止重名,把访问路径存到数据库。
file: upload-dir: /data/farm/uploads access-prefix: /api/file/@PostMapping("/upload") public Result<String> upload(@RequestParam("file") MultipartFile file) { if (file.isEmpty()) { throw new BusinessException("上传文件不能为空"); } String originalFilename = file.getOriginalFilename(); String ext = originalFilename.substring(originalFilename.lastIndexOf(".")); if (!Arrays.asList(".jpg", ".jpeg", ".png", ".gif").contains(ext)) { throw new BusinessException("不支持的文件格式"); } String newFilename = UUID.randomUUID().toString().replace("-", "") + ext; String datePath = new SimpleDateFormat("yyyy/MM/dd").format(new Date()); File dir = new File(uploadDir + "/" + datePath); if (!dir.exists()) { dir.mkdirs(); } file.transferTo(new File(dir, newFilename)); String accessUrl = accessPrefix + datePath + "/" + newFilename; return Result.success(accessUrl); }按日期分目录的意义是防止单目录文件过多,Linux下文件系统在单目录文件数量超过几千时性能会明显下降。如果上传量更大,后续可以改成OSS,但业务没到那个规模之前,本地存储完全够用,还省成本。
4. 前端Vue3实现:从登录到数据看板
4.1 项目结构和路由设计
前端我用Vite构建,配合Vue Router 4 + Pinia + Element Plus + Axios + ECharts。
src/ ├── api/ // 接口请求封装 ├── assets/ // 静态资源 ├── components/ // 通用组件 ├── router/ // 路由配置 ├── stores/ // Pinia状态管理 ├── views/ // 页面视图 ├── utils/ // 工具类,axios实例、格式化等 └── App.vue路由设计上,我用了动态路由方案:登录成功后根据后端返回的权限码,前端动态addRoute,没有权限的菜单直接不注册。这样做的好处是用户访问未授权页面时会进入404而不是401,避免暴露系统页面结构。
// 路由守卫核心逻辑 router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (!token && to.path !== '/login') { next('/login'); return; } if (token && to.path === '/login') { next('/'); return; } next(); });4.2 Axios封装:统一处理Token和错误码
Axios封装是所有前端项目的基建,我做了三层处理:
- 请求拦截器:每次请求自动带上
Authorization: Bearer ${token}; - 响应拦截器:判断后端返回的
code,如果code为200则直接返回data,省去业务代码里反复写res.data.data的痛苦; - 全局错误处理:401跳转登录页,403弹出无权限提示,500弹出后端错误信息。
const service = axios.create({ baseURL: '/api', timeout: 15000 }); service.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); service.interceptors.response.use( response => { const res = response.data; if (res.code === 200) { return res.data; } else if (res.code === 401) { localStorage.removeItem('token'); router.push('/login'); return Promise.reject(new Error('登录已过期')); } else { ElMessage.error(res.message || '请求失败'); return Promise.reject(new Error(res.message)); } }, error => { ElMessage.error('网络异常,请稍后重试'); return Promise.reject(error); } );这一套封装好之后,业务代码里写请求非常简洁:
const list = await getWorkOrderList({ landId: 1, pageNum: 1, pageSize: 10 });4.3 农事日历:Vue3实现按日查看农事记录
这是整个前端我做得最得意也最费劲的功能。农事记录要按日历展示,类似GitHub的贡献图,每天显示当天发生的农事次数。用户点某一天,能弹出当天的农事记录列表。
实现方案是:后端的Dashboard接口返回本月每天的农事记录统计数,前端按月获取,用CSS Grid画日历网格,根据日期数据渲染背景色深浅,数值越高颜色越深。
// 核心逻辑:生成当月日历数据 function generateCalendar(year: number, month: number, records: Record<string, number>) { const firstDay = new Date(year, month - 1, 1); const startWeek = firstDay.getDay(); // 0-6 const daysInMonth = new Date(year, month, 0).getDate(); const cells = []; for (let i = 0; i < startWeek; i++) { cells.push(null); } for (let d = 1; d <= daysInMonth; d++) { const key = `${year}-${String(month).padStart(2, '0')}-${String(d).padStart(2, '0')}`; cells.push({ date: key, count: records[key] || 0 }); } return cells; }这个日历组件我重构了两次,主要问题是Date对象的月份从0开始导致边界bug,以及日历跨月时起始星期几的兼容。最终通过统一格式化工具函数解决,也提醒我在做日期相关的功能时,尽量所有地方共用同一个日期处理库(我用的是dayjs),避免每个组件各自处理格式导致风格混乱。
4.4 数据看板:ECharts统计展示
数据看板是给管理者看的,用来掌握整体生产情况。我做了四个图表:
- 各地块种植面积占比(饼图)
- 近6个月农事作业类型分布(堆叠柱状图)
- 农资库存预警列表(表格)
- 本月每日农事作业频次(折线图)
ECharts在Vue3里使用,需要注意图表必须等DOM渲染完成后再初始化,且组件销毁时记得dispose,否则会有内存泄漏。
import * as echarts from 'echarts'; onMounted(() => { chart = echarts.init(chartRef.value); window.addEventListener('resize', handleResize); }); onBeforeUnmount(() => { window.removeEventListener('resize', handleResize); chart?.dispose(); });如果项目只要一两个图表,不建议全量引入ECharts。import * as echarts from 'echarts'会把整个库打进去,首屏包体积增长不少。可以改成按需引入:只需要BarChart、PieChart、LineChart和自己用到的组件。我在项目里只引入了核心的组件,构建产物体积从1.2MB降到700KB左右。
4.5 移动端适配:农场现场用手机操作
农事的实际操作场景在田间地头,不太可能拿着电脑去操作,所以前端在PC端适配的基础上,做了简单的移动端适配。方案是:页面整体用响应式布局,表单页面在手机宽度下自动改为单列,表格用横向滚动,核心操作按钮固定在底部。
Element Plus本身提供了ResponsiveLayout相关的栅格系统,配合useMediaQuery做断点控制,覆盖绝大多数场景。如果后续要做得更专业,可以考虑单独开发移动端H5甚至微信小程序,但那是后话,当前阶段保证“手机上能看、能录、能提交”就够了。
5. 权限控制和安全管理
5.1 RBAC权限模型实现
农事管理系统涉及多角色,权限模型用的是经典的RBAC:用户关联角色、角色关联菜单权限。表结构是sys_user、sys_role、sys_user_role、sys_role_menu、sys_menu五张表。菜单表里的perms字段存权限标识符,比如land:add表示新增地块权限,land:edit表示编辑权限。
后端实现上,登录成功时一次性查出用户的全部权限标识符列表,存到Redis里(key为user:perms:{userId}),之后每次请求需要校验权限时,从Redis读取并比对。避免每次请求都去数据库查权限表,性能更好。
我提供一个简化版的权限校验注解:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RequirePermission { String value(); }@Aspect @Component public class PermissionAspect { @Around("@annotation(requirePermission)") public Object checkPermission(ProceedingJoinPoint joinPoint, RequirePermission requirePermission) throws Throwable { String perm = requirePermission.value(); // 从Redis获取当前用户权限列表 Set<String> perms = getCurrentUserPerms(); if (!perms.contains(perm)) { throw new BusinessException(403, "无权限操作"); } return joinPoint.proceed(); } }这种基于Redis的方案,权限变更不是实时生效的,最长有Redis过期时间的延迟。我们的场景里角色调整频率极低,这个延迟完全可接受。如果想实时生效,在权限变更时主动删除对应用户的Redis缓存即可。
5.2 数据权限:只能看自己负责的地块
这是我踩坑最深的一个点。角色权限是“功能级”的,但农事系统还有个“数据级”的需求:操作员只能看自己负责的地块,农艺师能看自己管的所有地块,管理员能看全部数据。
这个数据权限没有用第三方框架,我在核心查询中加了数据范围过滤:
- 用户表加
data_scope字段,取值有ALL(全部)、LAND(指定地块)、BASE(指定基地)、SELF(仅本人创建); - 查询地块列表时,根据
data_scope动态拼接SQL条件; - 如果
data_scope = LAND,再去查用户地块关联表sys_user_land,拿到用户负责的地块ID列表,用IN条件过滤。
<if test="dataScope == 'LAND'"> AND l.id IN (SELECT land_id FROM sys_user_land WHERE user_id = #{userId} AND deleted = 0) </if>数据权限的坑主要在于:代码审查时很难发现漏加条件。后来我从架构上做了改进,把所有核心业务表的查询入口都收拢到DataScopeService处理,新增查询时必须通过这个服务来获取过滤条件,从根上避免遗漏。
5.3 其他安全措施
安全这块,除了登录鉴权和权限控制,我还做了:
- 密码加密存储:使用
BCryptPasswordEncoder,每次登录时校验。绝对不要用MD5,因为彩虹表攻击太容易了; - 防SQL注入:MyBatis的
#{}预编译已经天然防注入,但要留意不要在SQL里拼接${}用户输入; - CSRF防护:前后端分离 + Token认证后,CSRF风险大幅降低,主要是保持不在URL里传Token、限制跨域来源;
- 敏感操作二次验证:删除地块、批量导入这类高风险操作,前端弹确认框,后端记录操作日志。
6. 常见问题与排查技巧实录
6.1 时间格式化的经典Bug
前端传的日期是"2025-03-15",后端接收的LocalDate显示为2025-03-15T00:00:00,前端页面展示成了2025-03-15T00:00:00.000Z,时间错乱了8小时。
原因是Jackson在序列化LocalDate到JSON时,默认格式是ISO标准带T。解决方式是在配置里指定统一的时间格式:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8同时,LocalDate建议在实体上加上@JsonFormat(pattern = "yyyy-MM-dd")注解。这里要强调:前后端所有时间字段的传递格式必须统一约定,否则每个接口都在各转各的,调试心态会崩。
6.2 MyBatis驼峰映射不生效
数据库字段是create_time,实体类属性是createTime,查询出来createTime却一直是null。这个问题还不报错,特别隐蔽。
原因是MyBatis默认没有开启驼峰映射。在application.yml里加上:
mybatis: configuration: map-underscore-to-camel-case: true另外,如果XML里用resultType而不是resultMap,又设置了AS别名,字段映射规则会受此配置影响,建议统一改成建resultMap指定字段映射,一劳永逸,特别在关联查询多个表时能避免同名字段互相覆盖。
6.3 跨域配置的正确姿势
前后端分离部署在不同端口,必然遇到跨域问题。我用WebMvcConfigurer统一配置CORS:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(false) .maxAge(3600); } }这里有一个坑:如果你用了拦截器拦截OPTIONS预检请求,会直接返回401导致跨域失败。必须在拦截器里先放行OPTIONS请求:
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; }6.4 列表分页数据串了
用PageHelper时,如果startPage和后面的第一次查询之间夹了其他SQL,分页条件就会串到那条SQL上,导致返回结果不对甚至报错。
排查方式很简单:打开MyBatis的SQL日志,看打印出来的SQL是否真的带上了LIMIT,也能看出来是哪条SQL被分页了。我的习惯是把分页查询统一封装在Service层,确保PageHelper.startPage()紧跟着Mapper调用,中间不做任何多余操作。
6.5 大字段导出内存溢出
农事记录多时,Excel导出会一次性把所有记录查出来,几万条数据就把堆内存吃满。我的解决方式是导出时使用MyBatis游标查询,逐条写入Excel,避免把所有数据加载到内存。
@Select("SELECT * FROM work_order WHERE deleted = 0") Cursor<WorkOrder> selectAllForExport();配合EasyExcel的流式写入,即使是十万条记录,导出的内存占用也能控制在合理范围。如果实在记不住这个方案,记住一句话:任何“列表导出”功能,都不要用List接收全量数据,用流式或分批。
6.6 Vue3列表渲染卡顿
地块选择器上挂了几千条记录,下拉框一打开就卡。原因是Element Plus的el-select在下拉选项特别多时,一次性渲染所有选项会导致性能问题。
解决方案是在el-select里加了远程搜索,输入关键字后向后端请求匹配数据。这种方案在数据量大时是必须的,不要觉得“才几千条而已”,浏览器渲染几千个DOM节点加上响应式监听,卡顿是必然的。
7. 部署与运维实践
7.1 后端打包与启动
打包前建议把测试环境的配置单独放一个application-prod.yml,然后使用mvn clean package -DskipTests -Pprod生成可执行Jar包。
生产环境启动命令,这里有几个关键参数:
java -server -Xms256m -Xmx512m \ -Djava.security.egd=file:/dev/./urandom \ -jar farm-system.jar \ --spring.profiles.active=prod \ --server.port=8080-Djava.security.egd=file:/dev/./urandom这个参数必须加,否则Tomcat启动阶段会在获取安全随机数时阻塞,现场表现就是启动特别慢、感觉卡死一样。这个问题在很多生产环境出现过,我第一次遇到时排查了一下午。
7.2 Nginx配置前后端分离部署
前后端分离项目通常用Nginx把前端静态资源服务和后端API代理放在一起:
server { listen 80; server_name farm.example.com; # 前端Vue3构建后的dist目录 root /data/farm/dist; index index.html; # 处理Vue Router的history模式 location / { try_files $uri $uri/ /index.html; } # 后端API代理 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; proxy_set_header X-Forwarded-Proto $scheme; } # 上传图片访问 location /api/file/ { alias /data/farm/uploads/; } }try_files $uri $uri/ /index.html这段是history模式路由刷新不出404的关键,很多人部署Vue3时刷新页面404,多半就是漏了这句。
7.3 数据库定时备份
农事数据是生产数据,丢失后果严重。我先写了一个简单的备份Shell脚本,用mysqldump每日凌晨执行:
#!/bin/bash BACKUP_DIR="/data/backup/mysql" DATE=$(date +%Y%m%d%H%M%S) mysqldump -uroot -p密码 --single-transaction --quick farm_db > ${BACKUP_DIR}/farm_db_${DATE}.sql # 保留最近30天备份 find ${BACKUP_DIR} -name "farm_db_*.sql" -mtime +30 -exec rm {} \;用--single-transaction选项可以在不锁表的情况下进行备份,农忙时段线上还在写入数据也能安全执行。备份文件定期转移到异地,这个就不细说了,懂得都懂。
8. 经验总结与后续规划
8.1 个人实战体会
这个项目做下来,我最深的三点体会:
第一,业务理解比技术选型更重要。第一次做农业系统时我只想着“用什么技术栈”,后来发现最难的部分是理解农事作业的实际流程,比如不同作物的农事周期不同、同一地块可以轮作、打药记录经常是“几瓶配一桶水按亩喷洒”而不是简单的“用了多少毫升”。把业务搞清楚了,表结构设计自然顺。
第二,权限控制要提前设计。功能权限和数据权限完全是两个维度,后者更容易漏。建议尽早把用户、角色、数据范围的关系理清,否则后期数据混杂,只能靠“代码里加各种if”补救,维护成本爆炸。
第三,报表统计从一开始就要考虑。农事记录、农资库存这些表设计时,就要考虑后续统计查询是否方便。宁可多冗余一两个字段,也不要把所有统计都靠实时大SQL算,数据量大时性能会非常难看。
8.2 未来的扩展方向
如果这个项目继续做下去,有几个方向:
- 接入IoT设备:在基地部署温湿度、土壤墒情传感器,将环境数据与农事记录关联,形成完整的环境-操作-产出数据链;
- 农事任务自动提醒:根据作物生长周期模板,系统自动生成“该施肥了”“该打药了”的提醒任务;
- 农产品溯源小程序:基于已有的批次号机制,做一个小程序扫码查看作物全程农事记录;
- 智能决策模型:基于历史产量和农事数据,用机器学习模型预测最佳播种时间和预期产量。
每个方向都有价值,但前提是当前这个项目的数据质量要过关。这也是为什么我强调:任何功能设计,都要把“数据怎么来、怎么存、怎么用”想清楚再动手。数据是农事管理系统的血液,没有数据,什么智能分析都是空中楼阁。
最后再分享一个小技巧:如果你也想从零做一个类似的系统,不要一上来就写代码。先花至少一周时间去调研业务、画原型、建表,然后找一两个实际使用者过一遍流程,确认页面和功能都对得上他们的习惯,再开始敲代码。这个前置投入看起来“浪费时间”,实际上能省下后面至少三倍的返工时间。真实项目里,最贵的成本从来不是写代码,而是理解错了需求之后全部重来。