前几天整理一套Java Web乡村政务办公系统的源码,技术栈是SpringBoot2+Vue3+MyBatis-Plus+MySQL8.0,按惯例要把配套文档补齐再发出来。整理到一半我挺感慨:这类项目从外面看就是"又一个OA系统",但真正在乡镇机房跑过一遍的人会明白,乡村政务这四个字对开发的影响,要远远大于办公系统这四个字。用户是谁、网络什么环境、审批链多长、数据长什么样、文档谁来读,全跟普通企业OA不一样。
这篇文章把做这个系统的全过程完整盘一遍——选型逻辑、业务模块拆解、数据库设计、后端实现、前端落地、文档与部署,把我踩过的坑和沉淀下来的经验全部公开。如果你正准备用这套技术栈做乡村政务、基层治理、村务管理之类的项目,这篇应该能帮你少走不少弯路。
1. 为什么是SpringBoot2+Vue3+MyBatis-Plus,而不是追新版本
1.1 选型逻辑:稳定、可靠、接手的人好找
做乡村政务办公系统,第一优先级从来不是技术新鲜,而是稳定、可维护、能交接。这套系统最终要部署在乡镇的机房或区县政务内网里,环境往往比互联网公司差一大截:老旧的服务器、Windows Server或CentOS 7、没有外网、甚至JDK都不会给你装最新的。
SpringBoot2选择使用JDK8,这是基层环境兼容性最好的组合。SpringBoot3虽然性能更好、新特性更多,但它强制要求JDK17,而JDK17在乡镇机房并不普及。更关键的是,MyBatis-Plus对SpringBoot3的支持需要单独的适配依赖,版本不熟悉、资料没那么多,出了问题排查成本高。
另一个现实因素是团队承接能力。这类系统的维护者通常是区县的信息化服务商或乡镇本地的技术员,大家最熟悉的就是SpringBoot2+MyBatis-Plus这套组合。你选一个太激进的技术栈,将来交接的时候别人很难接,最后变成没人改得动的遗留系统。
1.2 MyBatis-Plus把重复CRUD压缩到什么程度
我最初也想全手写MyBatis XML,写了两天就放弃了。政务系统的后端接口大量是单表增删改查,用MyBatis-Plus能省掉至少一半的样板代码。
举个例子,建一张村务公示表,用代码生成器一键生成entity、mapper、service、controller,单表的增删改查、批量操作、分页查询全都有了。你再写业务的时候,条件构造器LambdaQueryWrapper比拼XML字符串舒服太多。
LambdaQueryWrapper<Notice> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Notice::getStatus, 1) .like(StringUtils.hasText(keyword), Notice::getTitle, keyword) .orderByDesc(Notice::getCreateTime); IPage<Notice> page = noticeMapper.selectPage(new Page<>(pageNum, pageSize), wrapper);这段代码干了什么?按状态过滤、按标题模糊查询、按创建时间倒序、分页,四件事全齐了。放在以前,你要写SQL、写Count查询、处理动态条件,现在一行搞定。
但要注意:生成器生成的只是骨架。真正复杂的业务——比如审批流转、联合查询、报表统计——还是要手动写XML或自定义SQL。把生成器当"脚手架"用,而不是当"完整后端"用。
1.3 Vue3+Element Plus在政务场景的适应性
前端选Vue3不是因为它新,是因为它成熟且被验证过。组合式API让页面逻辑复用变得舒服,一个公示管理的列表页可以拆成查询表单、表格、弹窗、文件上传等多个小组件,每个组件各管一段,后期维护起来很清楚。
Element Plus的表格、表单、弹窗、日期选择器、分页组件,几乎覆盖政务页面80%的界面需求。这类系统不需要炫酷的视觉效果,需要的是规整、好用、开箱即用。
还有一个隐藏原因:会Vue3的人真的多。无论是招人还是找外包,Vue3+Element Plus是当前前端生态里最容易找到人手的技术栈,这对政务项目的长期维护很重要。
2. 乡村政务模块设计:先有业务地图,再写代码
2.1 村务公开与公示审核:最核心的模块
很多人一听到"乡村政务办公系统"就以为是公文收发加会议通知,实际上这类系统最核心、最常用的模块是村务公开。
村务公开的业务逻辑是这样的:村里有些信息必须在一定时间内对外公示,比如财务收支、惠农补贴发放、工程项目、宅基地审批结果。公示之前要有人拟稿,经过村务监督委员会或村委审核,审核通过才能发布,发布后到期自动归档。
落到系统里就是一个公示表加状态流转:
| 字段 | 说明 |
|---|---|
| title | 公示标题 |
| content | 公示内容 |
| attachment_ids | 附件ID列表,Json存储 |
| start_date / end_date | 公示开始和结束日期 |
| status | 0草稿、1待审核、2公示中、3已归档、4已驳回 |
| audit_user / audit_time | 审核人、审核时间 |
| create_dept | 发布单位 |
注意"到期自动归档"这个动作。公示期是固定的,比如7天或15天,到期后状态要从"公示中"自动切到"已归档"。用定时任务扫描end_date字段就行,Spring的@Scheduled每隔一段时间跑一次:
@Scheduled(cron = "0 0 1 * * ?") public void archiveExpiredNotices() { LambdaUpdateWrapper<Notice> wrapper = new LambdaUpdateWrapper<>(); wrapper.eq(Notice::getStatus, 2) .lt(Notice::getEndDate, LocalDate.now()); noticeMapper.update(null, wrapper); }这个逻辑不难,但很容易被忽略。如果漏了,公示永远挂在"公示中",对群众来说就是在骗人。政务系统的严谨性往往体现在这种小细节上。
2.2 台账管理:别用固定字段写死
台账是乡村政务里绕不开的东西:低保台账、特困人员台账、残疾人补贴台账、危房改造台账、耕地地力保护台账……每个台账的表头还不一样,而且每年都可能变。
最蠢的做法是每来一个台账需求就建一张表,搞到后面数据库里几十张台账表,每张表结构又不同,维护成本直接爆炸。
务实的方案是"固定字段 + 扩展字段"模式:主表存通用字段(户主姓名、身份证号、所属村、金额、状态、登记日期),再留一个ext_json字段存这块业务特有的属性。
{ "低保证号": "农低保2024-0031", "家庭人口": 4, "致困原因": "因病", "保障类别": "A类" }MySQL8对JSON字段的支持很好,可以用JSON_CONTAINS、JSON_EXTRACT去查。台账数据量本身不大,一个乡镇一年几千条到几万条撑死了,用JSON字段完全扛得住。
这样做还有个额外好处:乡镇要的统计报表格式千奇百怪,JSON字段能把数据保留下来,后面再怎么变报表,也能从库里捞出来。
2.3 审批流不追求工作流引擎,状态机够用
乡村政务的审批链很短,大多是村→镇两级,偶尔加个县部门也就三级。我见过有人在这种系统里引入Flowable或Camunda工作流引擎的,结果就是配置文件一大堆、流程图设计复杂、运维人员根本不会调,纯属给自己找罪受。
我的做法是设计一张通用审批表,用状态机驱动:
public class AuditRecord { private String businessType; // 业务类型:宅基地申请、补贴申请、项目申报等 private Long businessId; // 业务主键 private String currentNode; // 当前节点:村委、乡镇 private String status; // 草稿、待村审、待镇审、通过、驳回 private String auditorName; // 当前审批人 private String auditOpinion; // 审批意见 }每一种业务的状态流转可以在Service层用枚举和Map定义清楚:
// 宅基地申请状态机 DRAFT -> VILLAGE_REVIEW -> TOWN_REVIEW -> APPROVED └──> REJECTED状态机的好处是代码直白、逻辑清晰、每个状态都有迹可循。审批意见、审批人、审批时间都记录在案,出了问题能追溯。工作流引擎是给流程复杂、节点会变的大系统用的,乡村政务这种"短流程"场景,状态机是性价比最高的选择。
2.4 通知公告与待办提醒:要让用户"进来就能看到"
政务系统的用户不像互联网产品用户那么主动,很多村干部是系统弹出来才想起来"哦我今天要审批一个东西"。所以首页必须做两块:未读公告和我的待办。
待办不用做复杂的消息队列,一个视图就够:查审批表里currentNode等于当前用户角色且status为待审的记录,加上count查询,首页轮询或者每次加载时触发。
已读公告的标记,我建议用一张独立的已读记录表,公告ID加用户ID唯一。因为公告常常是广播给全乡镇的,用"按用户记录已读"比"按公告记录已读用户列表"更顺:
create table notice_read_log ( id bigint primary key, notice_id bigint not null, user_id bigint not null, read_time datetime, unique key uk_notice_user (notice_id, user_id) );查询"我读没读过"就是一条count,数据量小,完全没问题。
3. 数据库设计里的乡村实务:组织、权限与MySQL8.0细节
3.1 两级组织架构怎么落表
乡村政务的组织结构其实很简单:镇管村,就两层。不要迷信无限层级树,做系统设计的时候按真实业务来。
我用的是一张sys_dept表:
| 字段 | 说明 |
|---|---|
| dept_id | 主键 |
| parent_id | 上级部门,镇的parent_id为0,村的parent_id为对应镇ID |
| dept_name | 部门名称 |
| dept_type | 1镇、2村 |
| leader_name | 负责人 |
| phone | 联系电话 |
查某镇下面所有村,一条SQL:
select * from sys_dept where parent_id = 某镇ID;这就是全部需求。MySQL8虽然支持WITH RECURSIVE做递归查询,但在这个场景里根本用不上,加了反而是过度设计。
3.2 数据权限:村级用户只能看本村数据
权限设计分两层:一是菜单权限,决定用户能进哪些页面,用RBAC模型,用户-角色-菜单三张表搞定;二是数据权限,决定用户在同一页面里能看到哪些数据,这是乡村政务系统最需要重视的地方。
镇长能看到全镇所有村的数据,村书记只能看到自己村的数据。我通常在业务表上统一加一个dept_id字段,然后写一个数据权限拦截器,在查询时自动追加:
@DataScope public PageResult<Notice> pageNotice(NoticeQuery query) { LambdaQueryWrapper<Notice> wrapper = new LambdaQueryWrapper<>(); // 自动追加 dept_id = 当前用户的部门ID 或 in 当前用户管辖的部门列表 if (当前用户角色是村) { wrapper.eq(Notice::getDeptId, 当前用户deptId); } else if (当前用户角色是镇) { wrapper.in(Notice::getDeptId, 该镇的所有村ID); } }我个人的做法是在自定义注解上做统一拦截,也可以在Service层显式处理,看团队习惯。但有一条原则必须坚持:数据权限判断要放在后端,前端只能做展示控制,不能依赖前端隐藏来保证数据安全。
市级或县级需要看数据时怎么办?单独开一个数据同步模块或者只读库,不要在业务系统里直接开放全量数据权限。
3.3 MySQL8.0落地时的几个隐藏坑
MySQL8比5.7好用的同时,也带来了几个必须处理的细节。
第一个坑是认证插件。MySQL8默认的认证插件是caching_sha2_password,如果你用了老版本JDBC驱动,启动时会直接报Unable to load authentication plugin 'caching_sha2_password'。解决办法很简单,把mysql-connector-java版本提到8.0.x,和数据库版本对应:
<dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency>第二个坑是JDBC连接串。必须明确指定时区和编码:
spring: datasource: url: jdbc:mysql://localhost:3306/politics_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.DriverserverTimezone不设的话,在中国时区下读写时间字段会差8个小时,list里明明显示的今天,库里存的是昨天。allowPublicKeyRetrieval在首次连接时也需要,否则可能报错。
第三个坑是SQL模式。MySQL8默认开启ONLY_FULL_GROUP_BY,以前在5.7里能跑通的group by查询,到8.0可能直接报错。如果是从5.7迁移上来的老项目,建议在my.cnf里显式设置sql_mode,或者重构SQL语句。
第四个坑是字符集。建库建表务必用utf8mb4。别问为什么,等某一个村书记的名字里有个生僻字,或者公示内容里被粘贴了一个特殊符号,你看到乱码的时候就懂了。
CREATE DATABASE IF NOT EXISTS politics_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;4. 后端实现里最值得记录的五个坑
4.1 分页插件的正确姿势
MyBatis-Plus的分页在SpringBoot2下的配置方式是注入MybatisPlusInterceptor,把分页拦截器放进去:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); interceptor.addInnerInterceptor(pagination); return interceptor; } }有两个细节。一是多个插件时,分页拦截器尽量放在靠后的位置,避免和其他拦截器互相影响。二是setMaxLimit要给一个上限,防止有人传pageSize=999999把数据库拖垮。政务系统很多是内网环境,性能压力不大,但没有上限就是隐患。
4.2 逻辑删除与唯一索引冲突
政务系统的数据最好别硬删,公示、台账、审批记录都有审计需求,所以逻辑删除在SpringBoot2+MyBatis-Plus下用得很普遍。配置了逻辑删除后,删除操作变成update,查数据时自动追加deleted=0条件。
但这里有个经典坑:逻辑删除字段和唯一索引会打架。比如用户表user_name有唯一索引,A用户被逻辑删除后,deleted变成1,你再创建一个同名的B用户,唯一索引立刻冲突,因为删掉的那行数据还在表里。
解决办法是给唯一索引加上deleted字段:
alter table sys_user add unique key uk_username_deleted (user_name, deleted);同时把deleted字段的默认策略改成:正常数据deleted=0,删除时deleted设置成主键ID而不是1。这样即使同一username被删了多次,每个删除行的deleted值都不同,索引不会冲突。
4.3 自动填充的边界你得知道
create_time、update_time这类字段,我一般通过MetaObjectHandler自动填充,不用每个Service里手动set:
@Component public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } }注意自动填充只对实体的INSERT和UPDATE生效。还有个更隐蔽的问题:updateById方法传入的实体里,某个字段如果是null,MyBatis-Plus默认不会更新这个字段。这不是bug,是特性,但会导致"我想把某字段置为null"的需求实现不了。这时候必须用UpdateWrapper:
LambdaUpdateWrapper<Notice> wrapper = new LambdaUpdateWrapper<>(); wrapper.eq(Notice::getId, id) .set(Notice::getStatus, 3) .set(Notice::getAuditUser, null); noticeMapper.update(null, wrapper);4.4 查询条件对象和实体对象要分离
很多初学者喜欢直接用实体对象接收前端的查询参数,比如把Notice对象直接当查询条件。这个习惯非常危险。
问题在于:一旦未来某个字段被前端传了值进来,你根本控制不住查询范围,要么查不到数据,要么数据越权。我的做法是每个列表页单独建一个Query对象,只包含需要过滤的字段,配合LambdaQueryWrapper使用:
@Data public class NoticeQuery { private String title; private Integer status; private Long deptId; private String startDate; private String endDate; }这样可以精确控制哪些条件是合法的,也方便以后扩展排序、分页参数。
4.5 文件上传的统一封装
政务系统的文件上传是我一开始低估的部分。公示要传扫描件,宅基地审批要传身份证照片和宅基地照片,补贴申请要传证明材料扫描件……整个系统到处都是附件。
我的做法是做一个统一的上传接口,文件落盘到服务器指定目录,数据库里只存文件元信息:
@PostMapping("/upload") public R<String> upload(@RequestParam("file") MultipartFile file) { String originalFilename = file.getOriginalFilename(); String ext = originalFilename.substring(originalFilename.lastIndexOf(".")); String filename = UUID.randomUUID().toString().replace("-", "") + ext; String monthPath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM")); File dir = new File(storagePath + monthPath); if (!dir.exists()) dir.mkdirs(); file.transferTo(new File(dir, filename)); // 返回文件访问路径,比如 /files/2024/06/uuid.jpg return R.ok("/files/" + monthPath + "/" + filename); }文件名用UUID生成,不要用原始文件名当落盘名,避免中文乱码、空格、路径穿越等问题。存储路径在配置里单独维护,同时配置Spring的静态资源映射:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/files/**") .addResourceLocations("file:" + storagePath); } }如果以后部署到集群,这个方案要换成对象存储或者共享存储,但单机部署的政务系统,本地磁盘足够。
5. Vue3前端的落地细节:低配置环境与低数字素养用户如何兼顾
5.1 动态菜单和按钮权限
前端权限的核心逻辑是:登录成功后,后端返回token、用户信息、菜单列表、按钮权限标识,前端根据这些数据动态生成路由。
Vue3+Vue Router的addRoute方法很顺手。菜单数据通常是一棵树,前端递归生成RouteRecordRaw,再按需动态添加。刷新页面时要重新拉取一次用户信息,所以我习惯把登录后的用户数据放在Pinia里持久化到localStorage,路由守卫里判断store中没有用户信息就重新请求。
按钮权限我封装了一个自定义指令v-permission:
const permission = { mounted(el, binding) { const required = binding.value const userStore = useUserStore() if (!userStore.permissions.includes(required)) { el.parentNode?.removeChild(el) } } }模板里直接用:
<el-button v-permission="'notice:audit'">审核</el-button>注意一点:前端的权限控制是体验优化,不是安全措施,后端接口同样要做权限校验。
5.2 列表页性能:数据量不大,但别写崩
乡村系统单表数据量一般不大,但列表页要复用很多次,性能仍然要注意。核心原则:后端分页,前端只渲染当前页。
Element Plus的el-table建议设置height属性,让表头固定,内容滚动。如果是几千条数据触发了比较严重的渲染卡顿,考虑vxe-table这类虚拟滚动表格,但一般场景el-table够了。
导出Excel是列表页的标配功能。我的建议是导出也走后端接口,前端传查询条件,后端异步生成Excel文件再返回下载链接。不要指望前端把当前页数据导出来就完事——业务上要导出的往往全部符合条件的数据,不是当前页。
5.3 "基层友好"的交互调整
乡村政务系统的用户画像非常特殊:大部分是村干部和乡镇工作人员,年龄集中在35到55岁,对电脑操作熟练度不一。我在前端做了几个细节调整:
- 全局字体调大。Element Plus默认14px对很多用户偏小,我会在全局样式中调到15或16px,按钮、表格、表单里的文字都要够大。
- 所有提交、删除、审核按钮都加确认弹窗。误点"删除"带来的后果没人能兜底。ElMessageBox.confirm是标配。
ElMessageBox.confirm('确定要删除这条公示吗?删除后不可恢复', '删除确认', { confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning' }).then(async () => { await deleteNotice(id) ElMessage.success('删除成功') loadData() })- 表单校验提示语写完整的人话。比如"请输入姓名"就不够好,改成"请填写村民姓名,支持身份证上的姓名"。一句话省掉很多电话咨询。
- 空白页面加上空状态。表格没数据时就显示"暂无数据,可点击右上角新增",别让用户面对一片空白发呆。
5.4 构建目标和浏览器兼容
基层电脑的浏览器很可能是老版本的Chrome、360安全浏览器兼容模式或者国产浏览器套壳。Vite默认构建目标偏高,会输出一些现代浏览器才支持的语法。在vite.config.js里把build.target调低:
export default defineConfig({ build: { target: 'chrome80' } })这能避免很多"拿到新电脑上打不开页面"的投诉。代价是打包体积稍微大一点,但政务系统内网部署,这个代价完全可以接受。
6. 配套文档真正决定这套源码好不好上手
6.1 说明文档里必须覆盖的环境清单
一套源码发布出去,最怕的就是使用方在环境上卡住。我整理文档时,第一件事就是列环境版本清单,并且锁死版本:
- JDK 1.8.0_202 及以上,不要用JDK11或17跑SpringBoot2项目
- Maven 3.6.3 及以上,不要用Maven 4
- Node.js 16.20 或 18.x,不要用Node 20+,部分依赖在新版本Node下会编译失败
- MySQL 8.0.20 及以上
- 推荐部署系统:Windows10/Server2016/Server2019,Linux CentOS7/Ubuntu20.04
为什么锁版本?因为"能跑"和"不能跑"之间往往就差一个环境版本。文档里不写清楚,使用方会在莫名其妙的地方浪费好几天。
6.2 初始化SQL脚本和演示数据
一套好的源码必须带完整的init.sql:建库、建表、插入初始数据。初始数据至少包含:
- admin管理员账号,并注释说明初始密码
- 菜单表数据,权限系统的根基
- 字典数据,政务系统到处是状态、分类的字典项
- 一条或两条演示数据,比如一条公示记录、一条台账记录,让使用方一登录就能看到页面不是空的
演示数据这点很多人忽略。新人拿到空表空页面,根本不知道系统应该长什么样,也不知道数据该往哪里填。有两条样例数据,照着样子操作,学习成本直接砍半。
6.3 部署的两种姿势:开发机直跑和服务器部署
文档里我把部署分为两步走。
第一步是开发机直跑。后端:
mvn clean package -DskipTests java -jar target/politics-system.jar前端:
npm install npm run dev前端开发服务器配一个Vite代理,把/api转发到后端8080端口,本地就能联调。
第二步是服务器部署。后端用systemd做服务托管,设置开机自启:
[Unit] Description=Politics System After=network.target [Service] ExecStart=/usr/local/java/jdk1.8.0_202/bin/java -jar /opt/politics/politics-system.jar Restart=always User=root [Install] WantedBy=multi-user.target前端build产物放到Nginx目录,Nginx配置里做静态资源服务和反向代理:
server { listen 80; server_name localhost; root /opt/politics/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 /files/ { proxy_pass http://127.0.0.1:8080/files/; } }特别要注意:location /api/ 的 proxy_pass 末尾带不带斜杠,效果完全不同。带斜杠会把URI里的/api去掉再转发,不带斜杠则原样转发。这个细节我在多个项目中都见过有人栽跟头,文档里必须点明。
6.4 二次开发路径:拿到源码后第一步做什么
文档最后一部分是二次开发导读。我习惯用一个"新增一个村务类型模块"的例子,把从建表到前端页面整个过程串一遍:
- 数据库建新表,加上create_time、update_time、deleted、dept_id这些通用字段
- 用代码生成器生成entity、mapper、service、controller,或者手写
- 在菜单表里插入一条菜单记录,前端刷新后就能看到新菜单入口
- 复制一个现有列表页面的Vue组件,改掉接口地址和字段
- 后端接口加上@DataScope数据权限注解
按这个顺序走一遍,基本就能理解整个项目的骨架了。文档里我还附了常见的扩展场景,比如给公示模块加一个新的审核层级、给台账模块加一个Excel批量导入,都是基于现有结构的增量改造。
写在最后的一点体会
把这套系统从头到尾做完,最深的感受是:乡村政务系统技术难度不算高,真正的难点在于理解使用场景。用户是基层干部,环境是乡镇机房,业务是村务公开和台账审批,数据要留痕可追溯。技术栈SpringBoot2+Vue3+MyBatis-Plus+MySQL8.0都不是最新的,但组合在一起,维护成本低、交付周期短、接手容易,正好契合这个场景的实际需要。
如果你准备开发类似的系统,我的建议是:先花时间把业务模块和角色梳理清楚,再动手建表写代码;数据库设计一定考虑扩展性,因为乡镇的报表和台账每年都在变;文档和部署脚本宁可多写也不要少写,这些在交付时的价值往往和代码一样重要。把稳定、简单、可维护放在第一位,这比追逐任何新技术都有意义。