做野生动物保护这块东西,我一直觉得市面上缺一套真正能落地的业务系统。野外监测数据、救助记录、物种档案全散落在Excel里,既没法实时更新,也没法给巡护员、科研人员一个统一的入口。所以接到这个基于SpringBoot和Vue的野生动物生物保护网站项目时,我第一反应不是"又是个CRUD后台",而是把它当成一套典型的全栈实战项目来做:后端用SpringBoot扛接口、接MySQL和Redis,前端用Vue做单页应用,覆盖物种档案、视频监测、救助记录、巡护巡护打卡和数据分析这几个核心模块。这篇文章就把完整的设计思路、核心代码实现和部署过程中踩过的坑,逐步拆开写清楚,适合正在做Java毕设、Vue全栈练手,或者想给保护区场馆搭信息系统的朋友抄作业。
1. 项目整体设计与模块拆解
1.1 业务需求到底在解决什么问题
野生动物保护网站听起来像科普展示站,但真正跑起来之后你会发现,核心业务其实是"记录"和"追踪":巡护员拍到野生动物踪迹要上传,救助站送进一只受伤的鸟要登记治疗过程,科研人员要查某个物种在保护区内出现多少次,公众能看到清晰的物种科普和最新的监测数据。所以我的功能拆解逻辑很简单——谁在用、要录什么、要查什么。
系统用户分三类:管理员管全部数据和人员权限,巡护员录巡护日志和物种影像,访客看科普内容和公开数据。围绕这三个角色,我最终保留了六个核心模块:
- 物种档案库:物种名称、保护等级、分布区域、习性描述、图片和视频,状态分"待审核"和"已发布"。
- 实时监测视频:接入野外摄像头的录像/直播流,存视频源地址和截帧图,前端做点播。
- 救助记录管理:救助时间、发现地点、物种、伤情描述、救助过程、恢复状态。
- 巡护打卡与日志:巡护路线、出发返回时间、天气、发现记录,支持图片组上传。
- 科普文章发布:面向公众的内容管理,标题、封面、富文本正文、浏览量统计。
- 数据统计面板:按物种、月份、保护等级统计发现的物种数量,可视化展示。
前端用Vue做单页应用把以上功能串成工作台,后端用SpringBoot暴露RESTful接口。这个结构不炫技,但胜在清晰,每张表都能说清楚是为了哪个业务场景存在的。
1.2 技术选型为什么这么定
技术栈是标题里已经写死的:SpringBoot加Vue。具体版本上我用了SpringBoot 2.7.18加Vue 3加Vite加Element Plus,持久层选MyBatis-Plus而不是JPA,主要原因是这类管理网站查询场景多、多表关联和条件筛选频繁,MyBatis-Plus的分页插件、Wrapper构造器写起来效率远高于JPA。
缓存选Redis,主要缓存热点数据比如物种列表、轮播图、验证码,避免每次前端刷新都打MySQL。权限校验没有引入全套Spring Security,而是用JWT加拦截器做轻量鉴权——后台管理系统的路权限很简单,拦截器里判断token有没有、角色对不对就够了,没必要把OAuth那套东西引入进来增加复杂度。
数据库设计上有几个值得注意的约定。主键雪花ID,方便将来分库分表;时间字段统一用datetime且由Java层赋值;species_id、rescue_id这类外键逻辑关联不写物理外键,减少删除时的锁竞争。下面是物种档案和救助记录两张核心表的关键字段示例。
-- 物种档案主表 CREATE TABLE species ( id varchar(32) PRIMARY KEY, name varchar(64) NOT NULL COMMENT '物种名称', alias_name varchar(64) COMMENT '别名', category varchar(32) COMMENT '纲目科属', protection_level varchar(16) COMMENT '保护等级: 一级/二级/三有', distribution text COMMENT '分布区域', habits text COMMENT '生活习性', cover_url varchar(255) COMMENT '封面图', video_url varchar(255) COMMENT '科普视频', status tinyint DEFAULT 0 COMMENT '0草稿 1已发布', create_time datetime, update_time datetime ); -- 救助记录表 CREATE TABLE rescue_record ( id varchar(32) PRIMARY KEY, species_id varchar(32) COMMENT '物种ID', rescue_date date COMMENT '救助日期', location varchar(128) COMMENT '发现地点', finder varchar(32) COMMENT '发现人', injury_desc varchar(255) COMMENT '伤情描述', treatment text COMMENT '治疗过程', recover_status tinyint COMMENT '0治疗中 1康复 2放归 3死亡', create_by varchar(32), create_time datetime );2. 后端核心实现:难啃的骨头都在这里
2.1 登录鉴权用JWT加拦截器就够了
后台管理系统的鉴权我见过很多人一上来就配置Spring Security加OAuth2,结果光搞明白过滤链就花了两天。这个项目我选了更轻的组合:登录接口校验用户名密码后返回token,前端把token存进localStorage,每次请求通过axios拦截器放在请求头里,后端写一个AuthInterceptor统一校验。
token生成用io.jsonwebtoken:jjwt,Payload里放userId和role两个核心字段,过期时间设两小时,前端在axios响应拦截器里遇到401就跳回登录页。这里有个细节值得说:拦截器需要放行登录接口、静态资源、预览接口,所以我在配置里维护了一个白名单列表,避免游客访问公开页面也被弹到登录页。
@Component public class AuthInterceptor implements HandlerInterceptor { @Autowired private StringRedisTemplate redisTemplate; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行OPTIONS预检请求 if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { token = token.substring(7); } if (StringUtils.hasText(token) && JwtUtil.validate(token)) { // 可选: 检查token是否在Redis黑名单中,比如用户改密码后强制下线 String userId = JwtUtil.getUserId(token); request.setAttribute("userId", userId); return true; } response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"登录已过期\"}"); return false; } }拦截器里我特意加了一个Redis黑名单判断。用户修改密码、管理员禁用账号时,把旧token写入Redis并设置过期时间,这样被删掉的token即使JWT本身没过期也立即失效,比单纯依赖JWT过期时间安全得多。
2.2 全局过滤器处理上传场景里的XSS风险
热搜词里有一条"springboot项目全局过滤器处理上传pdf文件时xss攻击",这条确实是真实痛点。最初我只对普通的JSON请求体做XSS过滤,结果测试时发现上传文件接口被绕过:有人上传的文件名里带<script>alert(1)</script>.pdf,虽然文件本身没有执行脚本,但前端用innerHTML渲染文件名时就被注入了。所以我的过滤器必须同时处理普通请求体和multipart表单里的字符串字段。
实现思路是定义一个XssFilter,继承OncePerRequestFilter,对请求体做包装。JSON请求体读取后跑一遍HTML标签清洗,multipart请求则单独把参数值里的危险字符替换掉。注意不要把所有控制器都无差别过滤,上传图片base64内容、富文本正文的合法格式化标签是要放行的,所以我的过滤规则只针对<script>、<iframe>、on*事件属性、javascript:协议。
public class XssFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; String contentType = httpRequest.getContentType(); if (contentType != null && contentType.contains("multipart/form-data")) { // multipart: 包装请求,遍历参数做转义 chain.doFilter(new XssMultipartRequestWrapper(httpRequest), response); } else { chain.doFilter(new XssBodyRequestWrapper(httpRequest), response); } } }清洗函数核心是正则替换,这里我贴一下比较关键的一段。注意顺序很重要:先替换事件属性,再处理脚本标签,否则嵌套写法会绕过第一层。
public static String clean(String value) { if (value == null || value.isEmpty()) { return value; } // 移除 on* 事件属性 value = value.replaceAll("(?i)\son\\w+\\s*=\\s*['\"]?[^'\"]*['\"]?", " "); // 移除 script / iframe / object 标签及其内容 value = value.replaceAll("(?i)<script.*?</script>", ""); value = value.replaceAll("(?i)<iframe.*?</iframe>", ""); value = value.replaceAll("(?i)<object.*?</object>", ""); // 移除 javascript: 协议 value = value.replaceAll("(?i)javascript\\s*:", ""); return value; }这里要强调一个实战教训:清洗是写进过滤层的,所以后端所有接口的入参都会经过清洗,但输出到前端做展示时建议不再转义,避免出现<这种二次转义问题。我的做法是入库前清洗、展示时按字段区分:富文本用v-html渲染,普通字符串字段用Vue插值{{ }},Vue本身会转义,双重保险。
2.3 基因数据与图片分页查询怎么提速
后台管理列表最常见的需求是"按条件筛选加大分页"。如果每个接口都手写LIMIT加COUNT(*)SQL,代码会非常碎。MyBatis-Plus的分页插件在这里能省一半工作量,核心就两步:配置PaginationInnerInterceptor,然后直接用Page对象作为查询入参。
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL); pagination.setOverflow(false); // 防止页码越界,超过最大值返回空列表 pagination.setMaxLimit(500L); // 单页最多500条,保护接口 interceptor.addInnerInterceptor(pagination); return interceptor; } }ServiceImpl里配合LambdaQueryWrapper做条件拼接,注意一个容易出错的地方:eq和like的空值判断必须显式调用StringUtils.hasText,或者用condition参数,否则前端传空的查询条件会被拼成name like '%%',导致的额外数据扫描非常没必要。
@Override public PageResult<SpeciesDTO> pageSpecies(SpeciesQuery query) { Page<Species> page = new Page<>(query.getPageNum(), query.getPageSize()); LambdaQueryWrapper<Species> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.hasText(query.getName()), Species::getName, query.getName()) .eq(StringUtils.hasText(query.getProtectionLevel()), Species::getProtectionLevel, query.getProtectionLevel()) .eq(query.getCategory() != null, Species::getCategory, query.getCategory()) .orderByDesc(Species::getCreateTime); IPage<Species> result = speciesMapper.selectPage(page, wrapper); return PageResult.of(result.getRecords(), result.getTotal()); }分页插件会拦截你的selectPage自动生成COUNT语句,不需要你额外调SELECT COUNT(*)。但这里有个坑:联动查询如果只查主表没问题,如果分页的是多表关联结果,Wrapper里的last("LEFT JOIN...")写法会影响COUNT语句的生成,必要的时候我干脆手写selectPageVo自定义SQL,保证count查询不带多余join。
2.4 视频播放链路:m3u8转码与前端对接
野生动物保护网站里视频点播是刚需,巡护员上传的野外手机视频五花八门,MP4、MOV、AVI都有。统一转成m3u8切片再分发是我敲定的方案,好处有三:浏览器原生H5标签不吃AVI,m3u8协议支持拖拽进度和自适应码率,切片文件可以被Nginx直接静态托管,后端只负责出接口返回播放地址。
实操链路是这样的:上传接口收到文件后,先存本地临时目录,用FFmpeg执行转码命令生成m3u8索引文件和ts切片,再把整个切片目录传到OSS或对象存储。FFmpeg命令参考如下,关键参数是-hls_time控制每个切片时长、-hls_list_size 0表示生成全量列表而不是只保留最近几片。
ffmpeg -i input.mov -c:v libx264 -c:a aac -strict -2 \ -hls_time 10 -hls_list_size 0 -hls_segment_filename "video_%04d.ts" output.m3u8转码是耗时操作,我直接把它异步化:上传接口只保存原文件路径和转码状态,丢消息给线程池处理,转码完更新数据库状态。前端轮询视频状态,转为SUCCESS后再渲染播放器。
前端播放m3u8,我用的是hls.js,它会把m3u8索引解析成浏览器认识的Media Source。这里贴一个核心代码片段,注意Vue项目安装依赖用的是npm install hls.js,播放器组件实例销毁时一定要destroy,否则切路由后会有播放器实例泄漏,导致内存持续上涨。
<template> <video ref="videoEl" controls muted autoplay style="width: 100%"></video> </template> <script setup> import { ref, onMounted, onBeforeUnmount } from 'vue'; import Hls from 'hls.js'; const props = defineProps({ src: { type: String, required: true } }); const videoEl = ref(null); let hls = null; onMounted(() => { if (Hls.isSupported()) { hls = new Hls(); hls.loadSource(props.src); hls.attachMedia(videoEl.value); hls.on(Hls.Events.MANIFEST_PARSED, () => { videoEl.value.play(); }); } else if (videoEl.value.canPlayType('application/vnd.apple.mpegurl')) { // Safari直接支持m3u8 videoEl.value.src = props.src; } }); onBeforeUnmount(() => { if (hls) { hls.destroy(); hls = null; } }); </script>3. 前端工程搭建与数据交互细节
3.1 从零搭好Vue 3开发环境
Vue项目起步最快的还是Vite。之前用过Vue CLI的朋友会明显感觉Vite冷启动快到飞起,但代价是插件生态跟CLI略有区别。创建命令如下:
npm create vite@latest wildlife-web -- --template vue cd wildlife-web npm install npm install vue-router@4 pinia element-plus axios hls.js echarts npm run dev环境装好后通常第一件事是配vite.config.js的代理,把开发环境的/api转发到后端服务器,免去开发和联调期的跨域烦恼。配置如下,实际部署时Nginx再做一次同样路径的代理,前后端分离就彻底了。
// vite.config.js import { defineConfig } from 'vite'; import vue from '@vitejs/plugin-vue'; export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } } });很多新手在Vue Router配置路由的时候容易忽略一个问题:后台管理系统的路由和游客可见的路由混在一起,导致需要登录态的页面在未登录时也能直接输入URL访问。我的做法是把需要鉴权的路由统一放进一个requiresAuth元信息字段,配合全局前置守卫拦截。
3.2 路由鉴权与动态路由控制
vue-router里判断登录状态最直接的方式,是读取本地存储的token,然后在beforeEach里做跳转。代码不长,但有一点必须提醒:不要只在路由守卫里去localStorage拿token,登录状态会失效于"接口返回401"这个时刻,所以还要在axios响应拦截器的全局代码里做一遍清理和跳转。
// router/index.js const router = createRouter({ history: createWebHistory(), routes: [ { path: '/login', component: () => import('@/views/Login.vue') }, { path: '/admin', component: () => import('@/layout/AdminLayout.vue'), meta: { requiresAuth: true }, children: [ { path: 'species', component: () => import('@/views/species/List.vue') }, { path: 'rescue', component: () => import('@/views/rescue/List.vue') }, { path: 'statistics', component: () => import('@/views/statistics/Index.vue') } ] }, { path: '/', component: () => import('@/views/Home.vue') } ] }); router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.meta.requiresAuth && !token) { next('/login'); } else if (to.path === '/login' && token) { next('/admin/species'); } else { next(); } });动态路由方面,我给管理员配了"按角色渲染菜单"的需求。普通巡护员看不到数据统计面板,但管理员可以。这个场景应该动态添加路由,而不是把全部菜单列出来再靠v-if控制,这样更安全——没有注册路由,即使手敲URL也访问不到。Vue Router 4里提供router.addRoute()方法,在登录后拉取用户权限菜单,再逐一注册。
// 登录后根据角色加载菜单 const menuRes = await getUserMenu(); menuRes.forEach(menu => { router.addRoute('admin', { path: menu.path, name: menu.name, component: () => import(`@/views/${menu.component}`) }); });这里有个编译层面的实用提醒:import()里的路径如果写成全动态字符串,Vite会把整个views目录都打进产物,不利于分包加载。我最后选择了"菜单路由映射表",即前端预注册好所有可用组件,加载菜单时只做匹配,避免这个问题。
3.3 axios封装与接口层设计
axios封装这步如果不做,项目写到一半会出现灾难——每个页面的请求都在重复写错误提示、重复处理401跳转。我统一封装了请求实例,几个关键设计点:
- 请求拦截器统一加
Authorization: Bearer token头。 - 响应拦截器提前拆出
data字段,业务层拿到的直接是后端返回体。 - 401时清空localStorage并跳登录页,弹一次提示避免并发请求时连环跳转。
// utils/request.js import axios from 'axios'; import { ElMessage } from 'element-plus'; import router from '@/router'; const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/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; } ElMessage.error(res.msg || '请求失败'); return Promise.reject(new Error(res.msg || '请求失败')); }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token'); router.push('/login'); } else { ElMessage.error(error.message || '网络异常'); } return Promise.reject(error); } ); export default service;表格页面的搜索筛选、分页切换、重置这三件套是后台系统的日常操作。我的封装的请求方法直接返回Promise<data>,页面里用reactive定义查询参数,改一个字段就重新调列表接口,不需要手动维护组件状态和请求数据的同步。另外,Vue DevTools插件一定要装,Vue 3项目的组件树和Pinia状态调试都靠它,Chrome插件商店直接搜"Vue.js devtools"。
4. 前后端联调、部署上线与常见问题排查
4.1 跨域配置最容易踩的三处坑
开发环境用Vite代理能挡住大部分跨域问题,但生产环境部署时跨域仍然是重灾区。我的部署拓扑是Nginx监听80/443端口,静态文件指向前端打包产物目录,/api路径转发到后端SpringBoot的8080端口,这样浏览器请求的始终是同源地址,不存在跨域。
server { listen 80; server_name wildlife.example.com; root /opt/wildlife/dist; index index.html; location / { try_files $uri $uri/ /index.html; # 前端history路由回退 } 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; } }后端也配置了CORS兜底,防止有人直接走后端地址联调时被跨域卡住。配置CORS时有个坑,allowedOrigins不要写成*且allowedHeaders误删了Authorization,会导致带token的请求全部失败。
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("http://localhost:5173", "http://wildlife.example.com") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }4.2 Docker Compose一键部署全栈项目
部署环节我用Docker Compose把MySQL、Redis、后端、前端四个服务串起来。后端镜像的多阶段构建很有必要,第一阶段用Maven镜像编译,第二阶段用JRE基础镜像运行时拷贝jar,最终镜像体积能压缩一半以上。
# 后端 Dockerfile FROM maven:3.8-openjdk-8 AS build WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn package -DskipTests FROM openjdk:8-jre-slim WORKDIR /app COPY --from=build /app/target/wildlife-server.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]docker-compose.yml里定义服务依赖关系,注意MySQL数据持久化要挂volume,否则容器销毁数据就没了。后端服务的启动参数里通过SPRING_PROFILES_ACTIVE=prod切换生产配置,数据库地址直接用服务名mysql:3306而不是localhost。
version: "3.8" services: mysql: image: mysql:8.0 container_name: wildlife-mysql environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: wildlife_db ports: - "3306:3306" volumes: - mysql-data:/var/lib/mysql redis: image: redis:7.0-alpine container_name: wildlife-redis ports: - "6379:6379" backend: build: ./backend container_name: wildlife-backend depends_on: - mysql - redis environment: SPRING_PROFILES_ACTIVE: prod ports: - "8080:8080" frontend: image: nginx:alpine container_name: wildlife-frontend ports: - "80:80" volumes: - ./dist:/usr/share/nginx/html:ro - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro depends_on: - backend volumes: mysql-data:4.3 常见问题速查表
项目里踩过的坑太多了,我把最典型的几个整理成一张表格,方便大家碰到相同报错直接照方抓药。
| 问题现象 | 排查方向 | 解决参考 |
|---|---|---|
| 前端请求跨域 | Nginx配置、后端CORS类 | Nginx加proxy_pass后同时保留后端CORS兜底 |
| 刷新页面404 | Nginx未配置history回退 | try_files $uri $uri/ /index.html |
| 数据库中文乱码 | JDBC连接串缺字符集 | URL加useUnicode=true&characterEncoding=utf8 |
| MyBatis-Plus分页不生效 | 未注册分页插件或版本冲突 | 确认PaginationInnerInterceptor已注入 |
| 上传大视频超时 | Nginx和SpringBoot超时时间过短 | 后端spring.servlet.multipart.max-file-size和Nginxclient_max_body_size同时调 |
| m3u8播放卡顿 | ts切片过大或网络带宽不足 | FFmpeg改hls_time 6,或用低分辨率转码 |
| Docker中MySQL连不上 | 服务名解析失败 | 后端配置数据库地址写mysql而不是localhost |
4.4 性能优化数据总结
项目上线后我对核心接口做了压测和优化,调优前后的数据变化挺有参考价值。以"物种列表分页查询"为例,优化前直接用select *加count,本地环境一千条数据响应要180毫秒左右;优化后只查需要展示的字段,列表页加Redis缓存热点数据,响应降到45毫秒左右,接口吞吐量提升了一倍多。
另一处明显优化是图片懒加载和静态资源CDN。物种档案列表的封面图一次性加载会卡掉首屏,改成Vue的lazyload指令配合CDN加速后,页面加载LCP从4.2秒降到1.8秒,感官上流畅不少。具体做法不复杂:列表接口只返回图片URL,前端在图片进入视口前不发起HTTP请求,CDN侧对图片URL做缩放参数处理,缩略图用?x-oss-process=image/resize,w_300这类方式实时生成。
5. 后续我还会扩展的方向
项目上线已经稳定跑了一个多月,目前真实使用过程中我又发现了几个值得继续投入的点。第一,地图可视化,目前统计数据是全表格展示,后面我想接入地图库,把物种分布点叠加到保护区范围内,让巡护数据真正落在空间维度上,这对科研人员写报告会非常有用。第二,移动端适配,Vue单页在手机上能用但体验普通,后面优先是对巡护员做一套移动端适配的报到、上传页,毕竟野外现场打开笔记本太不现实。第三,离线缓存,野外信号不稳定,PWA方案可以先把档案数据缓存到本地,没有网络也能查,等有网了再同步。
这些方向不是拍脑袋想出来的,是我把后台日志里巡护员真实操作路径翻了一遍之后得出的结论。技术上没有捷径,就是老老实实让使用者去用,然后跟着他们骂的地方去改。
我个人实际做下来最大的体会是,SpringBoot加Vue这组技术栈"下限低、上限高":学会配置和理解过滤器、路由守卫、JWT这套组合拳之后,像野生动物保护网这样的全栈管理系统,你基本可以在一周内搭出完整骨架,剩下的时间都花在业务细节打磨上。过程中折腾过的m3u8转码配置、全局XSS过滤钩子、Docker镜像瘦身,每一个坑单独拎出来都够写一篇长文,但组合起来它们就是一套系统的真实骨架。
最后再分享一个小技巧:上线前给自己留半天时间专门做"破坏性测试"。用错参数调用接口、传超大文件、快速连点按钮,把异常路径都跑一遍,修复的隐藏问题可能比正常流程测试多得多。好项目都是这么磨出来的。