SpringBoot+Vue3前后端分离扶贫助农系统开发实战详解

发布时间:2026/10/13 4:00:26
SpringBoot+Vue3前后端分离扶贫助农系统开发实战详解
做管理系统久了就会发现挑一个合适的业务往往比挑一套热门技术栈更考验人。扶贫助农系统就是那种典型“听上去很政务、做起来很扎实”的项目表面看是常见的 CRUD 后台但真拆开看它有非常完整的业务链条从农户建档、帮扶计划制定到走访记录填报、农产品上架再到订单履约和数据汇总。这条链能把 SpringBoot、Vue3、MyBatis、MySQL 这些技术点全部串起来而且角色权限、状态流转、图片上传、报表统计这些高频需求一个都不缺。这篇文章我打算围绕一套前后端分离的 SpringBoot Vue3 MyBatis MySQL 助农系统源码把从业务拆解、数据库建模、后端分层到 Vue3 前端对接、最后部署上线的完整思路写一遍。适合正在做这类毕业设计、或者想用一个完整业务项目练手 Java 全栈的人。源码本身是骨架但真正值钱的是骨架下面那些“为什么要这么设计”的判断我会把这些坑和经验一起说清楚。1. 先理业务再写代码扶贫助农系统的角色与核心链路拆解1.1 谁是用户角色与权限的第一版定义很多人拿到这类项目第一件事就是建表我的习惯正好相反先花半天把用户角色理清楚。因为角色一旦错了后面的菜单设计、接口权限、数据隔离全部跟着错。扶贫助农系统里我把它分成四类使用方角色主要操作关键权限系统管理员维护基础数据、账号和全局配置所有模块驻村干部 / 工作队员录入农户档案、制定帮扶计划、管理村级数据本村农户相关数据帮扶责任人查看分配农户、填写走访记录、上报需求仅限被分配农户农户查看自家信息、产品订单通常由工作人员代录只读为主这里有一个实际项目里常见的取舍农户端是否要单独做一个 H5 或者小程序如果只是做后台管理系统我会优先把农户侧的信息维护交给工作人员代录这样不用额外处理手机号验证、验证码登录、移动端适配复杂度能降一大截。等核心业务流程跑通了再决定是否扩展农户端。1.2 核心链路一条始终围绕“农户”的业务主线角色理清之后接下来要画业务主线。这套系统的核心不是“管理”而是“帮扶过程数字化”。所以我建议把主线定成农户建档 → 帮扶计划制定 → 走访记录填报 → 农产品信息维护 → 订单履约 → 数据汇总这条主线里农户是绝对的中心实体其他所有业务表都直接或间接挂在农户下面。帮扶计划是针对某个农户的走访记录是记录某次帮扶动作的农产品是某个农户出产的订单最终也要落到农户维度去统计。为什么要强调这条主线因为实际开发中最容易出现的问题就是业务表之间“各做各的”帮扶记录只关联责任人、不关联农户结果统计数据时根本看不出一个农户接受了多少次帮扶整个系统的价值就打折了。先定好主线再顺着主线去定表和页面后面每一步都会顺很多。1.3 模块优先级哪些必须做哪些可以后置我不建议一上来就追求模块大而全。按照这套源码的迭代思路我建议分三批走第一批是核心闭环用户登录、农户档案管理、帮扶记录管理。这三块能跑通系统的主干就有了。第二批是业务丰富帮扶计划、走访任务分配、农产品管理、按村/按责任人的基础统计。第三批才是商城和扩展订单状态流转、库存变更、公告通知、图表大屏。商城这块为什么放到最后因为订单有状态机待发货、已发货、已完成、已取消库存要扣减一旦跟农户结算挂钩还会涉及金额精度是整套系统里最复杂的闭环。前期用核心模块把架构打磨好再做这块会顺手很多。2. 数据库建模从农户建档到订单履约的表结构设计2.1 实体关系先于表结构我建表前会先在纸上把实体关系摆出来。这套系统里主要的实体关系大概是这样的用户与角色多对多一个用户可能既是管理员又是帮扶责任人农户与帮扶记录一对多一个农户有多条帮扶记录农户与农产品一对多一个农户可上架多个农产品农产品与订单一对多一个产品可产生多笔订单用户与帮扶记录一对多一个责任人填写多条记录。这个关系一旦定清楚表的外键字段就很好设计了。农户表的主键会被帮扶记录表、农产品表、订单表反复引用所以农户表的主键建议使用无意义的自增id不要用身份证号、手机号这些有业务含义的字段做主键后期数据是会变的。2.2 核心建表语句与字段约定下面给出农户档案表的简化建表语句这套字段基本覆盖了建档场景的核心信息CREATE TABLE farmer_info ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, name VARCHAR(50) NOT NULL COMMENT 户主姓名, id_card VARCHAR(18) DEFAULT NULL COMMENT 身份证号, phone VARCHAR(20) DEFAULT NULL COMMENT 联系电话, province VARCHAR(50) DEFAULT NULL COMMENT 省, city VARCHAR(50) DEFAULT NULL COMMENT 市, county VARCHAR(50) DEFAULT NULL COMMENT 县/区, town VARCHAR(50) DEFAULT NULL COMMENT 乡镇, village VARCHAR(50) DEFAULT NULL COMMENT 村, household_type TINYINT DEFAULT 1 COMMENT 农户类型1脱贫户 2监测户 3一般农户, family_count INT DEFAULT 0 COMMENT 家庭人口数, income_level DECIMAL(10,2) DEFAULT 0.00 COMMENT 年收入(万元), assist_user_id BIGINT DEFAULT NULL COMMENT 帮扶责任人ID, status TINYINT DEFAULT 1 COMMENT 状态1正常 0暂停帮扶, remark VARCHAR(500) DEFAULT NULL COMMENT 备注, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted TINYINT DEFAULT 0 COMMENT 逻辑删除0未删 1已删, PRIMARY KEY (id), KEY idx_village (village), KEY idx_assist_user (assist_user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT农户档案表;帮扶记录表则重点记录“做了什么、谁做的、什么时候做的”CREATE TABLE assist_record ( id BIGINT NOT NULL AUTO_INCREMENT, farmer_id BIGINT NOT NULL COMMENT 农户ID, user_id BIGINT NOT NULL COMMENT 填写人ID, visit_date DATE NOT NULL COMMENT 走访日期, record_type TINYINT DEFAULT 1 COMMENT 1走访 2电话 3物资发放, content TEXT COMMENT 帮扶内容, result_info VARCHAR(255) DEFAULT NULL COMMENT 帮扶成效, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_farmer_date (farmer_id, visit_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT帮扶记录表;注意几个约定所有状态字段统一用TINYINT加数字字典避免用中文写死金额用DECIMAL(10,2)绝不用FLOAT和DOUBLE每张业务表都有create_time和update_time由数据库自动维护代码里不需要手动赋值。2.3 表关联、冗余与索引规划实际开发里页面很少只查单表。比如农户列表页往往要显示“帮扶责任人姓名”这时如果只查farmer_info前端还要按assist_user_id二次请求用户表N 条数据就是 N 次请求页面性能会很难看。所以要么联表查询要么做冗余。我的建议是“查询频繁的小字段冗余大字段不冗余”。比如订单表里冗余一个farmer_name这样订单列表展示户主姓名不需要 JOIN 农户表但帮扶记录的内容字段属于大文本不走冗余需要的时候通过JOIN去取就行。下面是一条典型的联表查询 SQL用于农户列表页同时展示责任人信息和最近帮扶时间SELECT f.id, f.name, f.village, f.household_type, u.real_name AS assist_user_name, (SELECT MAX(r.visit_date) FROM assist_record r WHERE r.farmer_id f.id) AS last_visit_date FROM farmer_info f LEFT JOIN sys_user u ON f.assist_user_id u.id WHERE f.deleted 0 AND f.household_type #{householdType} ORDER BY f.update_time DESC LIMIT #{offset}, #{pageSize}索引规划上我的经验是优先给这四类字段建索引外键关联字段、状态字段、时间字段、查询条件常用的组合字段。上面建表语句里的idx_village、idx_assist_user、idx_farmer_date都是这个思路。2.4 建模阶段容易踩的三个坑第一个坑字段名用到了 SQL 保留字。比如desc想表示“描述”level想表示“级别”order想表示“排序”这些词在 MySQL 里都有特殊语义直接用很危险。要么加反引号要么干脆改成description、level_value、sort_no。第二个坑金额和比例用浮点型。农产品售价、农户收入、帮扶物资金额这些字段用DOUBLE存表面看着没问题一旦做加总统计就会出现 0.1 0.2 ≠ 0.3 的精度问题。统一用DECIMAL(10,2)数值计算才不会翻车。第三个坑逻辑删除只删标记不处理唯一约束。比如“农户编号”如果设了唯一索引逻辑删除后再次插入同样编号的数据就会冲突。处理方式是唯一索引里把deleted字段也放进去或者编号字段生成时拼上时间戳保证物理上就不同。3. SpringBoot MyBatis 后端分层、配置与关键实现思路3.1 包结构与分层职责拿到一套 SpringBoot 源码第一步先别急着跑把包结构过一遍。这套系统用的是很标准的四层结构com.example.assist ├── controller // 接收请求、参数校验、返回结果 ├── service // 业务逻辑、事务控制 ├── mapper // MyBatis 的 Mapper 接口 ├── entity // 实体类与表字段对应 └── common // 统一返回结果、异常处理、工具类 ├── Result.java ├── BusinessException.java └── GlobalExceptionHandler.javacontroller层只做三件事接参数、调 service、返回Result。业务判断不要写在 controller 里service层是核心事务注解也是加在这一层entity和表字段一一对应不额外塞页面展示用的冗余字段展示字段可以单独用 VO 对象承载。为什么强调这个因为很多源码项目跑得通但一到改需求就痛苦问题基本都出在分层不清晰业务逻辑散落在 controller 甚至前端。分层清晰之后后面加接口、修 bug 的定位成本能下降一大截。3.2 配置文件里的关键项SpringBoot 的核心配置其实就几项但每一项都有讲究spring: datasource: url: jdbc:mysql://localhost:3306/assist_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver servlet: multipart: max-file-size: 10MB max-request-size: 20MB mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl server: port: 8080map-underscore-to-camel-case: true必须开这样数据库中create_time就能自动映射到实体类的createTime不用手写一堆映射配置。mapper-locations指向 XML 文件所在的目录复杂的动态 SQL 都写在 XML 里比注解拼接字符串可读性好太多。多环境配置我习惯拆成三个文件application.yml公共配置、application-dev.yml本地库、application-prod.yml服务器库通过spring.profiles.activedev切换。这样本地随便折腾上线时只需要指定--spring.profiles.activeprod数据库密码和连接地址不用改代码。3.3 轻量鉴权拦截器加 Token够用又不重这类内部管理系统我没直接用 Spring Security而是用“登录接口 拦截器 Token”的轻量方案。Security 功能强大但配置复杂还要理解过滤器链对一个小型前后端分离项目来说拦截器三个类就能搞定。核心做法是这样的用户登录成功后后端生成一个 Token用 UUID 或者简单 JWT 都行存到 Redis 或者内存 Map 里把 Token 返回给前端前端每次请求在 Header 里带Authorization: Bearer Token拦截器统一从 Header 取 Token校验通过后把用户信息塞到请求上下文里。public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求 if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String token request.getHeader(Authorization); if (token null || !TokenStore.isValid(token)) { response.setStatus(401); return false; } // 将用户信息放入请求上下文后续接口可直接获取 request.setAttribute(currentUser, TokenStore.getUser(token)); return true; } }注册拦截器时注意放行登录接口、图片资源、文件上传预览路径其余接口全部拦截。用 Redis 存 Token 还有个额外好处——退出登录时把 Token 删掉就能立即失效不用等客户端清缓存。3.4 事务与文件上传的特殊处理帮扶记录这个接口比较典型既要写assist_record表又要更新farmer_info的最近走访时间还可能要更新责任人的帮扶次数。这类多表写入必须加事务Transactional(rollbackFor Exception.class) public void addAssistRecord(AssistRecord record) { assistRecordMapper.insert(record); farmerInfoMapper.updateLastVisitTime(record.getFarmerId()); userMapper.increaseAssistCount(record.getUserId()); }rollbackFor Exception.class一定要写明。默认情况下事务只对 RuntimeException 回滚如果业务方法里抛了受检异常不加这个参数事务是不会回滚的数据就会出现“记录插进去了农户状态没更新”的半截状态。文件上传这块农产品的图片我用的是“本地磁盘存储 虚拟路径映射”。上传接口把文件保存到服务器指定目录数据库只存相对路径/upload/20250101/xxx.jpg访问时由后端做虚拟映射spring: mvc: static-path-pattern: /upload/** resources: static-locations: file:D:/upload/上传文件还要校验后缀和大小图片只允许 jpg、png、webp文件大小不能超过配置的 10MB。校验放后端不能只依赖前端限制因为接口是可以被直接调用的。3.5 源码运行五分钟跑起来的完整步骤拿到这套源码环境满足下面这个表格之后按步骤走一遍就能跑起来组件版本建议JDK8 或 17 均可二选一Maven3.6 以上MySQL5.7 或 8.0Node.js16 以上npm/yarn随意启动步骤创建数据库assist_db导入项目db目录下的init.sql脚本初始管理员账号也在这个脚本里修改application-dev.yml里的数据库用户名和密码后端执行mvn spring-boot:run或者编译后运行 jar 包前端进到web目录执行npm install再执行npm run dev浏览器打开http://localhost:5173用初始账号登录。跑不起来时先检查三件事端口是否被占用、MySQL 版本是否匹配驱动、连接 URL 是否带了serverTimezone参数。80% 的启动失败都出在这三个地方尤其是时区参数少了它 MyBatis 连数据库时经常报“Could not get JDBC Connection”的诡异错误。4. Vue3 前端项目管理、接口封装与列表页的高效写法4.1 目录结构按业务组织页面按功能组织代码前端工程用的是 Vite Vue3目录结构我习惯这样组织src ├── api // 所有接口请求函数按模块拆分 │ ├── auth.js │ ├── farmer.js │ └── product.js ├── views // 页面组件按业务模块建子目录 │ ├── farmer │ ├── assist │ ├── product │ └── dashboard ├── router // 路由配置 ├── store // Pinia 状态管理 ├── components // 公共组件 └── utils // 工具函数axios 实例等页面按业务模块建目录接口按模块拆文件这样后端起第四批模块的时候新增页面只需要在views下加目录、在api下加文件不需要动其他代码。4.2 axios 实例封装几十个接口不用反复写重复逻辑前端项目里最容易出现的问题是每个页面都自己写一个 axios 调用头部的 Token、错误弹窗、loading 提示散落一地。我的做法是把 axios 实例统一封装在utils/request.js里页面只关心业务数据import axios from axios import { ElMessage } from element-plus const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer token } return config }) request.interceptors.response.use( response response.data, error { const status error.response?.status if (status 401) { localStorage.removeItem(token) window.location.href /login } else if (status 500) { ElMessage.error(服务器内部错误) } return Promise.reject(error) } ) export default request封装之后每个接口文件里的函数就很清爽了。比如农户模块的接口import request from /utils/request export function getFarmerList(params) { return request.get(/farmer/list, { params }) } export function saveFarmer(data) { return request.post(/farmer/save, data) } export function deleteFarmer(id) { return request.delete(/farmer/${id}) }4.3 登录态与路由守卫不要让每个页面都判断有没有登录登录态维护我用的是最简单的方案Token 存localStoragePinia 里存用户基本信息和角色。路由守卫统一拦截router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next(/login) return } if (to.path /login token) { next(/dashboard) return } next() })菜单这块我没有做后端动态下发因为这类管理系统的菜单基本是固定的管理员、工作队员、帮扶责任人虽然菜单项不完全一样但变化幅度不大。前端根据角色的menuList控制显隐比维护一套动态路由配置成本低很多。等真正有几十种角色再考虑动态路由也不迟。4.4 农户列表页搜索、分页、编辑弹窗的固定套路列表查询是这类管理系统最高频的需求写法上我基本固定成一套模板searchForm存查询条件tableData存列表数据page存页码和每页条数loading控制加载状态。增删改查的套路就是“操作成功后重新拉一次列表”const searchForm reactive({ name: , village: , householdType: }) const page reactive({ current: 1, size: 10, total: 0 }) const tableData ref([]) const loading ref(false) async function loadData() { loading.value true try { const res await getFarmerList({ ...searchForm, current: page.current, size: page.size }) tableData.value res.records page.total res.total } finally { loading.value false } } function handleSearch() { page.current 1 loadData() } function handlePageChange(p) { page.current p loadData() }这里有一个容易被忽略的点搜索之后页码一定要重置回 1。不然你搜出来的结果只有 3 条但当前页还在第 5 页列表就会空白用户会以为是系统 bug。4.5 图片上传与回显路径拼接是最容易被细节绊倒的地方农产品管理、农户档案里都会用到图片上传。上传接口返回的通常是一个相对路径前端展示时要拼上访问地址。最容易出的问题是开发环境和生产环境地址不一样导致本地能显示部署上去图片全部裂图。我的做法是在接口返回相对路径后统一通过一个工具函数拼接完整地址export function formatFileUrl(path) { if (!path) return if (path.startsWith(http)) return path return import.meta.env.VITE_BASE_URL path }VITE_BASE_URL在.env.development里配http://localhost:8080在.env.production里配线上域名这样代码里不会出现硬编码地址换环境只需要改环境变量文件。5. 前后端联调与部署跨域、打包和上线避坑清单5.1 开发阶段跨域前端代理是最省事的方案前后端分离项目跨域是绕不开的第一步。开发阶段我推荐用 Vite 的代理配置而不是开后端 CORS。原因很简单代理只影响开发环境上线走 Nginx后端 CORS 配置如果开得太宽等于给生产环境埋了个安全漏洞。// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })开发环境里前端请求/api/farmer/listProxy 会把请求转发到http://localhost:8080/api/farmer/list前后端端口不一致的问题就消失了。后端如果还需要处理跨域配置一个很小的 CORS 过滤器只允许本地开发地址生产环境由 Nginx 来兜底。5.2 打包上线前端静态文件加后端反向代理的经典组合打包流程本身很简单后端执行mvn clean package -DskipTests产物是一个jar包用java -jar启动前端执行npm run build产物是dist目录里面是纯静态文件。部署时我用的是 Nginx Java 进程的组合这是最成熟也最容易维护的方案server { listen 80; server_name your-domain.com; # 前端静态文件 root /var/www/assist; index index.html; # 前端路由 history 模式刷新不 404 location / { try_files $uri $uri/ /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; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # 上传图片访问 location /upload/ { proxy_pass http://127.0.0.1:8080; } }这里最关键的是try_files $uri $uri/ /index.html这一行。Vue3 用 history 路由时用户访问/farmers这类前端路由Nginx 找不到对应的物理文件如果不回退到index.html刷新页面就会白屏 404。这个location /的配置就是专门解决这个问题的。5.3 进程守护Java 进程不能被一次宕机带走生产环境如果直接用java -jar xxx.jar 启动一旦 SSH 断开或者进程异常退出服务就没了。我的建议是用 systemd 管理 Java 进程[Unit] Descriptionassist-server Afternetwork.target [Service] Userroot WorkingDirectory/opt/assist ExecStart/usr/bin/java -jar /opt/assist/assist-server.jar --spring.profiles.activeprod Restartalways RestartSec10 [Install] WantedBymulti-user.target配置好之后执行systemctl enable assist-server开机会自启systemctl restart assist-server重启日志用journalctl -u assist-server -f实时查看。这套方案比写 shell 脚本 nohup 可靠得多也是我每次部署 Java 服务的默认动作。5.4 上线后最容易翻车的细节清单最后把我实际部署这类系统时踩过的坑整理成一张表每一条都是真实项目中会遇到的问题现象根本原因解决办法前端刷新页面 404Vue history 路由在 Nginx 上没有回退配置try_files $uri $uri/ /index.html接口请求一直 504Nginx 没配到 Java 进程或者端口不通先 curl 内网地址确认后端存活再查 Nginx 日志上传图片不显示前后端访问地址不一致统一使用VITE_BASE_URL拼接不硬编码数据库时间差 8 小时JDBC 连接未指定serverTimezoneURL 增加serverTimezoneAsia/Shanghai大分页查询越来越慢偏移量大时全表扫描列表分页改为基于主键或游标的分页方式逻辑删除的数据占用唯一索引唯一索引不包含删除标记唯一索引加入deleted字段或生成唯一业务编号写到这里我再给一个一直保留的工作习惯整套源码跑通之后把数据库脚本重新执行一遍用一个全新的库从零走一遍“初始化 → 登录 → 建档 → 填帮扶记录 → 上架农产品 → 下单 → 看统计”的完整流程。这个动作能暴露很多跑 demo 时发现不了的问题比如某个字段没有默认值、某个字典状态在前端没对应上、某条 SQL 在数据量大了之后明显变慢。系统可以不大但是链条必须完整把这条链路走通之后你对这套框架的理解会比单纯看源码深一个层次。