开始
我接手这个项目的时候,第一反应是“又一个管理系统”,但真正把需求捋完才发现,流浪动物救助这个场景比想象中复杂得多。它既有普通政务系统的信息发布、用户管理,又涉及领养流程的审批流转、志愿活动的报名统计、寻宠启事的时效匹配,再加上图片上传、数据统计这些杂活,如果一开始不在架构上留好余地,后面每个迭代都会把自己坑进去。
这套系统整体就是经典的B/S架构,后端用SpringBoot提供RESTful API,前端用Vue做单页应用,数据库MySQL存业务数据,持久层用MyBatis处理SQL。整套源码我自己完整跑通过,从数据库建表到前后端联调,再到打包部署,都踩过一遍坑。这篇文章就把整个项目的设计思路、核心实现和实战中遇到的问题一次性说清楚,适合正在做毕业设计、想入门Java全栈开发、或者准备接外包练手的开发者参考。
1. 项目全景:流浪动物救助平台到底需要做什么
1.1 业务模块与功能边界拆解
流浪动物救助平台表面看是“一个网站”,但它内部其实是三类人、两个端的组合体。三类人是普通用户、救助站管理员、系统超级管理员,两个端是面向公众的前台展示端和管理员使用的后台管理端。
普通用户能做的事主要有五块:
- 浏览待领养宠物列表和详情,提交领养申请,查看申请进度;
- 发布和浏览寻宠启事,发布后发现宠物时能标记为“已找到”;
- 报名参加救助站组织的线下志愿活动,查看自己的活动记录;
- 在线捐款,系统里要做捐款记录,不需要真的接支付接口,但数据流和流程要完整;
- 在个人中心维护自己的基本信息、收养记录、申请记录。
后台管理员端做的事情就是把这些数据管起来:宠物信息的增删改查和下架,领养申请的审核(批准或驳回),寻宠启事的审核,志愿活动的发布和报名统计,捐款明细的管理,公告的发布,以及用户账号的禁用和启用。
把模块拆到这个粒度之后,项目边界就非常清晰了。我最初犯过一个典型错误:看到一个“平台管理系统”的标题就想把所有功能都塞进去,包括论坛、在线聊天、视频直播,结果做到一半发现重点全跑偏了。这个项目最核心的难点是“领养状态流转”,其他功能都是围绕它服务的。
1.2 技术选型:为什么是这套组合
技术栈用的是SpringBoot 2.7.18 + JDK 8 + MySQL 5.7 + MyBatis 1.3.2 + Vue 2.6,这套组合我建议新接触的人直接照抄,不要一上来就追新。原因很简单:SpringBoot 3.x强制要求JDK 17,很多公司的生产机器还是JDK 8;Vue 3的生态组件和教程质量虽然已经很成熟,但Vue 2的Element UI组件库依然是快速搭建后台界面的最高效选择,尤其对于单人全栈开发,写起来真的省力气。
选MyBatis而不是JPA或者MyBatis-Plus,也是同样的逻辑。很多人问我为啥不用MyBatis-Plus,CRUD能省一大半代码量。我的回答是:学习阶段用原生MyBatis,你能真正理解SQL是怎么映射成对象的,参数是怎么绑定到XML的,动态SQL的拼接逻辑是怎么工作的。这些基础掌握了,回头用MyBatis-Plus就是降维打击。而且这个项目里宠物列表的多条件查询、领养记录的嵌套查询,手写SQL想怎么写就怎么写,不会遇到插件在某些特定场景下SQL生成不对付的尴尬。
MySQL 5.7虽然老,但它和SpringBoot 2.7的配合最稳定,小内存服务器上跑起来很轻快。数据库是整套系统的地基,地基别乱动,项目就稳了一半。
2. 后端设计:从建表到核心接口的落地细节
2.1 业务表怎么设计才能不返工:六大核心表拆解
数据库设计是整个项目的地基。一个救助平台的核心数据流是“用户 → 宠物 → 领养申请 → 审核结果 → 归档”,围绕这条主线,我把表拆成六张核心表和四张辅助表。
用户表user
用户表要覆盖普通用户和管理员两种角色,所以字段里必须有role字段,取值范围建议用1表示管理员、0表示普通用户,后台用status字段控制账号状态,0为正常,1为禁用。密码存储用MD5加密,再加一层盐值拼接,避免密码直接躺数据库里。
CREATE TABLE `user` ( `id` int(11) NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL, `password` varchar(100) NOT NULL, `real_name` varchar(50) DEFAULT NULL, `phone` varchar(20) DEFAULT NULL, `email` varchar(100) DEFAULT NULL, `avatar` varchar(255) DEFAULT NULL, `role` tinyint(4) DEFAULT '0', `status` tinyint(4) DEFAULT '0', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;宠物表pet
宠物表是整个平台的流量入口,字段设计直接决定列表页能不能好查。除了名字、品种、性别、年龄、毛色、绝育状态、疫苗状态这些基本属性,一定要单独存cover_image作为封面图,images字段存多图的JSON数组字符串,查询列表时只取封面,详情时再解析完整图集。status字段是这个项目最关键的字段,取值范围:0待领养、1已申请、2已领养、3已下架,用数字而不是字符串,检索效率高。publish_user_id用于追踪哪个用户发布的。
领养申请表adoption_application
领养申请表多对一关联宠物,多对一关联用户。审核流转依靠status字段:0待审核、1已通过、2已拒绝。有一个很关键的细节:一个宠物同时只能有一个“待审核”状态的申请,这个业务规则最好通过代码先查再写来保证,而不是依赖数据库唯一索引——因为“待审核”是一个可变状态,很难用静态索引约束。
寻宠启事表lost_pet
寻宠启事表包含宠物特征描述、丢失地点、丢失时间、联系方式、图片、status(0寻找中、1已找到)。这个模块要注意全文搜索问题。小项目里用LIKE '%关键词%'先顶着,等数据多了再考虑上Elasticsearch或者MySQL全文索引。
志愿活动表volunteer_activity和报名表activity_signup
这两张表是关联关系:一个活动可以被很多人报名,一个人可以报多个活动。设计活动表的时候要存max_people和current_people两个字段,报名的时候先查current_people < max_people再插入报名记录,同时用数据库事务包住两步操作,防止“最后一个名额被两个人抢到”。signup表加一个创建时间字段,活动审核时按报名先后顺序录取,给用户展示时也能排序。
公告表notice
后台发布平台公告,前台公告列表展示,字段简单:标题、内容、发布时间、状态。没有复杂的逻辑,但前台首页推荐位经常要取最新一条公告,所以创建时间字段记得建索引。
2.2 项目分层结构与MyBatis的XML映射细节
后端代码我用的是经典四层结构:Controller层接收请求和参数校验,Service层处理业务逻辑和事务,Mapper层(DAO层)负责数据库读写,Model层放实体对象。有人觉得这个结构老套,但它最大的优点是职责边界清楚,单人开发时写代码不用犹豫“这段逻辑到底放哪里”。
MyBatis的核心配置要注意几个容易踩坑的点。第一,mapUnderscoreToCamelCase必须设置为true,否则数据库字段create_time无法自动映射到Java属性的createTime,你只能写一堆resultMap手动映射,纯属自找苦吃。第二,实体类里凡是数据库不存在的字段——比如宠物列表查询时用的statusName中文状态名、搜索用的keyword——都要加@TableField(exist = false)注解(如果引入MyBatis-Plus的话),原生MyBatis不需要,但要在XML的SQL里避免使用这些字段。
一个带搜索的宠物列表查询SQL示例:
<select id="selectPetList" parameterType="map" resultType="com.demo.pet.Pet"> SELECT p.*, u.real_name AS publisherName FROM pet p LEFT JOIN user u ON p.publish_user_id = u.id <where> <if test="keyword != null and keyword != ''"> AND (p.name LIKE CONCAT('%', #{keyword}, '%') OR p.breed LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="status != null"> AND p.status = #{status} </if> <if test="type != null and type != ''"> AND p.type = #{type} </if> </where> <if test="orderBy != null and orderBy != ''"> ORDER BY ${orderBy} </if> LIMIT #{offset}, #{pageSize} </select>这个SQL有两个细节值得展开。第一个是LIKE拼接用了CONCAT('%', #{keyword}, '%')而不是直接写'%${keyword}%',前者是占位符预编译,能防SQL注入,后者是字符串替换,会被攻击。第二个是ORDER BY ${orderBy}直接接收外部参数,用${}绕过预编译虽然理论上不优雅,但排序字段没法用?占位符替代,所以必须在后端白名单校验传入值,只允许白名单里的字段名传进来,否则就默认排序。
2.3 登录鉴权:JWT令牌如何在前后端之间流转
这个项目的登录鉴权方案我选的是JWT,而不是Session。和Session模式相比,JWT是无状态的,服务器不需要存会话记录,适合前后端分离的场景,也对后续横向扩展友好。用户登录成功后,Controller层生成一个有效期为24小时的Token返回给前端,前端每次请求都在Authorization头带上这个Token,后端用拦截器统一校验。
JWT的核心结构是三段式:Header(算法类型)、Payload(用户信息、过期时间)、Signature(签名)。下面是一个标准的登录接口核心流程:
public String login(String username, String password) { User user = userMapper.selectByUsername(username); if (user == null || !user.getPassword().equals(MD5Util.encode(password))) { throw new BusinessException("用户名或密码错误"); } if (user.getStatus() == 1) { throw new BusinessException("账号已被禁用"); } String token = JwtUtil.createToken(user.getId(), user.getUsername(), user.getRole()); return token; }JwtUtil.createToken内部就是把用户ID和角色写进Payload,再用密钥通过HMAC-SHA256算出签名。前端拿到Token后,还需要解密拿到用户ID吗?不需要,后端从Token中解析,每次请求拦截器都会把解析出的userId放到ThreadLocal里,后续Service层的方法直接UserContext.getUserId()就能拿到当前用户,这就是一个典型的上下文模式在Web项目里的应用。用ThreadLocal要注意请求结束时在拦截器的afterCompletion里调用remove()清理,否则线程池复用时会出现用户串号。
2.4 文件上传方案:配置MinIO作为图片存储服务
流浪动物救助的图片上传,涉及很多宠物照片、寻宠启事照片、用户头像。开发阶段用本地文件存储加虚拟路径映射能快速跑通,但部署上线后,本地存储有两个硬伤:服务器磁盘空间有限,而且图片无法统一做备份。我项目中后期把存储切到了MinIO。
MinIO是一个开源的、兼容Amazon S3协议的对象存储服务,Java后端通过SDK就能上传和下载文件。安装很简单,下载二进制包,修改启动参数指定MINIO_ROOT_USER和MINIO_ROOT_PASSWORD即可,日常开发在本地一跑,就是一个私有化的图床服务。
SpringBoot集成MinIO的核心配置:
spring: servlet: multipart: max-file-size: 10MB max-request-size: 50MB minio: endpoint: http://127.0.0.1:9000 access-key: minioadmin secret-key: minioadmin bucket-name: pet-images关键代码是上传逻辑。文件从MultipartFile变成MinIO对象,需要一个putObject操作,同时要设置对象的ContentType,否则图片在浏览器里会被直接下载而不是预览。文件名的生成一定要用UUID或者时间戳加随机数,千万不要用用户上传的原始文件名,否则不同用户上传同一个名字的文件会互相覆盖。
String fileName = UUID.randomUUID().toString().replaceAll("-", "") + "." + suffix; minioClient.putObject( PutObjectArgs.builder() .bucket("pet-images") .object(fileName) .stream(file.getInputStream(), file.getSize(), -1) .contentType(file.getContentType()) .build() );MinIO中Bucket的权限默认是私有的,也就是说直接通过URL无法访问文件。有两种处理方式:要么把Bucket的Policy设置为public,这是最简单粗暴的,开发阶段就用它;要么后端写一个临时访问链接接口,通过MinIO的presignedGetObject生成一个带有效期(比如7天)的签名URL。生产环境建议用后者,防止图片被外部程序批量抓取。
2.5 分页查询与条件搜索的通用实现套路
后台管理列表和前台宠物列表都需要分页和条件搜索。我用了一种在单体项目里非常通用且好理解的实现方式:前端传pageNum、pageSize、keyword等参数,后端手写一个PageResult包装类,配合PageHelper插件实现分页。
PageHelper用法的坑网上讨论很多,核心就一条:必须在查询前调用PageHelper.startPage(pageNum, pageSize),而且紧接着的第一条SQL查询就是你需要分页的查询,中间不能插入任何其他数据库操作。MyBatis-Plus的IPage是面向对象的分页,原生MyBatis配合PageHelper则是面向SQL的分页,如果你用XML手写了一个复杂的LEFT JOIN多表查询,PageHelper是能自动套上COUNT语句的,这也是选它最省事的原因。
分页结果前端一般要拿到total(总记录数)来算总页数,后端返回的结构统一是:
{ "code": 200, "message": "操作成功", "data": { "list": [], "total": 100, "pageNum": 1, "pageSize": 10 } }我强烈建议把一个项目里所有接口的返回值统一成Result<T>格式,Controller里永远返回这个对象,不要因为图方便而直接返回裸数据。统一响应格式能让你在前端写Axios拦截器时极其舒服,错误码和业务数据的解析逻辑只需要写一次。
3. 前端架构:Vue页面组织与组件复用
3.1 Vue工程搭建、路由设计和前端目录结构
前端用Vue 2.6配合Vue CLI 4.x脚手架创建工程。Vue CLI创建项目的命令很简单,关键是我推荐在创建时勾选Router和Vuex,这两个依赖在项目里都会用到。创建完成后,一个规范的前端目录结构是下面这样:
src/ ├── api/ // 所有接口调用封装 │ ├── pet.js │ ├── user.js │ ├── adoption.js │ └── request.js // axios实例封装 ├── assets/ // 静态资源 ├── components/ // 公共组件(图片轮播、分页组件、上传组件) ├── router/ // 路由配置 │ └── index.js ├── store/ // Vuex状态管理(用户信息、Token) ├── views/ // 页面组件 │ ├── admin/ // 后台管理端页面 │ ├── system/ // 系统管理页面 │ └── home/ // 前台页面 ├── App.vue └── main.js路由设计是整个前端最关键的一环。因为系统有前台和后台两套界面,我把路由拆成了两组:home布局下的前台路由,以及admin布局下的后台路由。两者的区别不只是路径前缀不同,更重要的是一套权限控制机制:后台路由必须在登录状态下才能访问,管理员角色才能进入。
Vue Router的守卫函数beforeEach是实现权限控制的标准姿势,伪代码逻辑如下:
router.beforeEach((to, from, next) => { const token = store.state.token; if (to.path.startsWith('/admin')) { if (!token) { next('/login'); } else if (store.state.user.role !== 1) { next('/403'); // 权限不足 } else { next(); } } else { next(); } });注意一个细节:路由守卫中检查token存在,只是确认用户登录过,不能确认用户是否还有效。这是因为JWT的过期校验在后端拦截器进行,前端需要的只是一个粗略的跳转拦截。Token过期后,前端调用接口时后端会返回401状态码,这时候在Axios响应拦截器里统一清除本地Token并跳转登录页,这才是完整的一套闭环。
3.2 Axios封装:拦截器才是前后端交互的中枢
前端的接口请求,我全部封装在一个request.js文件里。这个文件里导出一个配置好的Axios实例,请求拦截器统一把Token加到Header上,响应拦截器统一处理状态码。
import axios from 'axios'; import router from '@/router'; import store from '@/store'; const request = axios.create({ baseURL: '/api', timeout: 10000 }); request.interceptors.request.use( config => { const token = store.state.token; if (token) { config.headers['Authorization'] = token; } return config; }, error => Promise.reject(error) ); request.interceptors.response.use( response => { const res = response.data; if (res.code === 401) { store.commit('clearAuth'); router.push('/login'); return Promise.reject(new Error('登录过期')); } if (res.code !== 200) { // 统一弹出错误提示 return Promise.reject(new Error(res.message)); } return res.data; // 直接把业务数据返回给页面 }, error => Promise.reject(error) );这个封装的精华在响应拦截器的return res.data。如果没有这一步,页面上每次拿数据都要写response.data.data.list这种三层嵌套的对象,页面代码会非常啰嗦。有了它,页面里调接口拿到直接就是业务数据,代码干干净净。
另外要理解baseURL: '/api'是什么意思。开发环境下,Vue CLI的devServer会代理转发/api开头的请求到后端服务器;生产环境下,Nginx也要配置一个location /api的转发规则。这个过程就是前后端分离项目的“跨域解决方案”,不需要在后端开启CorsFilter——当然开发阶段图省事,开启一下也可以,上线时最好交给网关代理。
3.3 前台展示页实现要点:轮播图、宠物卡片、分页列表
前台页面是用户最容易感知的部分。我大概用了十几个Vue组件来组装整个前台。宠物列表页用一个PetCard组件展示单只宠物卡片,包括封面图、名字、品种、绝育状态、状态标签(可领养/已申请/已领养),父组件用v-for循环数据源渲染卡片列表,然后配一个通用分页组件。
宠物详情页的图片区域,我用了Vue版的Swiper轮播组件。宠物详情页还有一个特殊逻辑:如果在详情页能看到“发布者联系方式”,只有登录用户才能看到,未登录状态显示“请登录后查看联系方式”。这种细粒度的控制逻辑在前端模板里直接判断token是否存在就行,真正的数据敏感操作——比如提交领养申请,后端必须再校验登录状态和申请权限,前端隐藏只是体验层面的优化,不能作为安全机制使用。
后台管理页面用了Element UI的el-table、el-form、el-dialog、el-pagination这套组合,配置好数据源和事件方法,一个标准的后台CRUD页面大概150行代码就能写出来。我写后台页面的套路是:每个功能模块一个目录,目录下放index.vue(列表页)和form.vue(编辑弹窗),两个文件配合使用,代码结构统一,后续维护时查看某个功能的实现非常省时间。
3.4 前端本地开发:跨域代理配置与Mock数据策略
前后端联调是开发中不可避免的繁琐环节。我在开发初期习惯用Mock数据先把前端界面写好跑通,等后端接口就绪后再切换真实接口。这个方法能大大压缩纯联调等待时间。
实现方式不复杂:Axios封装里判断一个USE_MOCK环境变量,为true时直接走本地的mock/*.js文件返回假数据,为false时走真实API。等后端接口调试完毕,把这个开关切回来就行。
真正联调的时候,Vue CLI的vue.config.js里需要配置devServer代理,把请求转发到后端SpringBoot的8080端口,避免开发期一直受跨域问题折磨:
module.exports = { devServer: { port: 3000, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, pathRewrite: { '^/api': '' } } } } };这里要注意一个反向代理的常见坑:后端Controller的@RequestMapping路径如果写成了/api/pet/list,那么代理的pathRewrite会把/api前缀剥掉再转发到后端,如果后端路径本身又包含/api,就会404。最好的习惯是前端约定所有请求都带/api前缀,后端Controller统一用/api开头,代理上去掉前缀还是保留要看实际调试情况,前后端保持同一种约定就不会出问题。
4. 完整实操流程:从初始化到跑通全系统
4.1 环境准备与项目初始化清单
完整跑通一个项目,环境准备是最琐碎也最容易出问题的一步。我把自己实测过的一套环境版本和配置列在下面,直接对照着装就行。
- JDK 1.8 (配置
JAVA_HOME环境变量,别用JDK 21跑SpringBoot 2.7,会直接启动失败) - Maven 3.6+ (国内建议配阿里云镜像源,否则下载依赖能等到怀疑人生)
- MySQL 5.7 (安装时选UTF-8字符集,root密码记好)
- Node.js 14.16+ (npm安装依赖必需)
- IDEA 2022+(后端开发),VSCode或WebStorm(前端开发,可以用同一个IDEA装Vue插件,也行)
- Navicat或MySQL Workbench(数据库可视化管理)
后端工程我用IDEA自带Spring Initializr创建,选择Spring Boot 2.7.18,依赖勾选Spring Web、MyBatis框架和MySQL Driver。注意原生MyBatis在Spring Boot中是mybatis-spring-boot-starter,如果你用的版本是2.x,还要确认JDK版本和SpringBoot版本相匹配。
前端的初始化命令是:
npm install -g @vue/cli vue create pet-frontend创建后安装Element UI和Axios:
npm install element-ui npm install axios到这里,前后端的空工程框架就搭好了。
4.2 核心接口开发全流程:以领养申请为例
领养申请是整个系统里业务逻辑最复杂的接口,用它来展开完整的后端开发流程最有代表性。领养申请的前提是:用户已登录,宠物存在且状态为0(待领养),该用户没有对这个宠物处于“待审核”状态的申请。
接口定义:
POST /api/adoption/apply 请求体: { "petId": 1, "reason": "我想要领养它" } 响应: 申请成功Service层核心代码如下:
@Transactional public void applyAdoption(AdoptionApplication application, Integer userId) { Pet pet = petMapper.selectById(application.getPetId()); if (pet == null || !pet.getStatus().equals(0)) { throw new BusinessException("该宠物不可领养"); } int applyCount = adoptionMapper.selectCountByPetIdAndStatus(pet.getId(), 0); if (applyCount > 0) { throw new BusinessException("该宠物已被其他用户申请,请等待审核结果"); } application.setUserId(userId); application.setStatus(0); adoptionMapper.insert(application); // 关键点:更新宠物状态为“已申请” pet.setStatus(1); petMapper.updateById(pet); }这段代码有三个关键设计值得反复琢磨:
第一个是@Transactional注解。更新宠物状态和插入申请记录这两个数据库操作必须同时成功或同时失败。如果不加事务,万一申请记录插入成功但宠物状态没有更新成功,就会出现一个宠物被两个用户同时“申请中”的脏数据。事务这个概念可以理解为“把所有操作打包成一个整体,要么全部完成,要么全部回滚”。
第二个是查询和插入不是原子操作。高并发场景下两个用户同时发起领养申请,都查到了“当前没有申请”,然后都插入了申请记录。这种情况在真实环境存在的,虽然概率低,但最好在adoption_application表加一个(pet_id, status)的联合唯一索引,这样第二个插入就直接报数据库唯一约束异常,代码里捕获异常后提示“该宠物已被申请”。数据库约束兜底,永远比自己写判断逻辑更稳妥。
第三个是宠物状态从0变成1之后,前端宠物列表页要同步刷新。这里我建议的是列表接口查询时直接过滤掉状态3以下架的宠物,但保留“已申请”状态的宠物在列表上显示“已被申请,等待审核结果”的标签。业务细节越贴近真实场景,这个项目的完成度看起来就越高。
4.3 前后端联调与整体跑通的检查清单
前后端都开发完成后,在本地做一次完整的功能走查是必须的,不要直接打包部署。我总结了一份极简的功能走查清单,照着一项项打勾,能覆盖绝大多数问题:
| 功能模块 | 测试操作 | 预期结果 |
|---|---|---|
| 用户登录 | 正确密码和错误密码各测一次 | 正确密码登录成功,错误提示提示 |
| 宠物列表 | 查看首页宠物列表 | 已下架的宠物不出现,已申请的显示“申请中”标签 |
| 领养申请 | 对同一宠物重复提交申请 | 第二次提交被拒绝,提示已被申请 |
| 领养审核 | 管理员审批通过一个申请 | 宠物状态变为已领养,申请单显示“已通过” |
| 活动报名 | 报名满员的活动 | 提示名额已满,插入失败 |
| 文件上传 | 上传一张大图(>10MB) | 请求被拦截,提示文件过大 |
| 权限控制 | 普通用户访问后台路由 | 被路由守卫拦截,跳转登录页 |
| Token过期 | 人为修改本地Token值再访问 | 后端返回401,前端清除登录态并跳转登录页 |
联调成功后,最后一步是前后端构建。前端执行npm run build生成dist目录,里面是纯静态文件,可以交给Nginx托管。如果你希望部署成一个SpringBoot单体应用(也就是把前端静态文件直接塞进SpringBoot,一起启动),也是可行的。在后端工程的src/main/resources目录下创建一个static文件夹,把dist目录里的index.html和static子目录整体放进去,SpringBoot自动就会把它作为静态资源服务起来。需要额外注意的是:Vue Router用history模式时,刷新/admin/pet/list页面会导致404,因为后端没有对应的路由处理;解决方式是后端加一个WebMvcConfigurer把前端路由的URL全部转发到index.html,或者最简单的方法是把Vue Router换成hash模式,URL中带#号但不会404。我实际经验是:小项目直接用hash模式最省事,不留隐患。
5. 常见问题与排查技巧实录
5.1 数据库连接类问题:SSL连接错误与时区报错
很多初学者第一步就在连数据库时被卡住,报错信息五花八门,最常见的是这样两行:
Establishing SSL connection without server's identity verification is not recommended java.sql.SQLException: The server time zone value 'EDT' is unrecognized or represents more than one time zone根因是:MySQL 5.7默认开启了SSL验证,而本地开发连接的JDBC没有提供证书;同时MySQL服务器的时区配置和中国本地时区不一致。解决方式是在连接字符串上明确指定不用SSL、时区用东八区:
jdbc:mysql://localhost:3306/pet_system?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8mb4&allowPublicKeyRetrieval=trueallowPublicKeyRetrieval=true这个参数很多人不知道,它解决的是MySQL 8.0+使用caching_sha2_password插件时的公钥检索问题,MySQL 5.7不牵扯。但如果你用了MySQL 8.0,这个参数必须加上,否则连接会直接失败。我一般直接建议初学者锁死MySQL 5.7,就是为了一次性能把这些坑全避开。
5.2 MyBatis映射与缓存:常见报错和场景实测
MyBatis最常见的报错之一是:
Invalid bound statement (not found): com.xxx.mapper.PetMapper.selectPetList出现这个错,99%的原因是Mapper接口和XML文件的对应关系没有被Spring扫描到。检查三个位置:@MapperScan注解是否配置了正确的包路径;application.properties中mybatis.mapper-locations是否指向了classpath*:mapper/*.xml;XML文件里的namespace是否和Mapper接口的完整类名完全一致。这三个位置任何一个错了,都会报这个错。我自己实际开发中会优先打开编译后的target/classes目录看XML文件到底有没有被打包进去,这是排查这个报错最直接的方法。
MyBatis的缓存机制也是一个容易困惑的点。一级缓存是SqlSession级别的,默认开启,同一个SqlSession内执行同一条SQL两次,第二次直接命中缓存不会查数据库;二级缓存默认关闭,需要配置<cache/>才能开启。我实际开发中强烈建议:小项目直接关闭二级缓存,开启反而容易遇到缓存脏读的问题。因为一旦一个表的数据被别人改了(比如通过别的SqlSession),你本地SqlSession的缓存仍然是旧数据,这会在联调时造成非常隐蔽的Bug,数据明明改了但页面上不刷新。排查时在MyBatis的配置里把日志级别调到DEBUG就能看到SQL是否真的执行了。
5.3 前端开发高频问题:npm依赖安装异常和跨域请求失败
npm install在国内环境跑起来经常卡住不动或者报各种ECONNRESET错误。替代方案很简单:配置淘宝npm镜像源:
npm config set registry https://registry.npmmirror.com然后重新执行npm install,基本就顺畅了。这个镜像源配置一次全局生效,后续所有npm项目都走国内镜像,实测速度提升非常明显。
前端页面在开发环境调后端接口时,如果没配置代理,Network面板的请求状态是CORS error。有一个快速验证CORS问题的方法:按F12打开Network面板,如果请求失败但Response Header里有Access-Control-Allow-Origin字段,那代理配置大概率有问题,仔细检查vue.config.js的pathRewrite。如果Response Header里压根没有CORS相关字段,说明请求根本没走到后端,是代理层的问题。用这个思路十次能解决八次跨域问题。
Vue打包放进SpringBoot还有一种特殊场景:部署后刷新页面404,但首页能正常访问。这个就是我前面提到的history模式问题,最简单的方法是把Vue Router的mode改成hash:
const router = new VueRouter({ mode: 'hash', routes });改完后URL变成http://域名/#/admin/pet/list,刷新不会触发后端路由匹配,一个配置就解决了问题,代价是URL里多个#号,对内部系统来说完全无伤大雅。
5.4 后端部署的几个隐藏细节:端口占用、时区配置、内存限制
后端部署到服务器,有四个坑我几乎每次都会遇到。第一个是端口占用,SpringBoot默认8080,如果服务器上已经有一个服务占了8080端口,应用启动直接报Port already in use。解法很简单,换端口或者杀掉占用进程,但更优雅的做法是在启动脚本里通过--server.port=8081指定端口,这样配置文件不用动。
第二个是服务器时区和MySQL时区不一致导致的时间错乱问题。这个是最隐蔽的坑:数据库存的时间是2024-01-01 00:00:00,代码查出来变成2024-01-01 08:00:00,整整快8个小时。原因从前面第5.1节就能找到——服务器没有配置时区环境。一次性解法是在MySQL连接串上继续指定serverTimezone=Asia/Shanghai,同时把服务器的时间和时区设置好,这样前后端、数据库三方时间就统一了。
第三个是内存限制。一个SpringBoot应用默认JVM堆内存是物理内存的四分之一,小内存服务器(比如1G或2G的云主机)上很容易内存溢出。部署时一定记得显式设置JVM参数:
java -Xms256m -Xmx512m -jar pet-system.jar这样堆内存控制在512MB以内,1G内存的服务器足够跑起一套SpringBoot加MySQL了。
第四个是LocalDateTime返回给前端时被序列化成一长串数字,而不是2024-01-01 12:00:00的格式。这是因为Jackson默认把LocalDateTime序列化为时间戳。解决方式是统一加上:
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")或在配置文件里配置Jackson的日期格式全局生效:
spring.jackson.date-format=yyyy-MM-dd HH:mm:ss spring.jackson.time-zone=GMT+8这个坑非常高频,几乎每个前后端分离项目都会遇到一次。
6. 避坑总结与拓展方向
最后聊点实际的。这个项目从我搭框架到所有功能跑通,大概用了两周时间,经验全部踩完一遍之后,有几个真实体会想分享给正在做同类项目的人。
第一个体会是:不要过早优化,也不要过度设计。我的第一版数据库设计实际上是7张表,后来砍到6张,还有一张冗余表被拆成了字段。做项目的正确姿势是先把核心流程跑通——用户登录、宠物上架、领养申请、后台审核,再考虑加功能。哪怕功能做得粗糙一点,一条完整链路跑通带来的信心比很多半成品功能有用得多。
第二个体会是关于源码的可读性。自己写自己调试的时候,命名怎么随意都能看懂,但一个项目隔一周再打开,脑子里对代码的记忆几乎清零,这时候就会感谢当初给每个类、每个方法都起了精准的名字。我这个项目的类名规则是:Controller类用模块名加Controller后缀,Service接口用模块名加Service,实现类加ServiceImpl,Mapper接口用模块名加Mapper,一眼就能看出类在架构中的位置。代码里该加注释的地方加注释,尤其对于事务、状态流转这种业务逻辑,写一行注释说明“这个操作为什么这样判断”,对后来的自己帮助极其大。
第三个体会是:前端框架版本别乱升级、后端依赖别乱折腾。项目用的是一套经过大量验证的版本组合,一旦升级其中一个,可能连带要升级其他模块,然后就会陷入依赖冲突的泥潭。这套组合我用了很多次,从来没出过大问题,谁换谁知道。
至于后续可以扩展的方向,按实际价值排序,大概是这三个:一是接入真正的在线支付(支付宝沙箱或者微信支付API),把捐款模块从模拟变成真能收钱;二是引入Redis做热点数据的缓存(宠物列表、公告信息),减轻数据库压力;三是在前端加入ECharts可视化大屏,做救助站的数据统计展示(领养率、活跃用户数、活动参与人次等),视觉效果好,完成度感知也高。这三个方向无论哪个,都是在现有代码结构上增量开发,不会伤筋动骨。
最后再分享一个小技巧:整套项目的源码,我建议每个人在自己电脑上从零手敲一遍,不要直接复制粘贴运行。手敲的过程中遇到的每一个报错,都是在帮你在面试时说出“这个问题我遇到过,原因是什么、解决方案是什么”。这种真实经验远比背面试题答案更有说服力,也更能体现一个Java开发者的基本素养。