做企业级项目最怕的不是技术难,而是需求绕、状态乱、并发一上来就出幺蛾子。这套基于SpringBoot+Vue+MyBatis+MySQL的疫苗发布与接种预约管理系统,是我最近完整过了一遍源码的实战项目,从疫苗建档、库存批次管理,到用户注册、线上预约、现场核销,再到接种记录归档,整条业务闭环都有。本文不搞那种"源码粘贴一遍"的复读机式讲解,我会把这套系统从架构设计、数据库建模、核心接口实现到前端交互、部署踩坑,一层层拆开聊,适合正在做毕业设计、想快速上手企业级管理系统开发,或者准备拿SpringBoot+Vue全家桶练手的朋友。看完整篇文章,你不仅知道这套源码怎么跑起来,更知道它为什么这么设计、每个模块背后解决的是什么痛点。
1. 系统需求与整体模块拆解
1.1 疫苗预约系统要解决的几个真实痛点
先不说代码,聊业务。疫苗发布和接种预约这类系统,在企业和社区场景里其实"小而杂"——用户量不一定爆炸,但业务状态多、流程长、还涉及卫生安全合规,每一步记录都不能丢。这套源码把需求收敛成几个核心模块,我梳理一下:
- 疫苗全生命周期管理:从疫苗产品建档(名称、生产企业、规格、剂次),到批次入库(批号、有效期、库存数量),再到批次审核发布,疫苗的状态全程可追溯。
- 接种点与医生排班:每个接种点有独立的库存配额,医生/接种员账号按接种点归属,前端按接种点展示可预约号源。
- 用户预约与核销闭环:用户注册登录后,选择疫苗批次和接种点,选择日期与时间段,提交预约后锁定库存;现场接种时通过核销码完成确认,预约状态从"已预约"流转为"已完成"。
- 接种记录与统计分析:完成接种后生成接种记录,后台可按疫苗、按接种点、按时间段统计接种人次,这部分对管理者来说是最有价值的。
这套系统的角色管理也很有代表性,不是一刀切的单用户模型,而是分了三种角色:
| 角色 | 核心权限 | 典型操作 |
|---|---|---|
| 管理员(Admin) | 全量管理 | 疫苗建档、批次发布、接种点管理、数据统计、账号管理 |
| 接种点医生(Doctor) | 接种点维度操作 | 查看本点预约名单、核销预约码、登记接种记录 |
| 普通用户(User) | 个人操作 | 注册登录、查询疫苗、在线预约、查看接种记录与电子凭证 |
这种RBAC(基于角色的权限访问控制)设计,是大量企业管理系统的通用骨架。把这套权限模型看懂了,后面遇到类似项目你直接能复用。
1.2 为什么这套源码值得完整跑一遍
市面上SpringBoot+Vue的练手项目不少,但大多数是"CRUD四件套"——用户管理、文章管理、分类管理完事。这套源码的价值在于它覆盖了带状态的复杂业务流转:
- 疫苗批次状态:待审核 -> 已发布 -> 已售罄 -> 已下线
- 预约单状态:待支付/待确认 -> 已预约 -> 已核销 -> 已取消 -> 已过期
- 库存变动的强一致操作:每个预约动作都涉及库存扣减,而且必须防止超卖
这些状态流转和并发控制,才是企业级项目的灵魂。做过真实系统的人都知道,查询接口好写,但状态机设计、事务边界划分、数据一致性保证,才是真正考察功底的地方。把这份源码的流转逻辑吃透,比你写十个"增删改查Demo"都有价值。
2. 技术架构与工程结构精讲
2.1 后端SpringBoot分层架构
这套系统的后端没有用那种"全塞Service里"的粗暴写法,而是标准的企业级三层架构。后端工程结构大致是这样:
src/main/java/com/xxx/vaccine/ ├── config/ # 全局配置类:跨域、拦截器、MyBatis配置 ├── controller/ # 接口层,只做参数接收和响应封装 ├── service/ # 业务层,事务边界全部在这一层 ├── mapper/ # MyBatis数据访问接口 ├── entity/ # 数据库实体对象 ├── vo/ # 前端视图对象,用于接口响应数据组装 ├── utils/ # 工具类:JWT工具、日期处理、Result封装 └── VaccinationApplication.java # 启动类这个分层看着普通,但有几个细节值得说道:
- controller层很薄:只做参数校验和调用service,任何业务判断都不在controller里写。这样做的好处是接口层稳定,后面加接口只加方法,不会牵一发动全身。
- service层直接叫接口+实现类的形式:Service接口定义业务方法,Impl实现具体逻辑。这个模式经常被吐槽"过度设计",但在这个项目里"管理员只读疫苗信息"和"医生核销预约"这类不同角色的差异化鉴权,实际就靠service层的细分来完成。拆开写,权限逻辑各归各家。
- vo层是刚需而非冗余:比如预约详情接口,前端需要展示疫苗名称、接种点地址、剩余库存、医生姓名。这些字段分散在三四张表里,如果用entity直接返回,要么暴露多余字段,要么前端要连环调接口。vo层在这里就是给前端"定做"的数据结构,这个意识很多新手项目里是没有的。
启动类没做什么花活,标准写法。但有两点生产环境里必须注意:一是@MapperScan要扫到mapper包路径,二是数据源配置建议放application-prod.yml,别把线上数据库密码写死在主配置里。
2.2 前端Vue工程结构与组件设计
前端用的是Vue 2 + Element UI这组经典搭配。工程结构如下:
vue-admin/ ├── src/ │ ├── api/ # 接口请求封装,按模块拆文件 │ ├── assets/ # 静态资源 │ ├── components/ # 公共组件(UploadExcel、Pagination等) │ ├── router/ # 路由配置,含动态路由和权限控制 │ ├── store/ # Vuex状态管理 │ ├── views/ # 页面视图 │ │ ├── login/ # 登录页 │ │ ├── vaccine/ # 疫苗管理、批次发布 │ │ ├── appointment/ # 预约页面、预约列表 │ │ ├── site/ # 接种点管理 │ │ ├── record/ # 接种记录与统计 │ │ └── user/ # 用户中心与电子凭证 │ ├── utils/ # 请求工具(axios封装)、鉴权工具 │ └── main.js说两个容易踩坑的地方:
第一,axios封装一定不能省。这套源码里所有请求都走统一封装的request.js,统一配置了baseURL(比如/api),请求拦截器里自动带上Authorization: Bearer <token>,响应拦截器里统一处理Http状态码和后端业务错误码。比如后端返回401,前端直接跳login页。没有这层封装,每个页面都写一遍token处理和错误提示,维护成本直接爆炸。
第二,路由守卫和动态权限是一对好搭档。源码里router.beforeEach做三件事:判断有没有token、没有就跳登录页;有token但访问的是登录页就跳首页;再根据用户角色里的菜单权限生成动态路由并router.addRoutes。这个思路很关键,因为不同角色看到的菜单和能访问的页面完全不一样。把权限控制放在路由层面做统一拦截,页面组件内部就不用到处写v-if="role === 'admin'"了。
2.3 技术选型的几个关键判断标准
为什么这套源码选MyBatis而不是JPA?说白了还是业务决定的。疫苗预约这个场景里:
- 预约列表要按"用户、时间、状态、接种点"多个维度组合查询,MyBatis的
<if>动态SQL处理这种多条件组合搜索非常顺手。 - 库存扣减的
UPDATE ... SET stock = stock - 1 WHERE id = ? AND stock > 0这种原子操作用XML写出来语义清晰,JPA的抽象反而绕。 - 多表联查(预约单关联查询疫苗、接种点信息)直接用自定义SQL,效率可控,SQL优化手段(索引调整、执行计划分析)也都能直接用上。
当然,MyBatis的代价就是你要手写大量SQL,<resultMap>映射繁琐。但在这个项目里,联查的结果映射本来就是刚需,手写反而让数据流向更透明。一句话总结:CRUD简单的项目用JPA省事,复杂联查和条件查询多的业务用MyBatis更稳。
前端选Element UI而不是更潮的Ant Design Vue或Naive UI,图的还是生态成熟和组件齐全。表格、表单、分页、日期选择器、对话框这些后台管理系统的高频组件,Element UI开箱即用,遇到问题搜一下基本都有答案,对开发效率的提升非常实际。
3. 数据库设计:一张表一张表抠关键细节
3.1 核心数据表全景
这套系统的数据库叫vaccine_system,核心表有9张左右。我按业务域分组列一下:
基础档案域:
user:用户表,含用户名、密码(BCrypt加密存储)、手机号、身份证号、角色类型、状态。vaccine:疫苗产品表,存疫苗名称、生产企业、批准文号、规格、剂次(1/2/3)、适用人群、禁忌、不良反应说明。vaccine_batch:疫苗批次表,存所属疫苗ID、批号、生产日期、有效期、入库数量、剩余数量、状态。vaccine_site:接种点表,存接种点名称、地址、联系电话、每日最大预约量。doctor_info:医生/接种员表,绑定user表和vaccine_site表。
业务流转域:
appointment:预约单表,存预约用户ID、批次ID、接种点ID、预约日期、时间段、状态、核销码。inoculation_record:接种记录表,存预约单ID、实际接种日期、接种医生ID、批次ID、接种部位、不良反应记录。notice:公告表,存公告标题、内容、发布时间、发布状态。
表之间的关系不复杂,但业务约束值得注意。比如appointment表同时关联了user、vaccine_batch、vaccine_site三张表,实际上就是"谁、在哪个点、打哪批疫苗"三个维度的交叉记录。在实际建表时,这四张表之间要加外键索引,否则列表查询的联查性能会很难看。
3.2 状态字段设计:用int还是varchar
这个细节我重点说一下。预约单表里的状态字段,源码采用的是int类型,并且用常量类做映射:
0 = 已取消 1 = 已预约(待接种) 2 = 已核销(已完成) 3 = 已过期(未按时接种)为什么不用varchar存"已预约、已完成"这类语义化字符串?两个原因:
- 性能:int字段在索引查询和比较时比字符串快,数据量大时差别明显。
- 代码维护:状态值在代码里是常量引用(
AppointmentStatusEnum),改了展示文案只需改前端映射,数据库不用动。如果存字符串,一旦上游需求把"已预约"改成"待接种",全表数据都得update。
当然,int状态的代价是读代码时需要状态枚举对照表,个人建议在项目里维护一份统一的枚举类,把状态值、状态中文名、可流转到哪些状态都集中定义,别散落在各个Service里。
3.3 库存字段的防超卖处理
疫苗批次表里的remaining_count是典型的高并发热点数据。这套源码的扣减SQL非常关键,不是"先查出来再在Java里判断",而是直接一条原子SQL:
UPDATE vaccine_batch SET remaining_count = remaining_count - 1 WHERE id = #{batchId} AND remaining_count > 0然后通过int rows = mapper.updateXXX(...)的返回值判断是否更新成功。如果rows == 0,说明没库存了,直接抛业务异常提示"该批次疫苗已约满"。
这个设计思路叫"乐观锁思想下的原子扣减",避免了先查后改带来的并发超卖问题。很多人写扣库存先SELECT一次判断库存够不够,再UPDATE,在高并发下两个请求同时查到库存还剩1,两个都通过判断,结果两个人都约成功,库存变-1。这个坑在真实项目里是致命的。源码直接放弃了Java层的库存判断,把校验交给数据库的行锁和条件更新,简单粗暴且绝对可靠。
事务边界也很清楚:创建预约单 + 扣减批次库存,这两个操作放在同一个@Transactional方法里,要么一起成功,要么一起回滚。如果扣库存成功但插入预约单失败,事务回滚后库存也恢复,不会出现"库存没了但预约单不存在"的脏数据。
3.4 核销码的设计思路
每个预约单生成时,都会有唯一核销码,现场接种时验证。源码里核销码用的是纯数字的6位码,生成策略是"用户ID + 预约记录ID + 随机因子"做哈希后取6位。这里注意,核销码不能只依赖随机数,因为随机数有碰撞概率,万一两个预约单生成同一个核销码,现场核销就出大问题。源码里在appointment表给verify_code字段建了唯一索引,插入时如果冲突就重新生成,双保险避免碰撞。
4. 后端核心功能实现与代码拆解
4.1 基于JWT的登录认证与权限控制
这套系统的登录认证用的是JWT(JSON Web Token)方案,流程是:
- 用户提交用户名密码,后端用
BCryptPasswordEncoder.matches()校验密码。 - 校验通过后,用
JwtUtils生成token,token的payload里放userId、username、role三个关键信息。 - 设置token有效期(比如2小时),并把token返回给前端。
- 前端后续请求在请求头带
Authorization: Bearer <token>。 - 后端通过拦截器(
HandlerInterceptor)统一解析token,校验合法性并将用户信息放入ThreadLocal或RequestContextHolder供后续业务使用。
// 拦截器里的核心校验逻辑 String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { token = token.substring(7); Claims claims = JwtUtils.parseToken(token); // 校验通过后设置用户上下文 UserContext.set(claims.get("userId"), claims.get("role")); } else { throw new BusinessException("未登录或登录已过期"); }源码里针对不同角色做了接口级权限控制。方式很朴素但好用:拦截器里维护一个接口白名单,再配合自定义注解@RequireRole标注在Controller方法上,拦截器解析token里的角色,和注解要求的角色比对,不一致直接拒绝。这个实现思路比引入Spring Security全家桶轻量得多,够用且好懂。
我特别建议把JWT的密钥和有效期配置到application.yml里,不要硬编码在Java类里。源码里就是这么做的,换环境只需要改配置文件,不用重新编译。
4.2 预约流程的后端接口实现全解
预约接口是这套系统的核心,核心逻辑代码如下(逻辑经过简化梳理):
@Override @Transactional(rollbackFor = Exception.class) public void createAppointment(AppointmentCreateDTO dto) { // 1. 校验用户预约资格:同一批次每人只能预约一次 int count = appointmentMapper.countByUserAndBatch(dto.getUserId(), dto.getBatchId()); if (count > 0) { throw new BusinessException("您已预约过该批次疫苗,请勿重复预约"); } // 2. 校验接种点当天是否已达预约上限 int dailyCount = appointmentMapper.countBySiteAndDate(dto.getSiteId(), dto.getAppointmentDate()); if (dailyCount >= siteMapper.getById(dto.getSiteId()).getDailyLimit()) { throw new BusinessException("该接种点当天预约名额已满"); } // 3. 原子扣减批次库存 VaccineBatch batch = batchMapper.getByIdForUpdate(dto.getBatchId()); if (batch.getRemainingCount() <= 0) { throw new BusinessException("该批次疫苗已约满"); } int rows = batchMapper.decrStock(dto.getBatchId()); if (rows == 0) { throw new BusinessException("该批次疫苗已约满,请选择其他批次"); } // 4. 校验批次状态必须是"已发布" if (!"2".equals(batch.getStatus())) { throw new BusinessException("该批次疫苗未发布或已下线"); } // 5. 生成核销码并插入预约单 String verifyCode = generateVerifyCode(dto.getUserId(), dto.getBatchId()); Appointment appointment = new Appointment(); // ... 组装字段 appointmentMapper.insert(appointment); }这里有三个细节必须讲透:
第一个细节,校验顺序。先校验用户资格和接种点日限,最后才扣库存。为什么?因为用户资格和日限的查询成本低,先把低成本校验前置,能拦截掉大量无效请求,把真正需要走库存扣减的请求压缩到最小。这相当于在数据库行锁之前设置了几道"闸口",高并发场景下能显著降低锁竞争。
第二个细节,getByIdForUpdate和乐观扣减能不能混用?源码里getByIdForUpdate用了悲观锁(SELECT ... FOR UPDATE)查询批次,然后又用条件更新扣减。这个设计在低并发下其实有点冗余,因为FOR UPDATE已经锁行,后面的条件更新必然成功。但在高并发场景下,FOR UPDATE会阻塞其他事务,导致吞吐量下降。实际生产里我更推荐只保留UPDATE ... WHERE remaining_count > 0的原子扣减,初始化库存查询放到扣减成功后再做,性能更好。这也算是我对这份源码的一个优化建议。
第三个细节,事务的rollbackFor必须显式声明。Spring事务默认只回滚运行时异常(RuntimeException),如果业务里抛的是受检异常(Exception)而不加rollbackFor = Exception.class,库存扣减成功了但预约单插入失败,事务不会回滚,库存就悄悄丢了。这种bug排查起来极其痛苦,因为数据对不上账。
4.3 核销接口的实现
医生端核销预约码的逻辑相对简单但对数据一致性要求高,核心代码如下:
@Override @Transactional(rollbackFor = Exception.class) public void verifyAppointment(String verifyCode, Integer doctorId) { // 1. 根据核销码查询预约单 Appointment appointment = appointmentMapper.getByVerifyCode(verifyCode); if (appointment == null) { throw new BusinessException("核销码不存在"); } // 2. 校验预约状态:只有"已预约"状态才能核销 if (!AppointmentStatus.BOOKED.equals(appointment.getStatus())) { throw new BusinessException("该预约单状态不是待接种状态"); } // 3. 校验医生归属的接种点和预约单中的接种点一致 DoctorInfo doctor = doctorMapper.getByUserId(doctorId); if (!appointment.getSiteId().equals(doctor.getSiteId())) { throw new BusinessException("不能核销其他接种点的预约"); } // 4. 更新状态并插入接种记录 appointmentMapper.updateStatus(appointment.getId(), AppointmentStatus.FINISHED); inoculationRecordMapper.insert(...); // 组装接种记录 }核销场景下的状态判断用了乐观的思路:先查状态,再更新状态。这里其实存在并发风险,比如用户在医院现场让两个工作人员同时扫描同一个核销码,理论上两次核销都读到"已预约"状态,然后都更新成"已完成",就会生成两条接种记录。实际项目里,要么给核销更新加条件UPDATE ... SET status = '已完成' WHERE id = ? AND status = '已预约',用更新行数判断;要么直接给核销码加唯一约束配合分布式锁。源码里用了条件更新,这个处理是到位的。
4.4 MyBatis动态SQL的实战用法
这套系统里MyBatis的动态SQL用得很典型。举个例子,后台的预约列表查询,支持按用户手机号、预约状态、接种点、日期范围做多条件筛选,XML里的写法是这样:
<select id="selectAppointmentList" resultType="com.xxx.vaccine.vo.AppointmentVO"> SELECT a.id, a.appointment_date, a.appointment_time, a.status, v.vaccine_name, s.site_name, u.real_name, u.phone FROM appointment a LEFT JOIN vaccine_batch vb ON a.batch_id = vb.id LEFT JOIN vaccine v ON vb.vaccine_id = v.id LEFT JOIN vaccine_site s ON a.site_id = s.id LEFT JOIN user u ON a.user_id = u.id <where> <if test="phone != null and phone != ''"> AND u.phone LIKE CONCAT('%', #{phone}, '%') </if> <if test="status != null"> AND a.status = #{status} </if> <if test="siteId != null"> AND a.site_id = #{siteId} </if> <if test="startDate != null"> AND a.appointment_date >= #{startDate} </if> <if test="endDate != null"> AND a.appointment_date <= #{endDate} </if> </where> ORDER BY a.create_time DESC LIMIT #{pageNum}, #{pageSize} </select><where>标签会自动处理第一个条件前的AND,不会出现SQL语法错误,这是MyBatis日常开发里必须掌握的核心知识点。另外注意LIKE CONCAT('%', #{phone}, '%')的写法,用CONCAT拼接而不是直接写'%${phone}%',后者存在SQL注入风险,${}直接拼接字符串是MyBatis的大忌。
分页这里没有引入PageHelper插件,而是手写了LIMIT #{pageNum}, #{pageSize}。小项目够用,数据量大之后再换成PageHelper或MyBatis-Plus的分页插件也不难。这种"先跑起来,再按需优化"的思路,在企业项目里反而比"一步到位引入高级组件"更务实。
5. 前端Vue核心交互与页面实现
5.1 用户端的疫苗查询与预约流程
用户端的核心页面是"疫苗列表 -> 疫苗详情 -> 选择接种点与时间 -> 提交预约 -> 查看电子凭证",这条链路里有几个交互细节做得比较到位:
疫苗列表页用了卡片式布局而不是纯表格,每个卡片展示疫苗名称、生产企业、剩余剂次、库存状态(充足/紧张/约满)。库存紧张判断是前端根据后端返回的remainingCount字段算的,比如小于等于20就显示"仅剩少量"。这些状态判断放在前端的好处是交互反馈快,不用每次刷新都调后端。
预约流程页是核心交互页面,分三步完成:
- 选择接种点:从后端接口拉取当前批次疫苗可预约的接种点列表,展示地址、电话、当天剩余名额。
- 选择日期和时间段:日期组件禁用了今天之前的日期,时间段(上午/下午/晚上)根据接种点当天的预约配额动态控制是否可点击(已满则置灰)。
- 确认预约:前端展示预约信息的摘要(疫苗、批次、接种点、时间),用户确认后调
/api/appointment/create接口,成功后跳转预约成功页,页面展示核销码和预约单号。
这个三步交互把复杂表单拆分成了三个简单步骤,每步用户只需要做一个决定,转化率比一次性填写完整表单高得多。前端在每次"下一步"按钮点击时做一次轻量校验(是否选中可用日期、是否选中时间段),不给后端增加无谓的请求压力。
5.2 管理员端的疫苗发布与数据看板
管理员端疫苗管理的核心操作是"建档 -> 批次入库 -> 批次发布"。前端通过一个Tab页签切换三个子页面:疫苗档案、批次列表、发布审核。批次列表页每行显示批号、有效期、入库量、剩余量、状态,操作列根据状态动态渲染按钮——待审核状态显示"通过"和"驳回",已发布状态显示"下线",已下线状态显示"重新发布"。这样状态机驱动按钮显隐的写法,可以避免用户执行非法操作(比如对已发布的批次重复发布)。
数据看板是整个前端最有"企业级"感觉的部分。首页用ECharts做了三个图表:
- 折线图:最近30天每日接种人次
- 柱状图:各接种点接种量对比
- 饼图:各疫苗品种预约占比
这些图表数据来自后端的一个聚合统计接口,后端用一条GROUP BY的SQL把原始数据查出来,前端直接绑定渲染。图表配置不复杂,但要注意ECharts实例在组件销毁时记得dispose,否则频繁切换Tab会导致内存泄漏,页面越来越卡。这个坑我早期经常踩,后来成了习惯性的写进beforeDestroy生命周期里。
5.3 前端接口调用与状态管理的协作模式
这套系统的前端状态管理用的是Vuex,但只存了三个全局信息:token、userInfo、menus。其他数据都是页面组件内部data维护,通过接口获取。这个设计非常务实——不是所有数据都要进Vuex,全局状态只放"多页面共享且变化不频繁"的东西。如果你把预约列表、疫苗列表这种页面级数据也塞进Vuex,你会发现state越来越臃肿,代码越来越难调试。
axios封装的响应拦截器里做了统一错误处理:
service.interceptors.response.use( response => { const res = response.data; if (res.code === 200) { return res.data; } else if (res.code === 401) { // token过期,清除本地登录态,跳转登录页 localStorage.removeItem('token'); router.push('/login'); return Promise.reject(new Error('未登录或登录已过期')); } else { Message.error(res.message || '请求失败'); return Promise.reject(new Error(res.message)); } }, error => { Message.error('网络异常,请稍后重试'); return Promise.reject(error); } );统一在这里处理的好处是,每个具体调接口的页面代码非常干净,只管成功后的数据渲染,错误提示全部由拦截器兜底。如果某个接口有特殊的错误处理需求(比如预约失败要跳转重新选时间),页面代码里再单独catch即可。这种"默认统一、例外单独"的处理机制,是降低前端代码重复度的关键。
6. 本地环境搭建与项目部署实战
6.1 环境准备清单
跑这套源码之前,先把环境准备好。我列一个清单:
| 软件 | 版本要求 | 说明 |
|---|---|---|
| JDK | 1.8+ | 推荐用JDK 8,兼容性最好 |
| Maven | 3.6+ | 后端依赖管理 |
| Node.js | 14.x+ | 前端构建环境 |
| MySQL | 5.7+ | 建议8.0,注意连接驱动版本 |
| Redis | 非必需 | 这套源码暂未集成,但预留接口 |
Spring Boot版本注意一下,不同版本对JDK要求不同。如果下载的源码里pom.xml写的是Spring Boot 2.7.x,那JDK 8和JDK 11都能跑;如果已经是Spring Boot 3.x,就必须JDK 17+了。启动报"Unsupported class file major version"这类错误,十有八九就是这个不匹配导致的。
MySQL这边,建议提前把字符集设置为utf8mb4,不然疫苗名称里如果有特殊字符(比如"®"商标符号)可能存不进去。数据库的初始脚本一般在源码的/sql目录下,用Navicat或命令行执行source xxx.sql即可完成建库和初始数据导入。
6.2 项目启动步骤(后端+前端)
后端启动流程:
# 1. 修改application.yml里的数据库连接信息 # spring.datasource.url=jdbc:mysql://localhost:3306/vaccine_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai # spring.datasource.username=root # spring.datasource.password=你的密码 # 2. 在项目根目录执行Maven打包 mvn clean package -DskipTests # 3. 启动jar包 java -jar target/vaccine-system-1.0.0.jar如果是在IDEA里跑,直接右键VaccinationApplication.java选择Run即可。启动日志看到"Started VaccinationApplication"和Tomcat端口号(默认8080)就说明后端起来了。第一次启动建议先看日志里的SQL初始化情况,确认建表成功再启动前端。
前端启动流程:
# 1. 安装依赖(第一次会比较慢,建议用淘宝镜像) npm install --registry=https://registry.npmmirror.com # 2. 开发环境启动 npm run dev如果后端端口不是8080,需要改前端vue.config.js里的代理配置。我常用的devServer配置长这样:
devServer: { port: 9528, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, // 如果后端接口没有/api前缀,去掉这段注释让代理自动去除前缀 // pathRewrite: { '^/api': '' } } } }开发环境下用proxy代理,可以完美避开跨域问题。前端请求写/api/xxx,浏览器看到的是同源请求,代理把请求转发到后端8080端口。这个配置如果你自己起前后端项目,80%的跨域问题都是这么解决的。
6.3 生产部署的核心配置与超实用经验
生产环境部署,我推荐用Nginx托管前端静态文件,反向代理转发后端接口。Nginx配置的核心部分:
server { listen 80; server_name your-domain.com; # 前端静态文件 location / { root /opt/vaccine-front/dist; index index.html; 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; } }try_files $uri $uri/ /index.html这行是Vue路由history模式的标配,没有它,刷新一个/vaccine/list页面会直接404。生产环境下如果要https,记得在Nginx里配证书并加上location /的80端口跳转。
后端生产启动我建议用nohup配合日志输出:
nohup java -jar vaccine-system-1.0.0.jar --spring.profiles.active=prod > /opt/vaccine-backend.log 2>&1 &日志文件会越来越大,可以用Logrotate做日志轮转,或者在图方便的情况下定期手动清理。新手经常忽略日志管理,等出问题发现日志几百MB打不开,就真的是"历史事故"了。
7. 常见问题与排查技巧实录
7.1 后端启动失败速查表
| 报错信息/现象 | 大概率原因 | 解决方法 |
|---|---|---|
| Access denied for user 'root'@'localhost' | 数据库账号密码错误 | 检查application.yml里的username/password |
| Unknown database 'vaccine_system' | 数据库没有创建 | 手动执行CREATE DATABASE vaccine_system CHARACTER SET utf8mb4 |
| Table 'xxx' doesn't exist | 初始化脚本未执行 | 检查sql脚本执行情况,导入初始数据 |
| Port 8080 was already in use | 端口被占用 | 改server.port或用lsof -i:8080查占用进程并kill |
| java.sql.SQLException: No suitable driver | 缺少MySQL驱动 | 检查pom.xml里mysql-connector-java依赖是否存在 |
| bean初始化报错: Address already in use | 同上端口占用 | 有时是Redis也用了默认6379,但Redis没装导致连接失败 |
一个值得特别注意的点:新版MySQL 8.0的驱动类名换了。MySQL 5.x用com.mysql.jdbc.Driver,MySQL 8.0用com.mysql.cj.jdbc.Driver。如果你的数据库是8.0却配了老驱动类名,启动直接报驱动加载失败。这类配置型问题排查不难,但要细心。
7.2 前端启动与联调问题
前端开发中最常遇到的三个问题:
第一个,npm install网络卡死或报404。原因多半是官方源慢或某个依赖版本被下架。解决办法是配置淘宝镜像源,npm config set registry https://registry.npmmirror.com后再重装。如果还有个别包装不上,直接删掉node_modules和package-lock.json重新npm install,大概率能解决。
第二个,登录后页面空白,F12看到401。这是token丢失或过期导致的。检查后端返回的token是否正确保存,以及axios拦截器里的Authorization头名字和后端拦截器读取的名字是否一致。我见过最离谱的一次,是后端读的是token,前端传的却是Authorization,前端还一直没报错。这种"静默失败"最坑,一定要配合F12的Network面板看请求头。
第三个,接口返回的数据渲染不出来。这个优先看response.data的结构到底长什么样。很多时候前端拿的是res.data.data.vaccineList,但后端返回的是res.data.list,字段名对不上。建议在组件里先console.log(res)看一次结构再写渲染代码,别凭猜。
7.3 预约并发问题的真实场景还原
我拿测试环境模拟过,两个账号同时抢同一个批次疫苗的最后一个名额,我简单归纳一下异常场景和应对策略:
- 正常场景:两个请求都执行原子库存扣减SQL,数据库行锁保证只有一个请求更新成功,另一个获取的行数为0,后端返回"约满",无脏数据。
- 超时场景:库存充足但预约单插入耗时较长,事务长时间持有行锁,第二个请求等待锁超时,报"Lock wait timeout exceeded"。优化方案是避免在事务里做耗时操作(比如发短信通知、调用外部接口),这类操作放到事务提交后再执行。
- 重复预约场景:用户疯狂点"提交预约"按钮,产生并发请求。前端要加"提交中禁用按钮"的防抖处理,后端有唯一索引兜底。这套源码在两个层面都做了防护,这点做得比较严谨。
还有一个容易忽略的问题:核销码生成逻辑里如果用了UUID然后截断成6位,冲突概率会很高。源码的处理是"取哈希后6位+冲突重试",实测在百万条预约量级下偶发冲突但能自动重试处理,这个方案兼顾了可用性和碰撞降险。
7.4 效率类问题排查
系统用久了,最典型的问题是列表接口越查越慢。我给你列一条排查路径:
- 先看接口耗时:如果
SELECT * FROM appointment LEFT JOIN ...这种全表扫描,数据量上万后就明显变慢。 - 用
EXPLAIN看执行计划,重点看type字段是不是ALL(全表扫描)。 - 给外键字段加索引:
appointment.batch_id、appointment.user_id、appointment.site_id、appointment.status这些出现在WHERE和JOIN ON里的字段,都值得建索引。 - 如果订单量到了百万级,可以考虑按时间分表或引入ES做搜索,这是后话。
这套源码的索引建得比较基础,appointment表里对user_id和status建了单列索引,联查性能在十万级数据量内够用。如果你要基于这个项目做大并发改造,优先考虑引入Redis做疫苗库存的预扣减、预约列表查询的缓存,数据库只做最终一致性落库。
8. 写在最后的实操心得与扩展建议
关于这套源码,我最后分享几个实操中的真实感受:
第一,状态机设计是这套系统最值得学习的部分。预约状态从已预约到已核销到已取消的流转,每一步都有明确的触发条件(核销接口、取消接口、定时任务扫过期单)。很多新手做管理系统只关注"能查能增能改",忽略了状态流转的合法性和边界情况,这套源码把状态约束写在了代码和SQL里,这种"把规则写进实现"的习惯值得坚持。
第二,事务和并发安全是所有预约类系统的生命线。库存扣减的原子操作、状态更新的条件约束、事务回滚边界,任何一个环节出错都会直接导致业务事故。你可以在自己项目里做一次"高并发抢约"的压测,亲眼看到超卖是怎么发生的,再对比这套源码的处理方式,理解的深度完全不一样。
第三,前后端权限模型的协同是后台管理系统的通用模板。后端有拦截器和角色检查,前端有路由守卫和菜单权限,两层权限共同织网,单靠前端隐藏按钮是不够的,单靠后端校验又会牺牲交互体验。这套源码的权限设计不算复杂,但对中小型管理系统来说足够清晰。
这个项目后续扩展的空间也很大,我简单列几个方向:
- 引入Redis:把疫苗库存预热到Redis,预约扣减走Redis的
DECR命令,异步同步到MySQL,扛住高并发。 - 接入消息队列:核销成功后通过RabbitMQ/Kafka发送接种通知短信,异步解耦,避免同步阻塞事务。
- 引入定时任务:定时扫描过期未核销的预约单,自动更新状态并回补库存。
- 疫苗批次效期预警:对临近有效期的批次做颜色预警和自动下线,这是卫生领域非常实际的功能。
如果你手头正在写类似的预约管理系统,或者准备拿这个项目去面试讲项目,建议把状态流转、防超卖设计、权限控制这三个点讲透,这比背十道八股文都管用。这套源码的价值不在代码量,而在于它把"真实业务"和"企业级工程习惯"揉在了一起,顺着这个思路改造成你自己的项目,你会收获比"跑通一遍"多得多的东西。