1. 项目到底要做什么:需求拆解与方案选型
1.1 流浪宠物领养平台的核心业务场景
流浪宠物领养平台这个题目,在高校毕业设计里算是非常经典的一类,它的本质其实是一个典型的“信息撮合类”业务系统。你往大了看,它可以类比成宠物界的“闲鱼”或“公益版58同城”,往小了看,就是一个宠物信息管理后台加一个用户浏览申请前台。
但别因为它经典就觉得简单,恰恰相反,这类系统覆盖的业务链条相当完整。从用户侧来看,普通访客可以浏览待领养宠物列表、查看宠物详情(照片、年龄、健康状况、性格描述)、提交领养申请,还能查看个人申请进度;从管理侧来看,管理员需要维护宠物信息的上架和下架、审核用户提交的领养申请、管理公告通知、处理用户账号状态。有时还会加一些辅助功能,比如宠物寄养登记、寻宠启事、爱心捐赠记录等。
这些功能听起来不多,但落地到代码上,一套完整的用户端、管理端、数据库、接口文档、部署方案做下来,量其实不小。也正因如此,这个题目非常适合用来系统性地练一遍前后端分离开发的全流程——从需求分析、数据库建模、后端接口开发、前端页面联调,到最后的打包部署,每一环都能学到具体的东西。
我在设计和实现这个项目时,没有盲目堆功能,而是先把核心痛点拎出来:流浪宠物信息分散、领养流程不透明、审核机制缺失。所以最终确定的角色模型是普通用户和管理员,核心业务模型是“宠物信息发布—用户提交申请—管理员审核—线下交接确认”这条主线,其他功能都围绕这条主线做延伸。
1.2 为什么选SpringBoot+Vue这套前后端分离方案
现在社区里做这类管理系统的技术方案大概有几种流派:纯JSP+Servlet的老派做法、Spring Boot + Thymeleaf服务端渲染的做法、Spring Boot + Vue前后端分离的做法,以及一些更轻量的方案比如Node.js + React。这几种我都有接触过,如果从学习价值和实际工作衔接度两个维度来看,SpringBoot + Vue这套组合是最稳妥的。
首先说SpringBoot。它把Spring家族里大量繁琐的XML配置给干掉了,通过自动装配机制让你用最少量的配置就能把一个Web项目跑起来。对于做毕设或者练手项目的人来说,SpringBoot最大的价值在于“你不需要是一个Spring配置专家也能把项目启动”,但你又不能完全不懂Spring的底层逻辑,这种恰到好处的难度曲线非常合适。而且SpringBoot天然集成了内嵌Tomcat,打包成jar直接就能跑,部署成本极低。
再说Vue。Vue在国内前端社区的使用率一直居高不下,学习曲线比React平缓,对新手极其友好。Vue的响应式数据绑定、组件化开发、Vue Router路由管理、Vuex/Pinia状态管理,这些概念恰好覆盖了一个前端项目从零搭建到上线的大部分知识点。更重要的是,Vue生态里的Element UI(现在新版叫Element Plus)组件库,做管理后台简直是开箱即用,表格、表单、弹窗、分页全都有现成组件,能省掉大量重复的样式和交互代码。
前后端分离本身也是一种更接近真实工业界的工作模式,前端只管页面渲染和数据展示,后端只管接口逻辑和数据处理,两边通过约定的JSON格式进行通信,各改各的,互不干扰。这个项目的开发过程里,我基本就是两个窗口来回切,后端写一个接口,前端立刻调一下测试,联调效率比传统的服务端渲染高很多。
1.3 技术栈清单与版本选择经验
具体到我这个项目,完整的技术栈清单如下,每一层都可以单独拿出来写经验:
| 层次 | 技术选型 | 版本/说明 |
|---|---|---|
| 后端框架 | Spring Boot | 2.7.18(JDK8兼容性好,稳定) |
| ORM层 | MyBatis-Plus | 3.5.x,内置分页插件和代码生成器 |
| 数据库 | MySQL | 8.0以上,字符集utf8mb4 |
| 安全认证 | JWT + 拦截器 | jjwt 0.9.1,无状态认证方案 |
| 前端框架 | Vue | Vue 2.x + Element UI(兼容性和资料最多) |
| 前端构建 | Vue CLI / Vite | Vue CLI 4.x或5.x,Vite也可以 |
| HTTP请求 | Axios | 统一封装请求拦截器和响应拦截器 |
| 文件存储 | 本地存储 + Nginx映射 | 也可接MinIO,看实际部署环境 |
| 部署 | Docker Compose 或 传统jar+nginx | 推荐Docker方式来演示 |
版本选择这里我要多说一句。很多新手上来就装最新版,Spring Boot 3.x、Vue 3.x、Element Plus一套组合拳打下来,结果光是踩兼容性坑就能消耗掉一半的开发时间。我的经验是:如果目标是快速做完一个可用系统,而不是研究新技术,那就老老实实选一套经过社区大量验证的稳定组合。Spring Boot 2.7.x + Vue 2.x + Element UI是目前中文社区资料最丰富、遇到问题最容易搜到解决方案的组合。至于Spring Boot 3.x和Vue 3.x,等你把这套流程跑通之后,再迁移过去也不迟,核心逻辑都是相通的。
2. 数据库设计:把业务落到表结构上
2.1 核心数据表与设计思路
数据库设计是这类项目的命脉,表结构建不好,后面写SQL和业务逻辑的时候处处别扭。我设计的时候遵循了一个原则:宁可多拆几张表,也不要在一张表里塞一堆含义模糊的冗余字段。
这个平台最终落了8张表,核心的6张是这么设计的:
用户表(user):字段包含id、username、password(BCrypt加密)、nickname、phone、avatar、role(0表示普通用户,1表示管理员)、status(0禁用、1正常)、create_time、update_time。这里有个细节值得注意:用户名和手机号都加上唯一索引,防止重复注册。
宠物信息表(pet):字段包含id、name、category(猫/狗/其他)、breed(品种)、age、gender、health_status(健康状态描述)、vaccine_status(是否已打疫苗)、sterilization_status(是否已绝育)、description(详细描述,用TEXT类型)、cover_image(封面图URL)、images(多图,用JSON字符串存)、status(0待审核、1已上架、2已下架、3已领养)、publisher_id(发布者ID)、create_time、update_time。
我把status字段做了细分,特别是“已领养”这个状态,它不是一个简单的删除逻辑,而是业务闭环里的一个终点。宠物一旦被领养,这条记录在列表里就不能再展示,但管理后台仍然可以查看历史记录。
领养申请表(adoption_application):字段包含id、pet_id、user_id、applicant_name、applicant_phone、address(居住地址)、home_condition(家庭环境描述,比如是否有封窗、是否有其他宠物)、reason(申请理由)、status(0待审核、1已通过、2已拒绝、3已完成)、reject_reason(拒绝原因,审核不通过时填写)、create_time、update_time。这个表是整个平台审核逻辑的核心载体,设计得细一点,管理员审核的时候才有依据。
公告表(notice):字段包含id、title、content、cover_image(可空)、status(0草稿、1已发布)、create_time、update_time。
其他还有收藏表(favorite)、管理员操作日志表(operation_log)这一类辅助表,看个人需要决定加不加。我建议加上收藏表,因为“收藏”这个功能虽然不起眼,但它能明显提升用户的使用粘性,而且实现起来就是用关联表查一下,成本很低。
2.2 表关系与关键索引设计
这个项目里的表关系其实不算复杂,但是有几处需要特别注意:
用户—宠物:一对多。一个用户可以发布多条宠物信息,所以pet表里的publisher_id指向user表的id。注意这里的逻辑不要和“管理员录入宠物”搞混,实际项目中管理员也可以代发布,所以可以加一个字段区分来源是“用户发布”还是“管理员录入”。
用户—领养申请:一对多,宠物—领养申请:一对多。一个用户可以对多只宠物发起申请,但这里有一个需要前端和后端同时控制的逻辑:同一个用户不能对同一只宠物重复提交领养申请。这个约束单靠前端按钮置灰是不够的,必须在后端查询里做一次校验,否则恶意用户直接调接口就能绕过。
用户—收藏:多对多。通过favorite表做关联,favorite表里user_id和pet_id加联合索引。
哪些字段要建索引,我的经验是结合实际的查询需求来定。这个系统里高频查询有这几类:宠物列表按状态和分类查询、领养申请按用户或按宠物查询、收藏列表按用户查询。对应的索引就是pet表的status + category联合索引,adoption_application表的user_id、pet_id单列索引,favorite表的user_id + pet_id联合索引。至于create_time这种字段,分页排序的时候也会用到,可以顺手加上,但不用太纠结,数据量小的时候索引收益没那么明显。
表关系图我建议用Navicat或者DBeaver画出来,写文档的时候贴上去会很加分。具体表结构的SQL脚本要注意用utf8mb4字符集,不然存emoji表情或者生僻字的时候会报错,这是一个很经典的低级坑。
2.3 状态字段设计:从“待审核”到“已完成”
很多新手做这类系统的时候,对状态字段的处理非常随意,要么用String类型随便存个“待审核”“已通过”,要么用int存裸数字不写注释,导致后面接手的人完全看不懂。我的做法是用int类型存枚举值,同时在代码里用常量类或枚举类统一定义。
宠物状态我定义为:0待审核、1已上架、2已下架、3已领养。领养申请状态定义为:0待审核、1已通过、2已拒绝、3已完成。这里的“已完成”和“已通过”不一样:管理员审核通过后状态是1,此时用户看到的是“申请通过,等待联系”,后面双方线下完成交接,管理员再把状态改成3,宠物表里的状态同步更新为“已领养”。
这个“二次状态确认”的设计思路,是为了让整个领养流程有始有终。如果不加“已完成”这个状态,管理员一旦审核通过,流程就断了,后续宠物是否真的被领养走了完全无据可查。加了之后,管理后台可以形成一个完整的数据闭环,也算是一个业务逻辑上的亮点,写论文的时候可以重点描述。
前端展示的时候,状态值需要统一做映射处理。我的做法是在前端定义好常量映射,比如const PET_STATUS = {0: '待审核', 1: '已上架', 2: '已下架', 3: '已领养'},然后通过计算属性或者过滤器来展示,避免每个页面散落着一堆魔法数字。
3. 后端核心模块实现要点
3.1 项目初始化与基础配置
后端项目的创建我推荐直接用IDEA的Spring Initializr,也可以去start.spring.io网站生成后导入IDEA。最核心的几个配置项:
JDK版本选8或11都行,我用的JDK8。Spring Boot版本选2.7.18,这个版本是目前2.x系列的最后一个版本,修复了大量已知问题,稳定性最好。依赖方面需要引入Spring Web、MyBatis-Plus、MySQL驱动、Lombok、jjwt(用于JWT生成与解析)、Hutool(工具类,非必需但好用)。
application.yml的核心配置长这样:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/pet_adoption?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 servlet: multipart: max-file-size: 10MB max-request-size: 20MB mybatis-plus: 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 jwt: secret: your-secret-key expire: 604800注意几个容易踩坑的地方。第一,url里的serverTimezone如果不配置,高版本MySQL驱动会报时区错误。第二,characterEncoding=utf8和characterEncoding=utf8mb4不一样,必须用后者。第三,MyBatis-Plus的逻辑删除配置很重要,我这边是设置了deleted字段自动拼接WHERE deleted = 0,避免全表扫描时把已删除的数据查出来。
Lombok的引入能省掉大量getter/setter代码,但要注意IDEA必须安装Lombok插件,否则编译直接报错找不到方法。这是一个环境问题,很多新手卡在这里。
3.2 JWT身份认证与拦截器设计
登录认证方案我选了JWT,而不是传统的Session。原因有三点:前后端分离之后,Session的跨域处理比较麻烦;JWT是无状态的,服务端不需要存储会话信息,水平扩展的时候更轻松;JWT本身包含用户身份信息和过期时间,前端拿到之后存到localStorage里,每次请求带上Authorization头即可。
JWT工具类主要做三件事:生成token、解析token、校验token是否过期。生成时把用户的id、角色和用户名放进去,签名算法用HS256,密钥放在配置里。
拦截器配置这步是后端最关键的一个环节。SpringBoot里我implements HandlerInterceptor,重写preHandle方法,在方法里从请求头取Authorization,如果token不存在或者解析失败,直接返回401状态码,然后设置Content-Type为application/json;charset=UTF-8,返回一个统一的JSON错误体。前端拿到401之后,自动跳转到登录页。
同时,还需要在WebMvcConfigurer里注册拦截器并设置排除路径。排除路径至少包括:登录接口、注册接口、宠物列表查询接口、宠物详情接口、图片访问路径、以及前端的静态资源。这些做完了,才算真正划清了哪些接口需要认证、哪些接口可以匿名访问。
我实际做的时候还加了一个小细节:管理员接口和普通用户接口的权限区分。管理员相关的接口地址统一以/admin/**开头,拦截器里除了校验token有效性,还额外校验token里携带的role字段是否为管理员,不是则返回403。这样权限控制就集中在一个拦截器里完成了,不会在业务代码里散落大量重复的权限判断。
3.3 宠物管理接口与分页查询
宠物模块是所有接口里最核心的部分,主要包含发布宠物、宠物列表分页查询、宠物详情、修改宠物信息、上下架、删除这几个接口。
分页查询这里我强烈推荐MyBatis-Plus的分页插件,它用起来非常简单:配置一个MybatisPlusInterceptorBean,然后加入PaginationInnerInterceptor,之后调用page()方法就能自动完成分页。
@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }宠物列表查询接口要做成一个多条件筛选的分页接口,支持的查询参数包括:petName关键字模糊查询、category分类筛选、status状态筛选、排序字段(按时间倒序或按浏览量倒序)。这里直接用MyBatis-Plus的LambdaQueryWrapper操作即可,代码可读性很高。
public Page<Pet> getPetList(int pageNum, int pageSize, String keyword, Integer category, Integer status) { Page<Pet> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<Pet> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.isNotBlank(keyword), Pet::getName, keyword) .eq(category != null, Pet::getCategory, category) .eq(status != null, Pet::getStatus, status) .orderByDesc(Pet::getCreateTime); return petMapper.selectPage(page, wrapper); }宠物发布接口要处理封面图和多图上传的逻辑。图片我先存到服务器的一个固定目录(比如/usr/local/upload/),然后返回访问URL,前端拿到URL后拼接到表单数据里一起提交。这里有个数据完整性的问题需要考虑:如果用户上传了图片但没有最终提交表单,这些图片会变成孤儿文件。我的处理方式是前端提交的时候先传图片拿到URL,再提交表单数据,如果表单提交失败,前端主动调用删除图片的接口清理。
3.4 领养审核流程的核心逻辑
领养申请流程,是这个项目里业务逻辑最复杂的部分。具体实现上分为两个角色视角:
用户端:用户选中一只宠物,进入详情页后点击“申请领养”,弹出一个表单,填写姓名、联系方式、居住地址、家庭环境、申请理由。提交后生成一条adoption_application记录,status默认为0。同一个用户对同一只宠物只能有一条待审核状态的申请,这个我上面提到了,用后端查询做校验。
管理端:管理员在后台看到待审核列表,点进详情查看用户的申请资料,选择通过或拒绝。如果拒绝,必须填写拒绝原因,用户端会看到这条申请的状态变为“已拒绝”并展示原因。如果通过,这条申请的status变为1,用户端宠物详情页对当前用户显示“申请已通过,请留意电话通知”,管理员可以进一步把状态改为“已完成”,完成整个闭环。
这里有一处容易忽略的并发问题:同一只宠物可能同时被多个用户申请,而宠物只能被领养一次。所以管理员在将申请状态改为“已完成”时,需要同时给出一个事务性的操作:更新申请表状态的同时,把pet表的status从1改为3(已领养)。这个操作必须要加@Transactional注解,保证两条SQL要么都成功,要么都失败,否则会出现申请状态显示已完成但宠物还在列表上出售的脏数据。
@Transactional public void completeAdoption(Long applicationId) { AdoptionApplication application = applicationMapper.selectById(applicationId); Pet pet = petMapper.selectById(application.getPetId()); application.setStatus(3); applicationMapper.updateById(application); pet.setStatus(3); petMapper.updateById(pet); }3.5 图片上传实际上手与路径处理
图片上传看起来简单,但牵扯到的路径问题相当容易出幺蛾子。我的方案是本地磁盘存储 + 后端映射虚拟路径 + 部署时用Nginx转发,这个组合在当前绝大多数小型项目里都够用。
首先在配置里定义一个自定义属性upload.path,指向服务器的一个磁盘目录。然后配置一个WebMvc的静态资源映射,把/files/**这个URL路径映射到该磁盘目录。
@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/files/**") .addResourceLocations("file:" + uploadPath + "/"); }这样前端访问http://localhost:8080/files/xxx.jpg就能直接看到图片。上传接口用MultipartFile接收文件,生成文件名的时候我建议用UUID + 原始文件后缀名,避免文件名冲突,也能防止中文路径问题。
String originalFilename = file.getOriginalFilename(); String ext = StringUtils.substringAfterLast(originalFilename, "."); String filename = UUID.randomUUID().toString().replace("-", "") + "." + ext;另外,必须对上传文件做类型白名单校验。后端校验后缀名还不够,图片被改后缀名为.jpg也能上传,建议通过file.getContentType()判断MIME类型,同时限制文件大小。我的经验是,做毕设的系统虽然不用像企业系统那样把安全等级拉满,但至少要在代码里体现出这个意识——热词里有一条“springboot项目全局过滤器处理上传pdf文件时xss攻击”,说明很多人关心上传场景的安全校验,这绝对是个加分项。
如果本地没有部署Nginx的条件,也可以直接把虚拟路径指到Nginx的静态资源目录。总之不要让前端通过相对路径拼接图片地址时出现404就算了,图片能访问只是第一步,目录的读写权限和路径规范也要一并处理好。
4. 前端页面与交互实现
4.1 Vue项目初始化与核心配置
前端我用Vue CLI创建项目,执行vue create pet-adoption-web,然后选择Vue 2的默认配置项。这里插一句,网上很多教程推荐用Vite来创建Vue项目,Vite确实很快,但Vite + Vue 2的兼容性配置相对繁琐,用Vue CLI图个省心,等熟练之后想换Vite随时可以换。
创建完成后的第一件事是安装Element UI和Axios:
npm install element-ui -S npm install axios -S然后在main.js里做全局注册:
import Vue from 'vue' import ElementUI from 'element-ui' import 'element-ui/lib/theme-chalk/index.css' import App from './App.vue' import router from './router' Vue.use(ElementUI) Vue.config.productionTip = false new Vue({ router, render: h => h(App) }).$mount('#app')路由配置建议按模块拆分。核心路由我分了三个模块:用户端首页和宠物列表(游客可访问)、用户中心(需要登录)、管理后台(需要管理员权限)。Vue Router的路由守卫(beforeEach)在这里派上用场:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') const role = localStorage.getItem('role') if (to.meta.requiresAuth && !token) { next('/login') } else if (to.meta.requiresAdmin && role !== '1') { next('/403') } else { next() } })4.2 Axios封装:统一处理请求与响应
Axios如果直接在组件里到处import axios然后写上完整URL,开发到后面一定会变得很难维护。我的做法是在src/utils/request.js里做一次统一封装。
封装的核心包含四个方面:
第一,请求头统一携带token。请求拦截器里从localStorage取出token,设置为Authorization头。
第二,响应统一处理。后端返回的数据格式约定为{ code: 200, message: 'success', data: ... },响应拦截器里判断code是否为200,如果不是就组件上抛出错误提示。
第三,统一错误处理。401状态码表示token过期或未登录,直接清除本地存储并跳转登录页;其他状态码用Element UI的Message提示后端返回的message。
第四,基础URL统一下沉。开发环境用/api前缀,配合Vue CLI的devServer代理转发到后端8080端口,解决开发联调时的跨域问题。
import axios from 'axios' import { Message } from 'element-ui' import router from '@/router' const service = axios.create({ baseURL: process.env.VUE_APP_BASE_API || '/api', timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = token } return config }) service.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { Message.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res }, error => { if (error.response && error.response.status === 401) { localStorage.clear() router.push('/login') } Message.error(error.message || '网络错误') return Promise.reject(error) } ) export default service这样的封装用完一次就会上瘾,所有页面的请求都变成request.get('/api/pet/list', { params })这样的形式,页面代码非常干净。
4.3 宠物首页与列表页开发
首页是整个项目最先呈现在访客面前的页面,视觉上一定要说得过去。我的首页布局是:顶部导航栏 + 宠物卡片瀑布流 + 底部公告区。宠物卡片用Element UI的el-card组件,图片区域用el-image做懒加载,内容显示宠物名字、品种、年龄、健康状态等核心信息,点击卡片跳转详情页。
宠物列表页是核心页面,功能上要支持顶部分类筛选(全部/猫/狗/其他)、关键字搜索、排序(最新发布/人气最高),底部用el-pagination组件做分页。这些交互本质上都是先把筛选条件同步到路由的query参数上,然后调用后端接口刷新列表数据。
这里有一个交互上的经验点:分类筛选不要用新的路由页面,而是通过更新query参数来切换,比如/pet/list?category=cat&page=1。这样的好处是用户刷新页面后筛选条件不会丢失,而且可以直接通过URL分享当前的筛选结果,体验感会好很多。
宠物详情页要注意的信息比较多:宠物基础信息、详细描述、多图预览、领养按钮、收藏按钮。我在详情页做了一个判断逻辑:如果当前用户已经申请过这只宠物,按钮显示为“已申请”,点击无效;如果宠物状态不是“已上架”,按钮置灰显示对应状态。这些前置的用户体验设计减少了大量不合法的后端请求,也让系统看起来更完整。
4.4 管理后台的实现思路
管理后台是Vue项目的另一个重要模块,布局上用经典的左侧菜单 + 顶部栏 + 右侧内容区。我用嵌套路由来实现,父路由是/admin,子路由包括/admin/pet(宠物管理)、/admin/application(领养审核)、/admin/notice(公告管理)、/admin/user(用户管理)。
宠物管理页面用el-table展示数据,行数据包含宠物缩略图、名字、分类、状态、发布时间、操作按钮(编辑/上下架/删除)。缩略图直接渲染图片的URL,如果图片挂了,用@error事件替换为默认占位图。
领养审核页面这里是整个后台交互最密集的地方。我设计了两个Tab:待处理和已处理。待处理Tab的每一行都有一个“审核”按钮,点击弹出对话框展示完整的申请资料,管理员在对话框里选择通过或填写拒绝原因后提交。已处理Tab展示的是历史的审核记录,支持按审核结果筛选。
用户管理页面比较简单,列表展示注册用户的基本信息和注册时间,操作上提供禁用/启用按钮。禁用用户时,后端逻辑不仅要改用户表的状态,还要处理该用户已提交但还在审核中的数据——具体怎么处理看业务需求,我这边是保留数据但用户无法登录,等解禁后继续走流程。
5. 前后端联调、打包与部署
5.1 开发环境跨域的两种常规解法
前后端分离开发时,前端页面跑在localhost:8081(Vue Cli默认端口),后端接口跑在localhost:8080,两个端口不同,浏览器会直接产生跨域问题。解决方式有两种:
第一种是后端加CORS全局配置。SpringBoot里可以用@CrossOrigin注解加在Controller上,也可以写一个全局的CorsFilter。我建议写全局配置,一次性生效:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }第二种是前端做代理。在vue.config.js里配置devServer的proxy,把/api开头的请求代理到后端地址:
module.exports = { devServer: { port: 8081, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } }这两种方式我都用过的体会是:开发阶段用前端代理更方便,因为后端接口路径不需要做任何适配;但如果后端有人直接访问Swagger测试接口,会增加一些跨域限制的沟通成本。两者可以同时配置,并不冲突。需要注意的一点是,如果配置了CORS,allowedOriginPatterns("*")和allowCredentials(true)同时用时,不能写成allowedOrigins("*"),否则浏览器会拒绝带cookies的请求。
5.2 前端打包与Nginx配置实践
开发完成之后就是构建部署阶段。前端先执行npm run build,产物生成在dist目录。这个目录里就是一堆静态资源:index.html、css/js文件、图片等。把这些文件放到Nginx的html根目录下,再配一个反向代理把/api请求转发到后端jar包的8080端口,就完成了整套部署。
这里分享一份我实际用的Nginx配置,里面加了两个容易被忽略的细节,一个是SPA路由的history模式fallback,另一个是gzip压缩:
server { listen 80; server_name localhost; gzip on; gzip_min_length 1k; gzip_types text/plain text/css application/javascript application/json image/svg+xml; root /usr/share/nginx/html; index index.html; 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; } }第一个细节很关键。Vue Router如果使用history模式,前端跳转路由时URL看起来是/pet/detail/1这样的真实路径,但刷新这个页面时Nginx会先去磁盘上找有没有对应该路径的文件,找不到就会返回404。try_files $uri $uri/ /index.html;这句会让所有找不到文件路径的请求都回退到index.html,由前端路由接管,刷新就不会白屏了。
第二个细节是gzip,这个配了之后静态资源传输体积能压缩60%以上,页面加载速度快一截。虽然说配置很简单,但它属于那种“没配也不会出错、配了体验明显提升”的点,有条件就顺手打开。
5.3 后端打包与启动
后端部署我用的是Maven打包成可执行jar包。打包前需要注意:application.yml里配置的数据库地址、上传路径等,要根据生产环境做调整。我建议通过Spring Boot的多环境配置机制来实现,分别建application-dev.yml和application-prod.yml,启动时用--spring.profiles.active=prod指定环境,避免改代码和重新打包。
打包命令很简单:
mvn clean package -DskipTests打完包在target目录下会生成一个xxx.jar文件,放到服务器上执行:
nohup java -jar pet-adoption.jar --spring.profiles.active=prod > app.log 2>&1 &启动日志输出到app.log文件里,方便排查问题。如果服务器内存有限,可以加-Xms256m -Xmx512m限制JVM内存占用,这是我在2G小内存服务器上跑多个服务时学到的经验。
6. 实际开发中遇到的高频问题与排查实录
6.1 MyBatis-Plus分页插件不起作用
这个坑我估计十个用MyBatis-Plus做毕设的人有八个会踩。明明配置了PaginationInnerInterceptor,但调用selectPage返回的数据不带total字段,或者分页SQL根本没有LIMIT语句。
排查思路是这样的:先确认PaginationInnerInterceptor的Bean是否真的被Spring容器加载了。一个很经典的错误是把MybatisPlusInterceptor类给弄错了,注意在较新版本中它是在com.baomidou.mybatisplus.extension.plugins.inner包下面,不要引成旧版的PaginationInterceptor,旧版只兼容MyBatis-Plus 3.4.x以下。
还有一种情况是配置了Bean但项目里同时存在多个MyBatis-Plus配置类,导致拦截器链被覆盖。检查有没有多余的MybatisConfiguration或者MybatisSqlSessionFactoryBean配置,有的话把分页插件也加到那个自定义配置里。
6.2 图片上传后通过URL访问404
上传接口返回成功,但前端拿到URL后图片却显示404,这个问题多半是静态资源映射没生效。排查步骤:先试着手动拼接URL在浏览器直接访问,如果报404,说明addResourceHandlers没有生效。
常见原因有两个。一个是addResourceHandlers方法所在的配置类没有被Spring扫描到,导致注册没有生效。另一个是Linux服务器的磁盘路径权限问题,上传目录如果不存在,Spring也不会帮你自动创建,必须在启动前手动mkdir -p创建目录,或者用Java代码在启动时自动创建。
还有一个容易被忽略的点:Windows和Linux的路径分隔符不一样。上传路径如果写死了D:/upload/这种Windows风格路径,部署到Linux上就会出问题。我建议用配置项外置,同时代码里统一用Paths.get()来拼接,这样兼容性最好。
6.3 前端联调时CORS报错但CORS配置已经写了
明明在SpringBoot里配置了CorsRegistry,前端请求还是报跨域错误。原因可能是:请求通过代理方式转发,虽然前端配置了proxy,但Nginx(或Vue devServer)转发时没有正确处理。也可能是拦截器直接拦了OPTIONS预检请求——浏览器在做跨域POST请求之前会先发一个OPTIONS请求探路,如果你的SpringBoot拦截器把OPTIONS请求也当作正常请求拦截并要求带token,预检请求返回401,浏览器就会认为跨域失败。
解决办法是在拦截器的排除路径里把OPTIONS请求放行,或者直接判断:
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; }这个坑排查的时候特别容易忽略,因为后端CRUD接口的日志里完全没有OPTIONS请求的记录,实际上它被拦截器在入口就处理掉了。
6.4 Vue项目npm install各种报错
这些年Vue项目装依赖的报错基本集中在两个方向:网络问题和版本兼容。网络方面,npm默认源在国外,国内访问经常超时,直接换成淘宝镜像:
npm config set registry https://registry.npmmirror.com版本兼容方面最常见的场景是Node.js版本太新导致一些旧包编译失败。某个版本的node-sass如果和当前Node版本不匹配,就会报Module build failed这类错误。处理方式有两种:换成对应的node-sass版本,或者干脆换成sass(dart-sass),sass兼容性更好,推荐直接放弃node-sass换用sass包。
另外,如果项目中配置的依赖版本和npm解析出来的版本有冲突,导致node_modules目录出现各种奇怪问题,最简单的处理方式是删掉锁文件和依赖目录重新装一遍:
rm -rf node_modules package-lock.json npm install --registry=https://registry.npmmirror.com这个操作能解决绝大部分“莫名其妙”的依赖问题。
6.5 常见问题与解决方案速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 后端启动时报Access denied for user | 数据库账号密码不对或权限不足 | 检查application.yml中的账号密码和MySQL授权 |
| 查询结果的时间字段差了8小时 | 时区配置不一致 | JVM、MySQL、Jackson三层都要统一设置为Asia/Shanghai |
| 前端提交表单后请求报400 | 后端实体类字段和前端字段名不一致 | 核对JSON字段名,开启map-underscore-to-camel-case映射 |
| 修改密码后旧token还能访问 | JWT过期时间太长或无状态校验 | 业务上处理:用户修改密码后强制清除前端token |
| Vue页面加载后白屏 | 路由或组件路径写错 | 打开控制台查看报错,检查import路径大小写 |
| 部署到服务器后图片无法上传 | 磁盘写入权限不足 | 给上传目录设置chmod 777或单独创建账号授权 |
这个表是开发过程中比较典型的几类问题,整理出来放进论文的测试章节里也会比较好看,明显是属于“真实开发过程中遇到的坑”而不是空话套话。
个人实操经验总结
这个项目做完之后,我最大的一个体会是:这类平台类项目的难点从来不是某个单独的技术点,而是所有技术点串在一起之后形成的那条完整的链路。从数据库表结构开始,每一张表的关系、每一个状态字段的流转、每一条接口的权限控制、每一个前端页面的联动交互,都像是一条流水线上的某个环节,任何一个环节没有处理好,最后联调的时候就会暴露出来。
比如领养审核这个功能,如果你只在后端写了两个接口(提交申请、审核申请),没有仔细设计状态流转,没有考虑同一宠物不能重复领养的约束,没有处理管理员审核通过后宠物状态同步更新的事务问题,那你做出来的东西只能叫“能跑”,不能叫“能用”。真正好用的系统,是需要站在角色的角度去思考的——普通用户在意的是流程透不透明,管理员在意的是操不操作繁琐,而你需要做的,就是通过合理的数据结构和业务逻辑,让两边都觉得爽。
另外想提醒一个容易被忽略的环节:文档。如果你这个项目是要用于毕业设计答辩或者作品展示,那么数据库设计说明、接口文档、测试记录这些配套材料,尽量在开发过程中顺手整理,不要等到全部开发完再补。我当时就是一边写代码一边用Apifox维护接口文档,用Markdown记录表结构和状态流转逻辑,最后写论文的时候基本上是把这些材料整理重组一遍,节省了非常多时间。
还有一个小建议给准备做类似项目的人:不要一开始就追求功能多,先把核心业务链路跑通——用户注册登录、宠物发布、列表展示、提交申请、管理员审核、状态流转,这条线稳定了,再往上面加收藏、公告、统计之类的点缀功能。核心链路就像房子的承重墙,承重墙立住了,后面再怎么装修都安心。反过来,功能堆了一大堆,核心链路却三天两头出bug,那整个项目给人的感觉就是花架子。
如果你正打算用这个题目作为你的毕设或者练手项目,这套方案完全可以作为起步参考。按我自己预估,如果你每天能保证三到四个小时的编码时间,从零基础到把核心链路开发完,大概需要三到四周。中间遇到卡壳的时候,先静下心把日志看明白,再定位到具体模块去排查,绝大多数问题都能自己解决。