ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Spring Boot + Vue前后端分离实战:野生动物保护网站开发总结

2026/10/1 14:24:35 拓冰建站 浏览量
Spring Boot + Vue前后端分离实战:野生动物保护网站开发总结 去年我帮一家自然保护机构做了一套野生动物生物保护网站后端基于 Spring Boot前端用的是 Vue从数据库建模到服务器部署前后完整跑了一遍。这套系统的核心不是传统意义的“内容发布站”它管的是动物个体档案、野外监测记录、救助过程和保护区动态数据一旦对不上后续科研分析和保护决策全都会偏。如果你正准备做 Spring Boot Vue 的前后端分离项目尤其是偏业务管理类、数据采集类的系统这篇文章会帮你少走不少弯路。1. 野生动物保护网站要解决的真实痛点不是做几个CRUD那么简单1.1 从一张纸质巡护表说起不少保护区的日常数据还停留在 Excel 和纸质台账里。巡护员每天出去转一圈遇到动物就手写记录几点、在哪、几只、在干什么。照片贴在巡护本上或者存在个人手机里。到了月底科研人员想统计某片区域三个月内某种动物的出现频率得人工翻记录那场面我见过几个人围着几张表格对坐标、找照片效率非常低。这种流程的问题我用两个实际例子说明。第一坐标精度不一致。纸质记录写“核心区东侧”地图上根本无法还原更别说做栖息地分析。第二照片和记录分离。明明是同一只雪豹可能被不同巡护员写成两个编号档案就乱了。野生动物保护网站首先要解决的就是把这种零散信息变成结构化数据让记录从“写了就行”变成“录了能用”。1.2 三类用户、四条业务线实际落地时我没有做一刀切的权限控制而是把系统用户分成三类各自看到的东西完全不同。用户角色核心诉求功能侧重巡护员 / 保护站工作人员快速录入、减少手写监测记录上报、救助上报、档案查看、任务清单科研人员 / 管理员数据准确、可统计全库检索、种群趋势分析、救助审核、数据导出公众游客了解保护动态物种科普、保护资讯、公开统计信息业务线也拆成四条物种与个体档案、野外监测记录、救助管理、保护区动态。物种库是底层基础个体档案承接物种和每次观测救助管理是一条独立的强流程保护区动态主要做内容展示。这样拆完之后每个模块职责清晰前后端联调时不会出现“这个字段到底归谁管”的扯皮。1.3 容易被忽略的两个非功能需求我只说两个真正踩过坑的点数据审计和坐标脱敏。野生动物数据涉及珍稀物种不能像普通后台一样完全放开。每条监测数据都要记录录入人、录入时间和修改痕迹不然出了问题根本没法追溯。地图上的精确坐标只对管理员和科研人员开放公众端要做偏移显示或者只给到区域级别避免暴露栖息地。这个设计如果不在需求阶段定下来后面再加权限会非常痛苦前端所有组件都要跟着改。2. 技术选型与工程结构为什么是 Spring Boot Vue2.1 选型逻辑选 Spring Boot 的原因很直接生态成熟自动装配省掉了大量 XML 配置适合两三个人快速迭代。配合 MyBatis-Plus单表 CRUD 几乎不用手写 SQL开发效率能提升一大截。Vue 则胜在组件化和响应式地图、图表、表格这类交互复杂的页面都能找到成熟组件库不用自己从头造轮子。前后端分离的好处不用多说后端只出 JSON 接口前端只管渲染。这套组合特别适合管理后台加数据采集类系统团队不需要养一个全栈接口定义清楚之后前后端可以并行开发。2.2 工程目录划分后端我按功能分包而不是按技术层分包。这样每次加新模块只需要新建一个 controller-service-mapper 三元组定位问题也快。wildlife-backend ├── src/main/java/com/protect/wildlife │ ├── common // 统一返回、异常处理、常量 │ ├── config // Security、MinIO、WebMvc 配置 │ ├── controller │ ├── entity │ ├── mapper │ ├── service │ └── util └── src/main/resources前端目录也做了分层重点是把 api 单独拎出来所有接口地址都集中管理而不是散落在页面组件里。组件大了之后你会发现这一层能省不少事。wildlife-frontend ├── src │ ├── api // 接口定义统一放这里 │ ├── assets │ ├── components // 公共组件 │ ├── router // 路由配置 │ ├── store // Pinia 状态 │ ├── views // 页面 │ └── utils // axios、工具函数2.3 开发环境版本组合版本这块我吃过亏直接给出这套项目验证过的组合。组件版本说明JDK8稳定部署环境基本都有Spring Boot2.7.182.x 系列最后一个维护版本MyBatis-Plus3.5.3和 Spring Boot 2.x 兼容良好MySQL8.0.33用 utf8mb4 字符集Node.js18Vite 5 要求 18 以上Vue3.4配合 Element PlusVite5构建速度比 webpack 快很多这里说明一下没有直接用 Spring Boot 3.x是因为 3.x 要求 JDK 17而且部分 MyBatis-Plus 旧插件不兼容需要同步升级。如果你的服务器能跑 JDK 17完全可以上新版本如果和我一样要部署在客户已有的机器上2.7.18 反而是最省心的选择。3. 数据库设计野生动物数据为什么比普通业务表更难建模3.1 实体关系梳理我先梳理实体关系再动手建表。一个物种下面有多个动物个体一个动物个体会产生多条监测记录救助记录和动物个体关联保护区作为空间位置参考挂在观测记录和救助记录上用户操作全部留痕。这里不画 E-R 图了用文字描述就是物种一对外个体个体一对多观测救助一对一挂个体用户多对多操作日志。3.2 核心表结构设计挑几张关键表说。物种表不算复杂但保护等级字段建议直接用字符串不要用数字字典因为不同地区的保护等级表述不统一动不动就要加字典项。CREATE TABLE species ( id BIGINT PRIMARY KEY AUTO_INCREMENT, common_name VARCHAR(100) NOT NULL COMMENT 中文名, scientific_name VARCHAR(150) COMMENT 学名, category VARCHAR(50) COMMENT 类别兽类/鸟类/两栖等, protection_level VARCHAR(50) COMMENT 保护等级, habitat VARCHAR(255) COMMENT 栖息地, description TEXT COMMENT 形态描述, images TEXT COMMENT 图片URL逗号分隔, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) COMMENT 物种信息表;动物个体表是最核心的档案表个体编号要有业务规则比如区域代码加年份加序号。经纬度直接用 DECIMAL避免浮点误差。CREATE TABLE animal ( id BIGINT PRIMARY KEY AUTO_INCREMENT, species_id BIGINT NOT NULL COMMENT 关联物种ID, individual_code VARCHAR(50) NOT NULL UNIQUE COMMENT 个体编号, nickname VARCHAR(50) COMMENT 个体名称, gender VARCHAR(10) COMMENT 性别, estimated_age VARCHAR(30) COMMENT 估算年龄, health_status VARCHAR(30) COMMENT 健康状况, longitude DECIMAL(10,6) COMMENT 发现经度, latitude DECIMAL(10,6) COMMENT 发现纬度, location_description VARCHAR(255) COMMENT 地点描述, discover_time DATETIME COMMENT 发现时间, avatar_url VARCHAR(255) COMMENT 档案照片, status VARCHAR(20) DEFAULT ACTIVE COMMENT 档案状态, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) COMMENT 动物个体档案表;监测记录表是数据量最大的表字段拆得细一些后面统计分析才好做。天气、温度、行为这些字段别偷懒合并到一个备注里否则统计时全要重新清洗。CREATE TABLE observation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, animal_id BIGINT COMMENT 关联个体ID可为空, observer_id BIGINT NOT NULL COMMENT 记录人, observation_time DATETIME NOT NULL COMMENT 观测时间, longitude DECIMAL(10,6) NOT NULL COMMENT 观测经度, latitude DECIMAL(10,6) NOT NULL COMMENT 观测纬度, location_description VARCHAR(255) COMMENT 地点描述, behavior VARCHAR(100) COMMENT 行为状态, animal_count INT DEFAULT 1 COMMENT 数量, weather VARCHAR(30) COMMENT 天气, temperature DECIMAL(4,1) COMMENT 温度, image_urls TEXT COMMENT 图片URLJSON数组, remark VARCHAR(500) COMMENT 备注, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) COMMENT 野外监测记录表;救助记录表同样要有状态和时间节点后面会讲状态机这里先把字段留好。3.3 经纬度、照片和时间字段的处理细节坐标用 DECIMAL(10,6)换算成精度大概是米级野外监测完全够用。照片不要直接存 BLOB数据库会越来越大备份也慢。我习惯把多图存成 JSON 数组字符串后端用 Jackson 解析前端直接遍历渲染。如果想更规范可以建一张附件关联表但小团队项目用 JSON 字段更务实。时间字段有个容易忽略的坑统一用 datetime并且全系统约定一个时区。我这里直接用 Asia/Shanghai数据库连接串里也写明 serverTimezone不然 Jackson 序列化之后前端看到的日期会差 8 个小时排查起来非常隐蔽。3.4 统计查询的扩展设计救助成功率、种群数量趋势这类统计可以在救助表里冗余一个状态修改时间但更稳的做法是建统计视图避免业务写入时额外计算。MySQL 8 的窗口函数做同比环比很方便所以建表时尽量保留 create_time 这类原始字段别覆盖成“最后修改时间”否则想做趋势分析时发现原始时间没了那才头疼。4. 后端接口实战从登录鉴权到业务闭环4.1 Spring Security JWT 前后端分离登录权限我直接用 Spring Security JWT没有引入 Shiro。核心链路是登录接口校验用户名密码通过后生成 JWT前端每次请求在 header 里带上 Authorization后端用 OncePerRequestFilter 解析 token把用户信息塞进 SecurityContext最后通过方法级注解控制接口权限。JWT 过滤器核心代码Component public class JwtAuthenticationFilter extends OncePerRequestFilter { Autowired private JwtUtil jwtUtil; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { token token.substring(7); try { String username jwtUtil.getUsernameFromToken(token); if (username ! null SecurityContextHolder.getContext().getAuthentication() null) { UserDetails userDetails userDetailsService.loadUserByUsername(username); if (jwtUtil.validateToken(token, userDetails)) { UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } } catch (Exception e) { // token 非法直接放行后续接口会返回 401 } } filterChain.doFilter(request, response); } }接口上用注解控制权限比如只允许管理员更新物种信息PreAuthorize(hasRole(ADMIN)) PutMapping(/species/{id}) public ResultVoid updateSpecies(PathVariable Long id, RequestBody SpeciesDTO dto) { speciesService.updateById(dto.toEntity(id)); return Result.success(); }这里有个细节角色权限拼成 ROLE_ADMIN前端动态路由要根据这个字段过滤菜单所以要保证 JWT 里返回的 roles 和数据库一致。4.2 监测记录上报一条数据怎么走完整个后端链路巡护员上报一条监测记录看起来是简单插入但实际有业务规则。我先用 DTO 接参数进行坐标范围校验然后保存记录再更新动物个体的最近观测时间和健康状态。如果 animal_id 为空就只记录观测事件不更新档案。Service 核心代码Service public class ObservationServiceImpl extends ServiceImplObservationMapper, Observation implements ObservationService { Override public Long submit(ObservationDTO dto) { // 坐标范围校验经度 -180~180纬度 -90~90 if (dto.getLongitude() -180 || dto.getLongitude() 180 || dto.getLatitude() -90 || dto.getLatitude() 90) { throw new BusinessException(坐标超出有效范围); } Observation observation dto.toEntity(); observation.setObserverId(SecurityUtil.getCurrentUserId()); this.save(observation); // 如果关联了个体档案更新最近观测时间 if (dto.getAnimalId() ! null) { animalService.lambdaUpdate() .eq(Animal::getId, dto.getAnimalId()) .set(Animal::getLastObservationTime, observation.getObservationTime()) .update(); } return observation.getId(); } }这个接口在联调时容易忽略一点观测时间是业务时间不是系统当前时间。巡护员可能补录昨天的记录所以 create_time 和 observation_time 一定要分开存只用当前时间会丢数据。4.3 救助档案的状态机设计救助记录的业务流比较长待接收、治疗中、康复观察、已放归、死亡。一开始我没加状态约束结果测试时出现从“待接收”直接变成“已放归”的跳变逻辑上说不通。后来我用枚举加流转表定义允许的跳转关系。public enum RescueStatus { PENDING(待接收), TREATING(治疗中), RECOVERING(康复观察), RELEASED(已放归), DECEASED(已死亡); private final String text; // getter ... }在 Service 里做统一校验前端就算绕过按钮调接口后端也能拦住非法流转private static final MapRescueStatus, SetRescueStatus TRANSITIONS new HashMap(); static { TRANSITIONS.put(PENDING, Set.of(TREATING, DECEASED)); TRANSITIONS.put(TREATING, Set.of(RECOVERING, DECEASED)); TRANSITIONS.put(RECOVERING, Set.of(RELEASED, DECEASED)); // PENDING 和 TREATING 不可直接放归 }这里我的建议是状态字段用字符串还是枚举都可以但状态流转逻辑必须放后端。野外没有稳定的网络环境不能指望前端每次都拦截正确。4.4 MinIO 接入图片大文件不能直接进数据库监测照片、救助照片动辄几 MB数据库扛不住。我用了 MinIO私有化部署接口兼容 S3 协议不依赖外网。接入方式很简单先在 yaml 里配置minio: endpoint: http://127.0.0.1:9000 access-key: wildlife secret-key: change-me bucket: wildlife上传接口的核心逻辑是生成一个带日期的对象名避免文件名冲突PostMapping(/upload) public ResultString upload(RequestParam(file) MultipartFile file) { if (file.isEmpty()) { throw new BusinessException(文件不能为空); } String originalFilename file.getOriginalFilename(); String ext StringUtils.getFilenameExtension(originalFilename); String objectName LocalDate.now() / UUID.randomUUID() . ext; try { minioClient.putObject( PutObjectArgs.builder() .bucket(bucketName) .object(objectName) .stream(file.getInputStream(), file.getSize(), -1) .contentType(file.getContentType()) .build()); String url minioConfig.getEndpoint() / bucketName / objectName; return Result.success(url); } catch (Exception e) { throw new BusinessException(上传失败 e.getMessage()); } }实际部署时MinIO 域名要跟后端保持一致或者用 nginx 做代理不然前端页面是 HTTPS图片地址是 HTTP浏览器会直接拦截这个问题排查起来很费时间。4.5 统计报表接口的 SQL 写法统计部分不需要写 Java 代码MyBatis-Plus 里直接定义查询方法SQL 写在 XML 里更灵活。比如按月统计救助数量SELECT DATE_FORMAT(create_time, %Y-%m) AS month, COUNT(*) AS rescue_count FROM rescue_record WHERE create_time #{startTime} GROUP BY DATE_FORMAT(create_time, %Y-%m) ORDER BY month;再比如按区域统计观测次数需要对经纬度做网格化。项目中我用的是先取整数经纬度再 GROUP BY精度低一点但地图展示更直观。公众端不做精确坐标展示这个 SQL 直接服务脱敏需求。5. 前端页面实现让一线人员愿意用才算成功5.1 路由拆分与动态菜单前端用的是 Vue Router路由拆成两批常量路由给登录页和公开科普页动态路由根据角色返回的菜单权限过滤。admin 能看到系统管理菜单巡护员看不到点链接也会被路由守卫拦住。路由守卫里做 token 校验router.beforeEach((to, from, next) { const token localStorage.getItem(token); if (to.path ! /login !token) { next(/login); } else { next(); } });动态菜单的核心是后端返回当前用户可访问的路由表前端用 addRoute 注册。这里有个经验菜单标识不要用中文名用固定 code不然一旦改菜单名权限匹配就全乱了。5.2 Axios 请求层封装与错误处理前端请求统一封装在 utils/request.js 里目的是把接口地址、token、错误提示收敛到一个地方。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 { const res response.data; if (res.code 200) { return res; } if (res.code 401) { localStorage.removeItem(token); window.location.href /login; } ElMessage.error(res.message || 请求失败); return Promise.reject(new Error(res.message)); }, error { ElMessage.error(网络异常请检查连接); return Promise.reject(error); } );后端统一返回 Result 结构code、message、data 三件套。这样前端业务代码里不需要每个页面都写错误处理体验一致排查问题也方便。5.3 地图打点与录入地图这块我用了 Leaflet配 OpenStreetMap 的瓦片不需要商业 key。巡护员上报监测记录时直接点击地图取经纬度比手填数字准确得多。map.on(click, (e) { form.longitude e.latlng.lng.toFixed(6); form.latitude e.latlng.lat.toFixed(6); marker.setLatLng(e.latlng); });公众端展示监测点位时坐标要脱敏。我的做法是在后端根据角色判断非管理员返回的坐标做一次网格偏移保留两位小数约等于把点位模糊到公里级。这样既不影响大屏展示效果又不会暴露精确栖息地。5.4 ECharts 数据大屏从数据库到图表可视化用 ECharts数据大屏主要展示三类图种群个体数量趋势、救助类型分布、月度监测次数柱状图。加载方式要按需引入直接全量 import 会把包撑到 1MB 以上。import * as echarts from echarts/core; import { LineChart, BarChart, PieChart } from echarts/charts; import { GridComponent, TooltipComponent, LegendComponent } from echarts/components; import { CanvasRenderer } from echarts/renderers; echarts.use([LineChart, BarChart, PieChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer]);图表的数据从统计接口拿后端返回月份和数值数组前端直接 setOption。这里我的经验是图表配置项单独抽成自定义 hook不要在页面里堆几百行 option不然第二个人维护时根本不敢动。6. 部署上线和实测中踩过的坑6.1 前端打包与后端 JAR前端npm run build生成 dist 目录用 nginx 托管同时把/api路径反向代理到后端。后端mvn package打出 jar直接java -jar启动。nginx 配置参考server { listen 80; server_name wildlife.example.com; root /opt/wildlife-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; } }跨域问题我建议在 nginx 层解决后端不写复杂 CORS 配置。前端所有请求都走/api前缀后端 context-path 也设置成/api这样代码里没有硬编码域名换环境只需要改 nginx。6.2 MySQL 8 数据库初始化的坑MySQL 8 最典型的坑是时区和字符集。连接 URL 里没有serverTimezoneAsia/ShanghaiMyBatis-Plus 查询出来的时间字段会少了 8 小时。字符集必须用 utf8mb4不然生僻字和 emoji 存入会报错。建库语句CREATE DATABASE wildlife_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;另外 MySQL 8 默认sql_mode包含ONLY_FULL_GROUP_BY我之前统计接口里SELECT * ... GROUP BY直接报错必须把非聚合字段都写进 GROUP BY或者用函数包裹。这个遇到一次就记住了。6.3 图片域名和地图瓦片部署阶段最隐蔽的问题是混合内容。网站走了 HTTPS但 MinIO 还是 HTTP 地址浏览器把图片当成不安全资源拦截页面上的监测照片全部裂开。解决方式是用 nginx 给 MinIO 加一个 HTTPS 反向代理或者统一用内网地址加代理转发。地图瓦片同理。公网环境加载 OpenStreetMap 速度不稳定国内用户打开页面转圈很严重。我的处理方案是开发环境用在线瓦片生产环境把常用层级的瓦片缓存到本地静态目录或者接入国内地图服务商。6.4 日常运维日志与备份系统上线后日志要按天切割别让一个 log 文件涨到几个 G。备份直接写 crontab每天凌晨全量备份数据库保留最近 15 天。0 2 * * * mysqldump -u wildlife -p****** wildlife_db /opt/backup/wildlife_$(date \%Y\%m\%d).sql 0 4 * * * find /opt/backup -mtime 15 -name *.sql -delete这里的备份恢复一定要提前演练过别等到数据真出了问题才发现密码不对、磁盘满了。我吃过一次没演练的亏后来每次上线前都会在测试环境完整恢复一遍备份。做这套系统最大的感悟是技术选型不是越新越好Spring Boot 加 Vue 的组合之所以被大量项目使用就是因为生态成熟、资料多、团队接手成本低。真正决定项目成败的往往是你对业务细节的理解——经纬度怎么存、状态怎么流转、坐标怎么脱敏、照片怎么管理这些不起眼的地方才是后面稳定运行的根基。如果你也在做类似的项目希望这些记录能帮你少走几步弯路。