上个月帮一个学弟验收他的毕业设计,就是这个“java-ssm366疫苗接种管理系统”。项目本身是典型的SSM+Vue前后端分离架构,功能覆盖用户端预约接种、后台疫苗库存管理、接种记录登记,代码量不大,但胜在完整。我原本以为又是一套网上常见的“某某管理系统”模板,结果翻代码的时候发现这个项目在业务设计的细节和实际踩坑上,有不少值得拆开细讲的地方。这一篇我就从技术选型、数据库设计、后端接口、前端页面、联调部署这几个维度,把整套实现思路和我在改代码过程中遇到的坑完整捋一遍。想做JavaWeb方向毕设,或者准备一份像样的项目经验的,这篇文章可以当一份完整参考。
1. 整体需求拆解与技术选型
1.1 疫苗接种管理系统到底要管什么
坦白说,第一次看到“疫苗接种管理系统”这个题目,我的第一反应是:这不就是个普通的CRUD项目吗?无非是把疫苗当作一种商品,用户注册、查看疫苗、预约、管理员审核、登记接种记录。但真正把需求细扣一遍,会发现有几个点远不是简单增删改查能糊弄过去的。
先看业务角色。系统至少要有两种角色:普通用户和管理员。普通用户能注册登录、浏览疫苗列表、查看每种疫苗的剩余库存、发起预约、查看自己的预约记录和接种记录;管理员负责疫苗类型的录入和上下架、库存量的调整、预约审核、接种登记、公告发布。如果是实际疾控中心场景,可能还要拆出“医生”或“接种点”角色,但作为毕设或者课程设计,两种角色已经把权限模型讲得很清楚了。
再看核心业务流程。最容易忽略的是一条链路:用户选疫苗→提交预约→管理员审核通过→用户到接种点现场→管理员登记接种记录→疫苗库存扣减。预约和库存之间是有耦合关系的:审核通过那一刻,理论上应该锁定库存;接种完成那一刻,应该真实扣减库存;如果用户取消预约,或者审核不通过,又应该释放库存。很多人的代码就栽在这一步——预约归预约,库存归库存,两边各写各的,最终对不上账。
数据维度上,还需要考虑疫苗的批次管理。同一款疫苗,可能有多个批次,不同批次的效期和库存应该分开统计。如果一开始表结构设计不到位,后面做库存报表、过期预警时会非常难受。
需求理到这里,功能模块的清单就明确了:用户管理、疫苗类型管理、疫苗批次库存管理、预约管理、接种记录管理、公告管理、数据统计。七张核心表,基本就能撑起整个系统。
1.2 为什么是SSM+Vue这个组合
很多学生看到“SSM”就觉得是上个时代的技术,一脸嫌弃。我的看法恰恰相反,SSM(Spring + SpringMVC + MyBatis)到今天依然是Java后端最经典的组合之一。它把IoC容器、MVC分层、ORM持久层这三件事拆得清清楚楚,弄懂SSM之后再去看Spring Boot的自动配置,基本就是降维打击。
这个项目的实际形态我建议做成这样:数据库层用MyBatis-Plus简化单表CRUD,但保留XML里手写复杂联表查询的能力;后端运行载体直接选Spring Boot,但核心代码严格按SSM的分层习惯来组织。这里可能有人会问,既然用Spring Boot更方便,为什么还要纠结“SSM”这个标签?原因很简单:一是毕业设计开题报告和中期文档里写的技术路线是SSM,最终交付的东西要跟文档自洽;二是Spring Boot本质上还是Spring那一套,用Boot的壳,写SSM的魂,完全不冲突。我在这个项目里就是把Spring Boot作为运行载体,controller、service、mapper、entity、common五层结构清清楚楚,后面接JWT权限校验、接第三方工具,没有任何阻碍。
前端选Vue是让我觉得这个项目最“现代”的一点。Vue + Element UI做后台管理页面,组件化写法比JSP时代用iframe拼页面的体验好太多了。后端只负责吐JSON,前端按路由渲染页面,职责分离,联调起来也很舒服。Node环境、vue-cli脚手架、axios请求库这三个基本配置搞定,Vue项目就能跑起来,门槛并不高。
最后单独说MyBatis-Plus。核心单表操作真的不需要手写SQL,实体类加几个注解,Mapper继承BaseMapper,save、selectByPage、deleteById全都有了。甚至可以根据Java实体类自动生成创建表的SQL语句,这一点在项目初始化建表时特别省事。我的做法是:基础CRUD完全交给Plus,统计报表、多表联查这类SQL自己手写XML,两条线并行,效率最高。
2. 数据库设计与后端核心实现
2.1 七张核心表怎么设计
表结构是这个项目最先要拿下的硬骨头,因为它直接决定了后面Service层代码写得顺不顺畅。我逐一把核心表说一下设计思路。
用户表 sys_user:字段包括id、username、password、real_name、phone、id_card、role(0管理员/1普通用户)、status、create_time。唯一要注意的是密码不能明文存,用MD5加盐或者BCrypt加密都行。我在项目里用的是BCrypt,虽然比MD5慢一点,但安全级别高,答辩的时候还能借此讲讲密码存储的演进过程。
疫苗类型表 vac_type:id、type_name、manufacturer、immune_cycle(接种周期,比如“间隔21天”)、description、status。这张表管的是“这款疫苗是什么”,不涉及库存数量。
疫苗批次表 vac_batch:id、type_id、batch_no(批次号)、produce_date、expire_date、stock、safety_stock(安全库存下限)。这一张表才管真正的“有多少针”。
预约表 appointment:id、user_id、batch_id、appoint_time(希望接种的时间段)、status(0待审核/1审核通过/2已取消/3已完成)、audit_time、create_time。
接种记录表 inoculation_record:id、user_id、batch_id、appointment_id、vaccine_time、injector(接种医生)、hospital(接种点)、remark。
公告表 notice:id、title、content、create_time。
管理员操作日志表(可选):id、admin_id、action、target_type、target_id、create_time。这张表非必需,但加上之后能说自己做了操作审计,面试会是一个加分项。
设计这些表时有三个细节值得注意。第一,预约表和接种记录表都挂了batch_id,这能保证统计“某个批次的预约数、已接种数”时,不需要去Type层翻数据。第二,库存字段放在batch表而不放在type表,因为不同批次效期不同,库存必须按批次维度管理。第三,表名前统一加前缀(sys_、vac_、appointment_等),后续做权限控制或者数据库迁移时一目了然。
建表SQL可以直接根据实体类反向生成,这里我用的是MyBatis-Plus的代码生成器:先写好实体类,字段上加上@TableName和@TableId注解,然后调用AutoGenerator,配置好数据源、包名、策略,它就能自动生成controller、service、mapper,以及对应的SQL脚本。当然反过来,对已存在的表生成实体类也一样通,两个方向都能用。
2.2 后端分层与统一返回结构
后端代码我按标准的五层来组织:
com.example.vaccine ├── controller # 接口层 ├── service # 业务层 │ ├── impl # 业务实现 │ └── xxxService.java ├── mapper # 数据访问层 ├── entity # 实体类 └── common # 统一结果、异常、工具类controller层不写业务,只负责接收参数、调用service、返回结果。service层是核心,事务注解@Transactional放在service实现类的方法上。mapper层只做数据操作,不做逻辑判断。这个分层习惯在代码量变大之后优势非常明显,出问题的时候顺着调用链一路查下去,定位很快。
统一返回结构我定义了一个Result类,这是前后端联调的基础。前端拿到所有响应后,统一判断code是否为200,不是则弹出message,后端不用为每个接口单独定义返回格式,前端也不用每个页面写一堆try-catch。
public class Result<T> implements Serializable { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("操作成功"); result.setData(data); return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.setCode(500); result.setMessage(message); return result; } }2.3 权限认证与预约库存的并发问题
权限这块我强烈建议用JWT,不要用Session。原因很简单:前后端分离项目里,Session天然不好使。后端在8080端口,前端在8081端口,跨端口共享Session需要额外配置,麻烦。JWT的思路是用户登录成功后,后端签发一个带有效期的Token,前端每次请求把它放在请求头里,后端用拦截器校验Token,解析出用户ID和角色。用到的依赖是jjwt,核心代码就是写一个拦截器:
@Component public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("token"); if (StringUtils.isBlank(token)) { returnJson(response, Result.error("未登录")); return false; } try { Claims claims = Jwts.parser() .setSigningKey(SECRET_KEY) .parseClaimsJws(token) .getBody(); request.setAttribute("userId", claims.get("userId")); request.setAttribute("role", claims.get("role")); return true; } catch (Exception ex) { returnJson(response, Result.error("登录已过期,请重新登录")); return false; } } }这个拦截器注册到WebMvcConfigurer里,指定拦截路径为/**,放行路径为/login、/register,以及所有OPTIONS请求。跨域预检请求是浏览器自动发的,不带token,如果没放行,前端请求就会一直挂在“跨域”上。
业务上最有含金量的地方是预约审核时的库存操作。用户提交预约只写一条appointment记录,状态是“待审核”。管理员审核通过时,应该做两件事:更新预约状态为“已通过”,同时锁定库存。这个“同时”必须放在同一个事务里。锁库存的方式我推荐乐观锁:在vac_batch表加一个version字段,更新库存时带上条件version = #{oldVersion},如果update影响行数为0,说明有人并发改了这条记录,直接抛异常让事务回滚。通俗点说:你拿着货单去改库存,得先确认货架上的数量没被别人动过。多人同时操作时总有一个会抢到,抢不到的自动重试,或者提示“库存正在变化,请刷新”。
我看到很多同学的实现是:先查库存,if stock > 0 then set stock = stock - 1,这种写法在数据库层面是典型的“检查再更新”竞态。两个人同时查到库存剩1,都判断大于0,都去扣,结果库存变成-1。这个问题答辩时被老师一问就会露馅,提前用乐观锁就能避免。
接种完成时同理,更新接种记录状态,再扣减库存,也在同一事务内完成。预约审核不通过、用户主动取消,则要把之前锁定的库存释放掉。库存相关逻辑总结成一句话:一切对stock字段的修改都要走数据库层面的条件更新,绝不能先查再改,直接在内存里赋值。
3. Vue前端开发与联调落地
3.1 本地环境搭建与依赖安装
前端最容易卡在第一关:环境起不来。我先把一个最稳的版本组合写在前面:Node.js 14或16(不要一上来就装最新的20+,部分老Node包兼容性会出问题),vue-cli 4.x或5.x,Vue 2.6 + Element UI 2.15。如果你非要上Vue 3,请记住Element UI不支持Vue 3,必须用Element Plus,组件API也变了。毕设项目求稳,选Vue 2 + Element UI,参考资料最多,遇到问题一搜就有答案。
命令行流程如下:
# 安装vue-cli npm install -g @vue/cli # 创建项目 vue create vaccine-web # 安装依赖 npm install element-ui axios vue-router # 启动开发服务器 npm run serve这里坑最多的是npm安装依赖特别慢,或者直接失败。我的做法是切淘宝镜像:在项目根目录下新建一个.npmrc文件,写入registry=https://registry.npmmirror.com。如果你用pnpm或yarn,同理配置镜像源。
还有一点很容易被忽略:vue/cli创建项目时,会询问使用哪种配置预设,我们选Manual(手动),勾选Vue Router、CSS预处理器,把Linter也勾上,Vue版本选2.x。这样生成的项目天然带router文件夹,省得自己去配路由。另外,如果你用IDEA来开发Vue,记得装一下Vue.js插件和Node.js插件,不然写.vue文件时没有语法高亮和自动补全,体验非常难受。
3.2 核心页面与组件拆解
整个前端页面我分成三大块:登录注册页、用户端、管理端。
登录注册页是最基础的模块化页面:左侧放品牌介绍,右侧用Element UI的el-form做表单校验,登录成功后把token存到localStorage,同时把用户基本信息也存进去,然后根据角色跳转到对应的首页。这里有个细节:登录接口返回的数据里,用户昵称和角色信息要一起返回,不然前端还得再调一次接口查用户信息,白白多一次请求。
用户端页面包括:疫苗列表页(卡片式展示疫苗信息,点击“查看详情”弹出抽屉)、预约页(选择接种批次、时间段)、我的预约页(查看状态、取消预约)、我的接种记录页。路由上加一个导航守卫:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.meta.requiresAuth && !token) { next('/login'); } else { next(); } });管理端页面我用动态路由来控制菜单权限:登录后,根据用户role字段,向路由里动态注册对应的页面。管理员能看到用户管理、疫苗管理、预约审核、接种登记、公告发布、数据统计这六个菜单;普通用户只能看到疫苗、预约、记录这三个菜单。这块代码不复杂,但写出来之后,答辩时就能讲“基于角色的动态权限控制”,层次就上去了。
组件拆分的经验是:不要把每个页面都写成一个巨大的文件。比如疫苗表格组件、预约状态标签组件、分页组件,都单独抽出来,页面里只做组装。如果后续要给公告或科普模块加上视频展示,视频源常用m3u8格式,浏览器不能直接播放,需要引入hls.js插件来兼容,这也是一个可以顺手做的扩展点。组件化的好处在加功能时体现得最明显,一个模块改动不影响其他页面。
3.3 联调中的跨域与请求封装
前后端分离开发,最大的痛点就是跨域。本地开发时,后端跑在localhost:8080,前端跑在localhost:8081,浏览器会拦截前端发出的HTTP请求,这就是经典的CORS问题。
推荐两种解决方式,二选一即可。
方式一是后端全局配置CORS:
@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); } }注意allowedOriginPatterns写“*”时不能搭配allowCredentials(true),这是Spring 5.3版本之后出现的限制,否则会被拦截。方式二是前端用代理,在vue.config.js里配置:
module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } };前端请求/api开头的路径,开发服务器会把请求转发到8080端口,浏览器看到的是同源请求,就没有跨域问题了。我实际项目里更喜欢用代理方式,因为生产环境会有Nginx反代,本地开发用proxy模拟的效果跟生产最接近。
Axios封装也值得写两句。我在utils/request.js里做统一处理:请求拦截器带上token,响应拦截器统一处理业务code,遇到401跳登录页,遇到500弹错误提示。业务页面不用关心token和异常处理,只管调接口。
import axios from 'axios'; import { Message } from 'element-ui'; import router from '@/router'; const service = axios.create({ baseURL: process.env.VUE_APP_BASE_API, timeout: 10000 }); service.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers['token'] = token; } return config; }); service.interceptors.response.use( response => { const res = response.data; if (res.code !== 200) { Message.error(res.message || '请求失败'); if (res.code === 401) { localStorage.clear(); router.push('/login'); } return Promise.reject(new Error(res.message)); } return res; }, error => { Message.error('网络异常,请检查后端服务'); return Promise.reject(error); } ); export default service;4. 高频问题排查与部署上线经验
4.1 从开发到上线,最容易踩的七个坑
我把这个项目从零到能跑的过程里,出现频率最高的一批问题整理成一张速查表,遇到问题可以先对号入座。
| 现象 | 根本原因 | 解决办法 |
|---|---|---|
| 前端请求接口报CORS错误 | 跨域未配置或Spring版本限制 | 用前端proxy代理,或用allowedOriginPatterns |
| 返回JSON报"No serializer found for class" | 实体类没写全getter或者有未映射字段 | 检查实体类是否规范,临时给多余字段加@JsonIgnore |
| 数据库时间比本地时间早8小时 | 数据库连接时区未设置 | URL加serverTimezone=Asia/Shanghai |
| 库存扣成负数 | 没有用乐观锁/悲观锁 | 在stock字段上做version条件更新 |
| npm install卡住不动 | 默认源在国外 | 换.npmrc镜像源 |
| Spring Boot启动报版本冲突 | 依赖版本不兼容 | 用mvn dependency:tree排查,锁定父POM版本 |
| 登录后接口报401 | token过期或请求头名称不一致 | 统一拦截器放行路径,token key前后端对齐 |
其中“数据库时间早8小时”这个坑最有代表性。默认的MySQL连接串如果不指定serverTimezone,驱动会用JVM默认时区去解释,而国内服务器和MySQL默认是Asia/Shanghai,两边一差就是8小时。很多人改了一次connection URL发现没用,其实是要同时看数据库初始化时的日期格式化和查询端的时间处理,前后不统一才会出现错乱。
另一个值得反复提醒的是Spring Boot版本。选Spring Boot 2.5.x左右的版本配JDK8最稳。新项目默认生成的可能是Spring Boot 3.x,如果你电脑装的是JDK8,它根本跑不起来,必须升级JDK17,而JDK17又会引发一系列依赖兼容问题:Lombok版本要换到1.18.30以上,javax包要改成jakarta包。所以我的建议很直接:做SSM项目,老老实实Spring Boot 2.x + JDK8 + MySQL 8.0,这一套组合最省心。
4.2 打包部署:从IDEA到宝塔Docker
部署环节,我推荐一套很实用的路径:后端打jar包,前端build出dist目录,用Nginx做站点托管。
后端打包之前,注意配置文件里数据库地址不要写死localhost。单独准备一个application-prod.yml,敏感信息用环境变量注入。打包命令:
mvn clean package -DskipTests打完的jar在target目录下,JDK8环境下直接启动:
java -jar target/vaccine-system.jar --spring.profiles.active=prod如果不想手动管理进程,可以用宝塔面板的“Java项目管理器”,或者直接用Docker。Docker部署的思路不复杂,写一个Dockerfile:
FROM openjdk:8-jdk-alpine VOLUME /tmp COPY target/vaccine-system.jar app.jar ENTRYPOINT ["java","-jar","/app.jar","--spring.profiles.active=prod"]构建镜像后,配合docker-compose把MySQL、后端、前端三个服务编排起来,一条命令启动整套环境。前端用Nginx镜像托管dist目录,Nginx配置里用location /api/做反向代理转发到后端容器地址。这里有个小技巧:Docker里容器间通信用服务名而不是IP,只要docker-compose里的服务名不换,后端IP怎么变都不影响前端。
上线之后还要做两个例行检查:一是看后端日志里有没有异常堆栈,二是用curl验证前端是否真的能访问后端接口:
curl http://你的服务器IP/api/vaccine/list返回JSON说明链路通了。不少人部署完成后发现接口404,多半是Nginx的location配置里少了路径匹配规则。注意匹配/api/时要用proxy_pass http://backend:8080/,尾部带不带斜杠,转发后的路径会不一样,这个细节特别容易踩。
4.3 关于“能跑”和“能答辩”之间的距离
如果目标是毕业设计顺利通过,光把网站跑起来是远远不够的。老师不看你页面有多炫,更关心代码里有没有自己的想法、有没有解决实际问题的过程。建议给项目补三个亮点。
第一,把预约状态机画清楚。从待审核到通过或拒绝,从通过到完成或取消,每个状态转换节点都用代码校验合法性,防止用户通过接口伪造请求,绕过审核直接把状态改成“已完成”。
第二,给接口做参数校验。用Hibernate Validator或者最简单的手写校验,对手机号、身份证号、预约时间段做格式检查。用户提交非法参数是实际系统一定会遇到的情况,有校验就说明你考虑到了数据完整性。
第三,写一点统计功能。比如按疫苗类型统计接种人数、按月统计预约趋势、按批次统计剩余库存,前端用ECharts的折线图或柱状图展示。ECharts的图表配置只需要几行,但视觉效果和功能完整度都会直接上一个档次。
其实整个项目梳理下来,真正的难点并不在某个单独的技术上,而在于把预约、库存、状态流转这条业务主线串起来。只要数据库设计先把这条主线理清楚,后端代码基本上就是水到渠成的事;前端Vue这块,多花点时间把Element UI的表格和表单玩熟,开发效率会非常高。这个系统后续还可以继续往微服务、消息通知、移动端方向扩展,但作为毕业设计或简历上的一份项目经验,把现有这套做扎实,已经完全够用了。