☰
基于Spring Boot+Vue的社区流浪动物救助系统设计
2026/10/30 19:39:51 网站建设 项目流程

简介:这份基于Java+SpringBoot+Vue+MySQL构建的社区流浪动物救助系统,是一套面向计算机专业毕业设计、课程设计及期末大作业的完整项目源码包,解决了从后台管理到前端交互的落地实现问题。压缩包共389个文件,约63.74MB,涵盖106个Java后端源文件、82个Vue前端组件、数据库SQL脚本、XML配置及图片资源等,代码与文档分层清晰,便于直接导入IDE运行。系统包含动物信息录入与修改、救助活动记录、志愿者注册与统计、领养申请处理等核心模块,界面美观且操作便捷,前后端分离架构便于二次开发。该资源已有62人浏览学习,适合需要参考完整前后端分离项目的高校学生。项目经严格调试和导师指导,可直接运行;内含源码、数据库脚本及工具配置说明,可帮助读者快速理解SpringBoot整合Vue的实战流程,并作为毕业设计答辩的可靠参考资料。

1. 从一张“待领养列表”说起:这套社区流浪动物救助系统到底解决什么

去年帮朋友搭一套校园流浪猫救助平台,我翻到一张十几人共享的 Excel 表格,上面是两百多只猫的档案:外貌描述靠备注,绝育情况靠猜,领养状态经常同时存在两份互相矛盾的更新。这就是社区流浪动物救助系统最典型的痛点:信息录入靠人,查询靠肉眼,状态流转靠群里吼。用 Java + Spring Boot + Vue + MySQL 做这类系统,本质是把“救助登记、动物档案、暂养、领养申请、审核、回访”做成一套有权限、有状态、可检索的 Web 应用。后端用 Spring Boot 出接口,前端用 Vue 写页面,MySQL 存全量数据。适合 Java 基础差不多、但想接触完整前后端分离项目的学生,也适合社区志愿者小规模自用。这类毕设能拿高分,不是因为功能多炫,而是它把真实业务闭环讲清楚了。

2. Spring Boot + Vue + MySQL 怎么分工:逻辑分层与救助系统的表结构设计

2.1 后端、前端、数据库各管哪一层

拿到这类“源码+数据库”压缩包,先别急着启动。理解分工比跑通更重要,不然答辩时被问一句“为什么 animal 表要单独放 status 字段”你就卡住了。

常见的分层方式是:Spring Boot 只提供服务端接口和业务规则,Vue 只负责页面渲染和用户交互,MySQL 负责数据落库。三者通过 JSON 串起来。后端不直接输出 HTML 页面,前端通过 axios 调用接口拿数据再渲染。Spring Boot 里的 @RestController 返回的是 JSON,这就是前后端分离的标志。

为什么这套组合能成为毕业设计的主流?原因很实际。Java 基础只要掌握面向对象、集合、JDBC 往上一点的层次,就能用 Spring Boot 写出完整业务;Vue 的 Element Plus 组件库把表格、表单、弹窗全做好了,前端工作量大幅下降;MySQL 免费、可视化工具多,导入导出脚本都方便。最重要的是,答辩时评委最关注的“事务、权限、状态流转”在 Spring Boot 里都有现成的表达方式——@Transactional 管事务、拦截器管登录、字段状态机管业务流转。

2.2 用 mysql 建一套救助系统:8 张核心表与字段设计

表结构是整个系统的地基,表设计错了后面全得返工。救助系统的信息架构,围绕“动物档案”这张主表展开:用户上报救助记录,管理员审核后把动物录入档案,动物可能进入暂养流程,等待领养人提交申请,管理员审核通过后状态改为已领养,之后还能做回访。

我一般会拆成 8 张表:sys_user(用户)、animal(动物档案)、rescue_record(救助记录)、foster_record(暂养记录)、adopt_application(领养申请)、follow_up_record(回访记录)、notice(公告)、favorite(收藏)。其中 animal 和 adopt_application 是整个系统的核心,核心 SQL 脚本大概是这个结构:

CREATE TABLE `sys_user` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL COMMENT '登录账号', `password` VARCHAR(100) NOT NULL COMMENT 'BCrypt加密后的密码', `real_name` VARCHAR(50) DEFAULT NULL COMMENT '真实姓名', `phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号', `role` TINYINT NOT NULL DEFAULT 0 COMMENT '0普通用户 1管理员', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `deleted` TINYINT DEFAULT 0 COMMENT '逻辑删除标记', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; CREATE TABLE `animal` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `name` VARCHAR(50) NOT NULL COMMENT '动物昵称', `species` TINYINT NOT NULL COMMENT '1猫 2狗', `breed` VARCHAR(50) DEFAULT NULL COMMENT '品种,如 橘猫/中华田园犬', `gender` TINYINT DEFAULT NULL COMMENT '1公 2母', `age` VARCHAR(20) DEFAULT NULL COMMENT '年龄,存'2岁'/'3个月'这类可读文本', `health_status` TINYINT DEFAULT 0 COMMENT '0健康 1待治疗 2康复中', `is_neutered` TINYINT DEFAULT 0 COMMENT '0未绝育 1已绝育', `cover_image` VARCHAR(255) DEFAULT NULL COMMENT '封面图URL', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待领养 1已领养 2暂养中 3已死亡', `description` TEXT COMMENT '救助故事/性格描述', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `deleted` TINYINT DEFAULT 0, PRIMARY KEY (`id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='动物档案表'; CREATE TABLE `adopt_application` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `animal_id` BIGINT NOT NULL COMMENT '申请的动物', `user_id` BIGINT NOT NULL COMMENT '申请人', `applicant_name` VARCHAR(50) NOT NULL COMMENT '领养人姓名', `phone` VARCHAR(20) NOT NULL COMMENT '联系电话', `address` VARCHAR(255) DEFAULT NULL COMMENT '居住地址,审核用', `reason` TEXT COMMENT '领养理由', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待审核 1通过 2驳回', `audit_remark` VARCHAR(255) DEFAULT NULL COMMENT '审核意见', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `audit_time` DATETIME DEFAULT NULL, `deleted` TINYINT DEFAULT 0, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='领养申请表';

字段里的几个细节要注意。动物年龄我存成字符串而不是整数,因为“2个月”和“1岁半”用整数不好表达,存文本反而方便前端原样展示。status 字段是整个系统的状态机核心,所有列表页都要按它筛选。逻辑删除字段 deleted 用 TINYINT 而不是直接 DELETE 物理删除,这样以后查“曾经领养过哪些动物”还有数据。字符集统一用 utf8mb4,否则用户提交的 Emoji 表情会变成问号。

2.3 从 zip 里的数据库脚本到本地运行:navicat 与命令行两种导入方式

数据库脚本在压缩包里通常是一个 .sql 文件。先用连接工具建一个空库,再导入脚本。命令行方式如下:

mysql -uroot -p --default-character-set=utf8mb4 -e "create database animal_rescue default charset utf8mb4;" mysql -uroot -p --default-character-set=utf8mb4 animal_rescue < animal_rescue.sql

命令行导入时,--default-character-set=utf8mb4 不能省。如果你用 Navicat,右键目标库选择“运行 SQL 文件”,同样要选 utf8mb4。导入完成后重点检查三张表有没有数据:sys_user 里应该有一个初始管理员账号(通常密码是加密后的 123456),animal 里最好有几条演示数据,adopt_application 里最好有一条“待审核”的数据——这些是后面启动项目看效果的关键。

MySQL 版本差异在这里开始体现。如果你本地装的是 MySQL 8.0,连接 URL 里要加 serverTimezone 参数;如果是 5.7,一般不加也没事。这个坑我在第 5 章会专门讲,但导入阶段就得先确认版本,不然项目启动报错你会怀疑是 SQL 脚本写错了。

3. 后端从零搭接口:Spring Boot 的项目骨架、实体映射与领养状态流转

3.1 pom.xml 依赖怎么选:Spring Boot 版本是最大的坑

Spring Boot 2.7 和 3.x 的差异非常大,网上大量教程、源码包都停留在 2.x。2023 年之后的 Spring Initializr 默认生成 3.x,3.x 把 javax 包名换成了 jakarta,导致很多老代码直接编译不过。这类毕设源码我建议锁 Spring Boot 2.7.18 + JDK 8,兼容性最好,教程最多。

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.2</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

这段依赖里,mysql-connector-j 是 MySQL 8.0.31 之后官方改的名字,老写法 com.mysql:mysql-connector-java 在 Spring Boot 2.7.18 里也能用但会有警告。MyBatis Plus 3.5.3.2 是兼容性比较好的版本,再往上 3.5.4 和 3.5.5 对 JDK 版本有要求。

3.2 的 application.yml 配置:数据源、驼峰映射与逻辑删除

Spring Boot 的配置集中在 application.yml 里。数据源 URL 要带时区参数,MyBatis Plus 的配置决定你能不能少写大量 XML:

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/animal_rescue?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 file: upload-dir: ./upload

这里三个配置容易漏。map-underscore-to-camel-case 打开后,数据库的 create_time 字段自动映射到 Java 实体的 createTime 属性,不用写一堆 resultMap。logic-delete-field 指定所有实体的 deleted 字段作为逻辑删除标记,之后调用 deleteById 时 MyBatis Plus 会自动改成 UPDATE deleted=1,而不是物理删除。file.upload-dir 是自定义配置,用 @Value("${file.upload-dir}") 注入,后面文件上传会用到。

3.3 从表到实体:用 MyBatis Plus 把 SQL 结果映射为 Java 对象

实体类是对照表结构写的,每个字段对应一个属性。用 Lombok 的 @Data 省去 getter/setter,MyBatis Plus 的注解把 Java 类和表关联起来:

@Data @TableName("animal") public class Animal { @TableId(type = IdType.AUTO) private Long id; private String name; private Integer species; private String breed; private Integer gender; private String age; @TableField("health_status") private Integer healthStatus; @TableField("is_neutered") private Integer isNeutered; @TableField("cover_image") private String coverImage; private Integer status; private String description; @TableField("create_time") private LocalDateTime createTime; @TableField("update_time") private LocalDateTime updateTime; @TableLogic private Integer deleted; }

字段名里 is_neutered、cover_image 这种带下划线的,靠驼峰映射能自动对上,但 @TableField 显式标注更保险。@TableLogic 加在 deleted 上,查询时自动追加 AND deleted=0。Mapper 接口只需要继承 BaseMapper,连 XML 都不需要:

@Mapper public interface AnimalMapper extends BaseMapper<Animal> { }

3.4 分页查询与领养申请接口:把“待领养列表”做成真正的搜索

分页是这类系统的高频需求。MyBatis Plus 的 PaginationInnerInterceptor 要先注册成 Bean,分页查询才生效:

@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }

分页接口的写法很固定,Controller 接收参数,Service 层组装查询条件:

@RestController @RequestMapping("/api/animal") public class AnimalController { @Autowired private AnimalMapper animalMapper; @GetMapping("/page") public Result<Page<Animal>> page(@RequestParam(defaultValue = "1") long pageNum, @RequestParam(defaultValue = "10") long pageSize, String keyword, Integer species, Integer status) { LambdaQueryWrapper<Animal> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.hasText(keyword), Animal::getName, keyword) .eq(species != null, Animal::getSpecies, species) .eq(status != null, Animal::getStatus, status) .orderByDesc(Animal::getCreateTime); return Result.success(animalMapper.selectPage(new Page<>(pageNum, pageSize), wrapper)); } }

这段核心逻辑在 wrapper 的四个条件里。like 的第一个参数是“是否追加这个条件”——keyword 为空时不加模糊搜索;eq 同理,species 和 status 为 null 时不过滤。这样前端少传参数不会报错,多传参数也能精准筛选。Page 对象返回给前端时包含 records(当前页数据)、total(总条数)、current、size,前端分页组件直接绑定这四个字段。

领养申请的接口比分页多一层“状态校验”,需要加事务:

@Transactional public boolean applyAdopt(Long animalId, Long userId, AdoptApplication apply) { Animal animal = animalMapper.selectById(animalId); if (animal == null || animal.getStatus() != 0) { throw new BusinessException("该动物当前不可领养"); } AdoptApplication application = new AdoptApplication(); application.setAnimalId(animalId); application.setUserId(userId); application.setApplicantName(apply.getApplicantName()); application.setPhone(apply.getPhone()); application.setAddress(apply.getAddress()); application.setReason(apply.getReason()); application.setStatus(0); adoptApplicationMapper.insert(application); // 动物状态先改为暂养中,防止其他人继续申请 animal.setStatus(2); animalMapper.updateById(animal); return true; }

先查动物状态,不是“待领养”就直接抛异常;插入申请表后把动物改为“暂养中”,保证同一时间只有一个领养流程在进行。审核通过时把 animal 状态改为“已领养”,审核驳回时改回“待领养”。这就是整个系统的核心状态流转,答辩时把这个讲清楚,比你背十个接口都有说服力。

4. Vue 前端页面与接口对接:路由权限、Axios 封装和分页搜索联动

4.1 从 vue-router 开始:按角色拆页面结构

前端用 Vue 3 + Vite + Element Plus。Vue 3 是当前主流,Vite 比 Webpack 启动快很多,Element Plus 是 Vue 3 对应的组件库。创建项目的命令是 npm create vite@latest animal-front -- --template vue。

路由设计直接对应系统功能:登录页、首页、待领养列表、动物详情、领养申请、个人中心、管理员后台。管理员后台需要单独保护,所以用路由元信息 meta 标记角色:

// src/router/index.js import { createRouter, createWebHistory } from 'vue-router' const routes = [ { path: '/', redirect: '/animals' }, { path: '/login', component: () => import('../views/Login.vue') }, { path: '/animals', component: () => import('../views/AnimalList.vue') }, { path: '/animals/:id', component: () => import('../views/AnimalDetail.vue') }, { path: '/my', component: () => import('../views/MyApplications.vue') }, { path: '/admin', component: () => import('../views/admin/AdminLayout.vue'), meta: { requiresAdmin: true }, children: [ { path: 'animals', component: () => import('../views/admin/AnimalManage.vue') }, { path: 'adopts', component: () => import('../views/admin/AdoptAudit.vue') } ] } ] const router = createRouter({ history: createWebHistory(), routes }) router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (!token && to.path !== '/login') { next('/login') } else { next() } }) export default router

beforeEach 是全局前置守卫。每次路由跳转前先查 localStorage 有没有 token,没有就强制去登录页。requiresAdmin 的校验放在守卫里进一步判断当前登录用户的 role 字段。用 createWebHistory 是 HTML5 路由模式,URL 更干净,但部署时要在 Nginx 做刷新回退,否则直接访问 /admin 页面会 404。不想折腾就用 createWebHashHistory,URL 带个 # 号但省事很多。

4.2 Axios 封装:登录态与错误处理的统一出口

页面里不直接调用 axios,而是封装一个 request 实例,统一处理请求头、错误提示、401 跳转。原来后端接口都挂在 /api 下,这层封装之后业务代码会干净很多:

// src/utils/request.js import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' const service = axios.create({ baseURL: '/api', timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }) service.interceptors.response.use( res => { if (res.data.code === 200) { return res.data.data } ElMessage.error(res.data.message) return Promise.reject(new Error(res.data.message)) }, err => { if (err.response && err.response.status === 401) { localStorage.removeItem('token') router.push('/login') } ElMessage.error(err.response?.data?.message || '网络请求失败') return Promise.reject(err) } ) export default service

request 拦截器给每个请求自动加上 Authorization 头,后端通过拦截器校验 JWT 判断登录状态。response 拦截器把后端返回的 data 直接剥离,业务代码里拿到的就是干净的数据,不需要每处都写 res.data.data.xxx。401 统一清 token 跳登录页,避免每个页面重复写判断。

4.3 待领养列表页与 mysql 分页参数联动

列表页是系统的门面。搜索栏、表格、分页器三者要实现“查询条件变化后自动重新加载数据”。用 Vue 3 的 script setup 写法:

<template> <div> <el-form inline> <el-input v-model="query.keyword" placeholder="动物名称" clearable /> <el-select v-model="query.species" placeholder="种类" clearable> <el-option label="猫" :value="1" /> <el-option label="狗" :value="2" /> </el-select> <el-button type="primary" @click="handleSearch">搜索</el-button> </el-form> <el-table :data="list" v-loading="loading"> <el-table-column prop="name" label="名称" /> <el-table-column prop="species" label="种类" width="80"> <template #default="{ row }">{{ row.species === 1 ? '猫' : '狗' }}</template> </el-table-column> <el-table-column prop="status" label="状态" width="100"> <template #default="{ row }"> <el-tag :type="row.status === 0 ? 'success' : row.status === 1 ? 'info' : 'warning'"> {{ statusText[row.status] }} </el-tag> </template> </el-table-column> <el-table-column prop="createTime" label="录入时间" /> </el-table> <el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="total" :page-sizes="[5, 10, 20, 50]" layout="total, sizes, prev, pager, next, jumper" @size-change="loadData" @current-change="loadData" /> </div> </template> <script setup> import { reactive, ref, onMounted } from 'vue' import request from '@/utils/request' const list = ref([]) const total = ref(0) const loading = ref(false) const statusText = ref(['待领养', '已领养', '暂养中', '已离世']) const query = reactive({ keyword: '', species: null, pageNum: 1, pageSize: 10 }) async function loadData() { loading.value = true try { const data = await request.get('/animal/page', { params: query }) list.value = data.records total.value = data.total } finally { loading.value = false } } function handleSearch() { query.pageNum = 1 loadData() } onMounted(loadData) </script>

联调时最容易翻车的点就在这里。后端 Page 对象返回的是 records 和 total,前端表格绑定 list,数量一不对就是参数名没对齐。尤其是 axios 的 params 传参,数组和 null 会被序列化成不同的样子,后端如果没做空值处理,查询条件就会失效。搜索引擎查询和分页参数 pageNum、pageSize 必须和后端 @RequestParam 的默认值一一对应,这是前后端分离项目“对字段名”的第一课。

5. 打包部署与 4 个必踩的坑

5.1 前端打包放进 springboot 的 static 目录:合并部署还是分开部署

毕业设计演示最省事的做法是前后端合并部署:Vue 打包成静态文件放进 Spring Boot 的 static 目录,最终只需启动一个 Java 进程。前后端同源之后,连跨域问题都一并消失了。

cd animal-front npm run build # 产物在 animal-front/dist rm -rf ../animal-backend/src/main/resources/static/* cp -r dist/* ../animal-backend/src/main/resources/static/ cd ../animal-backend mvn clean package -DskipTests java -jar target/animal-rescue-0.0.1-SNAPSHOT.jar

打包前注意一件事:前端 axios 的 baseURL 如果是相对路径 /api,那么前后端只要同端口就不需要任何跨域配置;如果写成 http://localhost:8080/api 这种绝对路径,部署到服务器上就得改代码重新打包。所以我前端一直用相对路径。如果坚持前后端分开部署在 8080 和 5173 两个端口,后端需要配 CorsFilter 允许跨域,前端 Vite 开发环境再用 proxy 转发,这一套反而复杂。

5.2 坑一:mysql 8.0 时区报错,启动直接失败

一次启动 Spring Boot 项目,控制台刷出一长串红色异常,核心信息是The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized。这是因为 MySQL 8.0 的时区默认值不是标准时区标识,JDBC 驱动没法解析。

原因:数据库服务器和 JDBC 驱动之间的时区协商失败,本质是 MySQL 8.0 比 5.7 更严格。解决:连接 URL 加参数serverTimezone=Asia/Shanghai&useSSL=false,或者登录 MySQL 执行SET GLOBAL time_zone = '+08:00'。第二种方式要重启 MySQL 才永久生效,第一种方式改一次配置就能覆盖所有环境。

注意不是所有“时区报错”都长一个样。如果你在 Linux 服务器上部署,系统时区改成 UTC,那么即便加了 serverTimezone 也可能差 8 小时。排查时先执行date看系统时区,再执行mysql -e "select now()"看数据库时区,最后看 JDBC URL 里的时区参数,三层对齐才算解决。

5.3 坑二:springboot 版本太高导致 jakarta 包名冲突

把源码导入 IDEA,默认用 Spring Boot 3.2 重新构建,结果大量javax.annotation.Resource找不到、javax.servlet.http.HttpServletRequest报错。这是 Spring Boot 3.x 的破坏性变更——Java EE 迁移到 Jakarta EE,所有 javax 开头的包名都改成了 jakarta。

原因:老源码基于 javax 编写,Spring Boot 3 只认 jakarta。解决:不要用 3.x。在 pom.xml 的 parent 里明确指定spring-boot-starter-parent版本为 2.7.18,同时本地 JDK 用 1.8 而不是 17。Spring Boot 2.7.x 是 2.x 系列的最终维护版,也是兼容 javax 的最后一个大版本线。

还有个小细节会坑到你:IDEA 里如果全局配置的 Maven JDK 是 17,即使 pom 指定了 2.7.18,编译时也可能报source option 8 is no longer supported。需要在 IDEA 的 Settings 里把 Project SDK、Maven 的 JDK、Modules 的 language level 三处都改成 8,这台是纯环境问题,跟代码无关。

5.4 坑三:vue-router history 模式刷新页面 404

本地开发一切正常,打包部署到 Nginx 后,在页面内点击跳转没问题,但手动刷新或者直接访问http://ip/admin就是 404。浏览器地址栏明明有这个路径,Nginx 返回的却是 index 之外的错误。

原因:history 模式的路由是纯前端模拟的,服务器上并不存在 /admin 目录。Nginx 收到 /admin 请求后去找对应文件,找不到自然 404。解决有两个方向。一是改前端路由用createWebHashHistory,URL 变成/#/admin,这种模式刷新不会请求服务器。二是保留 history,在 Nginx 配置里加上 try_files:

location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; }

生产环境我倾向保留 history 模式,URL 更干净,但 try_files 必须配好。如果用的是 Spring Boot 内嵌 Tomcat 部署而不是 Nginx,那么后端需要加一个专门的转发 Controller 或配置 fallback,否则同样会 404。

5.5 坑四:文件上传在本机能用、服务器上就失败

图片上传功能是救助系统的刚需——动物档案必须有照片。开发和演示时图片本地上传、本机显示都正常,一到服务器就出问题:要么上传后图片打不开,要么直接报FileNotFoundException或 403。

原因有两个层面。第一是路径问题:开发时用相对路径 ./upload,Tomcat 的当前目录在 IDE 里是项目根目录,在服务器上则取决于你执行 java -jar 的位置,路径不一致导致文件写到了奇怪的地方。第二是权限问题:Linux 下用 nobody 用户运行 Nginx,而上传目录属于 root,Nginx 读不了文件自然 403。

解决:后端配置里改成绝对路径,比如/data/animal-rescue/upload,并在启动前手工创建目录并授权。同时 Spring Boot 要注册静态资源映射,把 /upload/** 指向真实目录:

@Configuration public class WebConfig implements WebMvcConfigurer { @Value("${file.upload-dir}") private String uploadDir; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadDir + "/"); } }

如果用 Nginx 做前端部署,图片不走 Spring Boot 更好,直接在 Nginx 配置location /upload/ { alias /data/animal-rescue/upload/; },由 Nginx 直接返回图片文件,Java 进程只负责接收和落盘。这个方案能减少 Java 静态文件处理的开销,部署时也更灵活。这些坑每一个都曾经让我在答辩前夜加班到凌晨,提前排查一遍,真的能省出改论文的时间。

6. 拿到源码后的验证方法:从“跑通”到“加分”

6.1 用一条业务主线验证闭环

启动完项目别急着截图写论文,先在系统里走通一条完整路径:用户注册登录 → 上报一条救助记录 → 管理员审核后创建动物档案 → 前台展示待领养列表 → 另一个用户提交领养申请 → 管理员审核通过 → 动物状态变成已领养 → 记录回访信息。这条主线走通,系统就没有大的逻辑硬伤。验收时打开数据库看 animal 表,确认状态字段从 0 变成 2 又变成 1,比只看页面截图更能说明问题。

6.2 三个能写进论文的扩展点

第一个是领养回访提醒:用 Spring 的 @Scheduled 定时任务,定期扫描领养满一个月的记录,给领养人发送短信或站内信。第二个是微信小程序端:现有 Vue 接口直接复用,小程序只换壳,查接口时用微信开发者工具代替浏览器。第三个是救助地图:给 animal 表加 latitude 和 longitude 字段,调用高德地图地图组件把所有待领养动物标记在地图上,视觉效果立竿见影。

我自己做这类项目时最深的体会是:先把表结构和状态流转图画清楚再动手写代码,否则改表和接口时没有后悔药。遇到前后端参数对不上,不要当成玄学,打开浏览器 Network 面板看请求体比猜代码快得多。希望这篇笔记能帮你把这个方向做扎实,拿到一份真正讲得清楚的毕业设计。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询