做毕业设计或者接乡镇数字化的私活时,土地资源管理子系统几乎是绕不开的一个典型业务场景。最近刚完整交付了一套基于Spring Boot + MyBatis 的土地资源管理系统,从需求梳理、数据库落表到权限控制、统计报表、前后端联调,整套流程走下来我发现这个命题其实远不止"增删改查"这么简单。这篇文章就把我实际开发中踩过的坑、定过的方案、留下的代码结构一次性讲清楚,末尾有源码获取方式,需要完整工程的同学直接找我拿即可。
先交代一下我这边项目的最终情况:后端采用 Spring Boot 2.6.13 + MyBatis + MySQL 8.0,前端用的是 Vue 2 + Element UI,鉴权走的是 JWT + 拦截器方案,部署在单台 2 核 4G 云服务上。整个系统分了系统管理、基础信息、地块管理、承包管理、流转管理、统计报表六大模块,其中土地资源管理子系统是信息平台的核心业务端,主要解决"土地信息怎么录、变更怎么记、归属怎么查、统计怎么出"这四件最基础也最头疼的事。
这个项目我定位为"乡村振兴数字化平台"里的一个业务子系统,所以没有做微服务那一套,而是用模块化分包的方式保持边界。实际做下来我的体会是:对于县、乡镇级别的土地管理场景,单体应用绰绰有余,硬拆微服务反而会让部署和运维成倍变复杂。下面我把这套系统从设计到落地的全过程拆开讲。
1. 需求分析与系统定位
1.1 土地资源管理到底在管什么
很多同学把土地资源管理直接理解成"村子地块的录入和查询",这没错,但太单薄了。我实际接触到乡镇工作人员后才发现,他们每天的痛点集中在三个层面:
第一,档案底数不清。全村到底多少块地、每块地权属是谁、面积多大、什么地类,很多还停留在纸质台账阶段,想查一块地的历史记录要翻半天档案。
第二,变更流程靠人记。土地承包、流转、征收、宅基地审批,每一次变动如果没在系统里形成一条可追溯的记录,后续很容易产生纠纷。
第三,统计口径千差万别。上级要报表,村里报一个数,乡镇核对又发现对不上,原因就是各方手里的数据源不一致。
所以我做需求分析时,把子系统拆成了四个核心域:基础档案域(地块本身的属性)、权属域(承包信息)、变动域(流转和变更记录)、分析域(统计报表)。这四块搞清楚了,系统的主干就立住了。
1.2 核心用户与角色权限划分
这套系统我设计了四种角色,权限层级非常清晰,具体如下表:
| 角色 | 主要职责 | 核心权限范围 |
|---|---|---|
| 系统管理员 | 账号维护、字典管理、日志查看 | 系统管理模块全部权限 |
| 乡镇管理人员 | 地块录入、变更审批、流转登记 | 基础信息、地块、承包、流转的增删改查 |
| 村级信息员 | 地块信息采集录入、资料上传 | 新增地块、修改待审核信息、查看本村数据 |
| 普通浏览用户 | 查阅公开地块信息、公告 | 只读部分公开字段 |
这里有个关键设计思路:村级信息员录入的数据必须经过乡镇管理人员审核后才正式生效,这是现实业务里最真实的需求——村里录错了不能直接污染全乡镇的数据。所以我在每张核心表上都加了status字段,用状态机控制数据流转(草稿 -> 待审核 -> 已生效 -> 已归档),这个设计审计人员一眼就能看懂,后续扩展"驳回""退回"也方便。
1.3 为什么强调"子系统"
标题里写的是"信息平台建设——土地资源管理子系统",这说明整个平台还会有村庄概况、农产品信息、村务公开、在线办事等其他子系统。我在项目里用 Maven 多模块结构来承载这个设计思路:
platform-common 公共工具、统一返回、异常处理 platform-system 系统管理(用户、角色、菜单、字典) platform-land 土地资源管理子系统(核心业务) platform-web 启动模块各模块之间通过 Service 接口解耦,platform-land 不直接依赖 platform-system 的实现类,只依赖接口。这样做的好处是,如果后面平台新增一个"宅基地管理子系统"或者"农业补贴子系统",新模块可以直接复用系统管理和公共模块,不需要改老代码。
2. 技术选型:Spring Boot 版本与配套组件
2.1 Spring Boot 2.6.x 还是 2.3.x
最开始我准备用 Spring Boot 3.x,但考虑到大部分学校课程和老项目还在用 JDK 8,而且第三方 Starter 兼容性在 3.x 下偶尔会有幺蛾子,稳妥起见还是选了 2.6.13。这里我对比过 2.3.x 和 2.6.x,核心差异在于:
- Spring Boot 2.3.x 比较老,虽然稳,但一些新特性(如
spring-boot-starter-validation的独立版本管理、更好的配置处理)支持不够好。 - Spring Boot 2.6.x 引入了路径匹配策略调整,
PathPatternParser成为默认,这对 RESTful 风格接口很友好,且生命周期覆盖到 2023 年之后,用于毕业设计和中小项目完全够。 - 2.4.x 之后的配置文件不再支持多文档
spring.profiles写法,改成了spring.config.activate.on-profile,这个变化很多人升级时栽过跟头。
最终我决定使用 Spring Boot 2.6.13 + JDK 8 + MyBatis 3.5.x。实际跑下来稳定性很好,没有遇到明显问题。
2.2 持久层方案:MyBatis 还是 MyBatis-Plus
热词里那个"spring boot + mybatis 的 java 开源多商户跨境商城源码"说明很多人确实在招这种组合。我到今天还是倾向于原生 MyBatis 搭配通用 Mapper 工具类,主要原因有两点:
- 土地资源管理业务的 SQL 复杂度比普通 CRUD 高不少,涉及多表关联、按条件动态查询、地理属性筛选,MyBatis 的动态 SQL 能力在这些场景下非常舒服。
- 我的团队和读者里有大量习惯裸写 SQL 的开发者,原生 MyBatis 让 SQL 可读、可控、可优化,排查慢查询时直接捞 XML 出来执行,不用经过一层抽象。
我同时引入了 PageHelper 做分页。PageHelper 拦截器会改写 SQL,需要注意在配置里指定合理的reasonable参数,避免页码越界时查到脏数据。
2.3 Spring Boot Admin 做监控
我看到热搜里有"spring boot admin"这个词,忍不住多说一嘴。在这个项目里我确实集成了 Spring Boot Admin 2.6.8,用来自定义内存、线程池和接口响应耗时监控。它不需要引入太重的东西,被监控端加一个spring-boot-starter-actuator,服务端单独起一个轻量工程,界面能看到实时的堆内存使用、HTTP 接口调用情况和在线实例列表,对单体应用排查内存泄漏和慢接口非常有帮助。
2.4 前端与后端数据交互格式
前端的技术选型我没有花哨,直接用 Vue 2 + Element UI。或许是私活交付和毕设场景下最稳妥的组合,组件丰富、中文文档齐全、后端同学容易上手。接口统一返回 Result 结构:
{ "code": 200, "msg": "操作成功", "data": { } }返回码不用 HTTP 状态码作为业务成功与否的唯一标识,而是用业务 code 区分成功和失败,这样前端拦截器处理 401 跳登录、403 跳无权限、业务异常全局提示就非常清晰。
3. 数据库设计:表结构拆解与核心字段说明
3.1 土地基础信息表(land_info)
土地表是整个系统的地基,字段设计尤其重要。我的核心字段如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint 主键自增 | 主键 |
| land_code | varchar(32) 唯一索引 | 地块编码,规则:乡镇编码+村编码+序号 |
| land_name | varchar(64) | 地块名称 |
| land_type | tinyint | 地类(1水田 2旱地 3林地 4宅基地 5其他) |
| area_mu | decimal(10,2) | 面积,单位亩 |
| location_desc | varchar(255) | 位置描述 |
| longitude / latitude | decimal(10,6) | 经纬度坐标 |
| owner_id | bigint | 权属人ID(关联农户表) |
| status | tinyint | 状态:0草稿 1待审核 2已生效 3已归档 |
| create_by / create_time / update_by / update_time | 审计字段 |
这里最要注意的是area_mu必须用 decimal,不能用 float。土地面积涉及到补贴计算、流转费用,浮点误差会导致金额对不上,这是实际数据核对时最容易暴露的问题。另外经纬度字段不要用 varchar,配合地图组件后续做可视化定位会很麻烦。
3.2 承包信息表与流转记录表
承包信息表存的是"这块地归谁承包、承包期到什么时间";流转记录表则保存每一次经营权流转的详细信息。
流转表我额外加了一个contract_attachment字段,用来存合同附件的文件路径。不要小看这个字段,实际业务里签合同是家常便饭,没地方传附件系统根本用不起来。文件我采用本地磁盘存储,上传后的访问路径做了静态资源映射,这样部署时只挂一块数据盘就够,不需要引入 OSS,省心,也减少私活成本。
4. 核心模块功能实现与实操代码
4.1 JWT 登录认证与权限拦截器
登录这块我用了 JJWT 0.9.1 + Spring 拦截器实现:
@Component public class JwtInterceptor implements HandlerInterceptor { @Autowired private StringRedisTemplate redisTemplate; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (StringUtils.isBlank(token)) { throw new BusinessException(401, "未登录或登录已过期"); } Claims claims = JwtUtil.parseToken(token.replace("Bearer ", "")); Long userId = Long.valueOf(claims.get("userId").toString()); // 校验 token 是否存在于 Redis,单设备登录 String redisToken = redisTemplate.opsForValue().get("LOGIN_TOKEN_" + userId); if (!token.replace("Bearer ", "").equals(redisToken)) { throw new BusinessException(401, "账号在其他设备登录,请重新登录"); } request.setAttribute("userId", userId); return true; } }这个方案里我把 token 放在 Redis 里做单点登录控制,用户在别处登录后旧 token 会失效。功能不算复杂但很实用,避免了"改完密码旧 token 还能用"的尴尬。
4.2 土地信息分页条件查询
土地查询是使用频率最高的接口。支持按地块编码、地类、权属人、状态和面积区间五种条件组合查询。Mapper 里用了动态 SQL:
<select id="selectLandPage" resultType="com.platform.land.entity.LandInfoVO"> SELECT li.*, ho.holder_name, ho.idcard_no FROM land_info li LEFT JOIN holder_info ho ON li.owner_id = ho.id <where> <if test="landCode != null and landCode != ''"> AND li.land_code LIKE CONCAT('%', #{landCode}, '%') </if> <if test="landType != null"> AND li.land_type = #{landType} </if> <if test="holderName != null and holderName != ''"> AND ho.holder_name LIKE CONCAT('%', #{holderName}, '%') </if> <if test="status != null"> AND li.status = #{status} </if> <if test="minArea != null"> AND li.area_mu >= #{minArea} </if> <if test="maxArea != null"> AND li.area_mu <= #{maxArea} </if> </where> ORDER BY li.create_time DESC </select>这里我踩过一个坑:一开始LIKE CONCAT('%', #{landCode}, '%')的写法在老版本 MyBatis 里没问题,但如果用${landCode}直接拼字符串会有 SQL 注入风险,所以必须用#{}参数绑定。
4.3 Excel 批量导入导出
乡镇工作人员手里往往有一堆旧 Excel 台账要迁入系统,手打录入会崩溃。我引入了 EasyExcel 做批量导入导出。导入的过程不复杂:前端上传 Excel -> 后端解析成对象列表 -> 逐条校验 -> 合法数据入库 -> 生成导入结果详情,包括成功条数和每行的失败原因:
public ImportResult importLand(MultipartFile file) { List<LandExcelDTO> list = EasyExcel.read(file.getInputStream()) .head(LandExcelDTO.class) .sheet() .doReadSync(); ImportResult result = new ImportResult(); int successCount = 0; List<String> errorMessages = new ArrayList<>(); for (int i = 0; i < list.size(); i++) { LandExcelDTO dto = list.get(i); String validateMsg = landService.validateLand(dto); if (validateMsg != null) { errorMessages.add("第" + (i + 2) + "行:" + validateMsg); continue; } landService.insertLand(dto); successCount++; } result.setSuccessCount(successCount); result.setFailMessages(errorMessages); return result; }导出的逻辑同理,把查询出的 List 直接EasyExcel.write(response.getOutputStream())。这套方案目前处理单次几千行数据毫无压力,而且不需要让前端装任何依赖。
4.4 统计报表与地图可视化
统计模块我实现了三张报表:按地类统计面积、按村统计确权数量、按年份统计流转趋势。这些报表的底层只是集 SQL 查询,比如按地类统计:
SELECT land_type, SUM(area_mu) AS total_area, COUNT(*) AS land_count FROM land_info WHERE status = 2 GROUP BY land_type ORDER BY total_area DESC前端用 ECharts 画饼图和柱状图。ECharts 很成熟,但要注意折线图时间轴的数值类型转换,后端返回的时间字段一定要格式化成字符串,否则前端 new Date() 会解析失败。
地图可视化我做了层聚合展示:把每块地的经纬度查询出来,在地图上打点,点击弹窗显示地块详情。做的过程发现一个很实际的坑——如果某块地的经纬度是 0,打点会全部挤在偏航位置,所以查询时必须过滤掉longitude = 0 OR latitude = 0的记录。
5. 接口设计与第三方对接的思考
5.1 子系统接口应该怎么规划
有很多人问过我:"Spring Boot 对外提供的接口(给第三方)应该放在哪里?是单独的服务还是放在对应的业务模块里?"
我的建议是:如果接口是同一个团队内部使用(比如前端页面调用),直接放在对应的 Controller 里,不加额外前缀;如果确定要开放给第三方系统(比如县级数据平台想要拉取全镇的耕地数据),那就单独开一层 Api 模块:
/api/v1/land/info /api/v1/land/statistics /api/v1/land/change-record这层接口与页面接口分离,统一加/api/v1前缀,并且走独立的密钥鉴权(AppId + AppSecret + 签名),不跟用户登录态混在一起。这样做的理由是:
- 页面接口的鉴权和限流策略不适合第三方,比如第三方不应该刷新页面 token、不应该依赖用户角色的数据隔离规则。
- 第三方对接往往有固定版本,
/api/v1本身就是对外版本承诺,升级时新增/api/v2不影响老客户。 - 单独打包成模块后,可以单独做流量监控和接口文档(接入 Swagger/knife4j),不会污染内部接口。
我在实际项目里就遇到了县里农业大数据平台要求对接数据的需求,因为提前做了接口分层,两天就完成了对接任务,省了很多麻烦。
5.2 接口幂等与防重提
土地流转模块有一个接口非常容易踩坑:提交流转登记。如果前端网络抖动时用户重复点击,或者消息重试,就会产生两条重复的流转记录。我在流转提交接口上加了一个简单的幂等控制:
前端在提交时生成一个requestId(UUID 或者基于业务信息生成的 MD5),后端在 Redis 里SETNX key requestId EX 60,拿到锁才执行保存逻辑,执行完删除 key。这样即使同一个请求重复发过来,后端的幂等校验也能拦住。这个小功能我建议所有写"提交类"接口的同学都安排上,成本极低,收益立竿见影。
6. 常见问题与排查技巧实录
6.1 高频报错排查速查表
| 问题现象 | 原因 | 解决方式 |
|---|---|---|
启动报Invalid bound statement (not found) | Mapper XML 没有扫描到 | 检查@MapperScan路径与 XML 的 namespace 是否完全匹配 |
Whitelabel Error Page404 | 请求路径没映射 | 检查 Controller 类有没有加@RestController,以及请求方法是否匹配 GET/POST |
| 中文乱码 | 前后端编码不一致 | 后端接口方法加produces = "application/json;charset=UTF-8";数据库连接串加characterEncoding=utf8 |
| 分页数据总数不对 | PageHelper 和嵌套查询冲突 | 不要在PageHelper.startPage()后使用带LEFT JOIN的复杂 count 查询,手动拆分或用@SelectProvider |
| 跨域调不通 | 端口不同 | 配置 CorsFilter 时加上allowCredentials(true)且指定allowedOriginPatterns,不能用*和 allowCredentials 同时 |
| 内存持续上涨 | 大 Excel 导入全部 readSync | 使用AnalysisEventListener分块解析,不要一次性把几万行全 load 进内存 |
| 前端上传文件报 413 | Nginx 请求体大小限制 | client_max_body_size 50m; |
6.2 开发过程中让我印象最深的三个坑
第一个是 MyBatis 的 SQL 里!= null做条件判断时,如果传参是类型为 Integer 的包装类,并且值为 null,MyBatis 不加<if>包裹直接比较!= null是没问题的,但如果用的是<if test="landType neq null">这种写法,在与<script>语法混合时偶尔会解析报错。我后来统一规范成<if test="landType != null">,再没出过问题。
第二个是 JWT 双人同时登录问题。系统一开始只验证 token 是否有效,没有做唯一登录控制,结果村信息员把账号密码告诉另一个人后,两人互相挤掉线,天天来骂系统。后来加了 Redis 单设备登录逻辑,才算把这个烂摊子收拾干净。
第三个是 Excel 导入的数据校验一定要放在事务外层逐条处理。我最初用@Transactional包了整个导入方法,结果中间某行数据非法导致全部回滚,用户导入 3000 行只因为第 500 行有问题,整批全失败。后来改成"校验通过才入库,失败行收集错误信息返回前端",用户体验完全提升了一个档次。
7. 源码结构与获取方式
整套系统的源码我整理得非常干净,适合学习或二次开发。主要目录结构如下:
platform-web/src/main/java/com/platform ├── common # 统一返回、全局异常、常量、工具类 ├── system # 用户、角色、菜单、字典管理 ├── land # 土地信息、承包、流转、变更记录、报表 │ ├── controller │ ├── service │ ├── mapper │ ├── entity │ └── vo └── LandApplication.java源码里面包含了完整的数据库初始化 SQL、接口文档(Swagger 注解)、前端 Vue 工程(含登录页、主框架、土地管理页面、统计图表页面)、部署说明文档。你可以直接拿来改改用于自己的毕业设计或者小型乡镇数字平台。
如果看到这里觉得这套土地资源管理子系统的思路和代码对你有帮助,需要源码做参考或者二次开发,可以直接在文末留下你的联系方式,或者通过博客底部的按钮联系我。我发你完整工程包的时候会带上打包好的数据库脚本和部署文档,按 README 一步步操作就能跑起来。
最后再分享一点个人体会:做这类管理系统,新手往往沉迷于把代码写得更"炫",什么微服务、高并发、分布式缓存都想往上堆,但实际真正让项目被客户认可的,永远是数据可靠、操作流畅、权限清晰、报表准确。土地资源管理表面上是 CRUD,本质上是一套信息治理的基础设施。把表设计吃透、把状态流转梳理清楚、把导出导入这类细节做到位,比堆十个框架都有用。这套系统后续我还在规划接入地图遥感和宅基地管理模块,到时候会再写文章分享。