我先说下对这个项目的整体判断:这标题一看就是典型的校园级/中小型项目”——前端 Vue 做页面交互、后端 SpringBoot 提供接口、MyBatis 管数据库访问、MySQL 存业务数据。技术栈不算新,但组合非常稳固,特别适合做毕设、课程设计,也适合想完整走一遍前后端分离开发流程的初学者。驾校预约这个业务场景选得也不错,既有用户端约车、约教练的真实需求,又有管理端排课、审核的后台逻辑,复杂度刚好处在一个“能讲清楚又不至于失控”的位置上。
我自己用一个模拟项目把这套流程完整跑通过一遍,从建表到接口设计,从预约并发到部署上线,踩了不少坑。下面就把整个项目从零到实操的拆解、关键代码思路和避坑经验整理出来,给准备做类似系统的同学一份可以直接“抄作业”的参考。
1. 驾校预约管理系统到底要管哪些事
很多人一看到“预约管理系统”就默认它是个简单的课程表,其实驾校预约的业务逻辑比表面看起来要麻烦不少。先把真实业务场景梳理清楚,才能设计出靠谱的表结构和接口。
1.1 业务角色与核心流程拆解
驾校预约管理系统里至少有三类角色。学员是核心使用者,他们要注册、登录、选择教练、预约训练时段、查看自己的学时记录。教练端需要查看自己被预约的时段、确认或取消预约、记录学员当次训练情况。管理员则要管理教练信息、审核学员资料、配置训练场次、处理异常预约、统计整体运营数据。
这三类角色对应到系统里就是三种不同的权限体系,后端接口要按照角色做访问控制,前端页面也要按角色做路由拦截。
预约流程本身会比想象中复杂。我先列一个完整链路:学员登录后选择教练(或场地)→ 查看该教练可用时段 → 提交预约申请 → 系统判断冲突(教练时间冲突、学员时间冲突、时段容量限制)→ 预约成功进入待确认状态 → 教练端确认后变为已确认状态 → 学员按时到场训练 → 教练记录本次训练学时 → 预约闭环完成,生成学时记录。
这个流程里有几个容易被初学者忽略的点:同一时段可能多个学员抢同一个教练,需要做并发控制;预约状态不是简单的“已预约/未预约”两种状态,而是有“待确认、已确认、已完成、已取消”等多个中间状态;教练每个时段的可预约人数上限需要单独配置,不能简单按“一人一时段”来设计。
1.2 最小可用功能清单
基于上面这个业务流程,我列了一份功能清单,按优先级分了三层,做系统的时候建议严格按照这个顺序开发,先跑通核心链路再补边缘功能。
核心功能层(必须完整跑通):学员注册登录与个人信息维护、教练列表与详情查看、空闲时段查询、预约提交与取消、预约状态流转、教练端确认训练、学员学时记录查询。
管理功能层(第二优先级):管理员登录、教练信息管理(增删改查)、学员审核、训练场地与时段配置、预约列表总览与干预、基础数据统计。
增强功能层(有余力再上):课程套餐管理、考试进度跟踪、消息通知(站内信或邮件)、满意度评价、练车打卡签到。
我建议不要一上来就铺开所有功能,先把核心链路做通——“学员能预约、教练能确认、管理员能看数据”,这个闭环完成后系统已经可以用,后面再慢慢加辅助功能,节奏会舒服很多。
2. 技术选型的细节逻辑与前后端工程结构
标题里的技术栈已经很明确,但为什么是这几个组件、它们各自承担什么职责、在工程上如何组织协作,这些才是真正值得展开说的部分。
2.1 SpringBoot、Vue、MyBatis、MySQL分别解决什么问题
SpringBoot负责把后端整个 HTTP 服务架起来。它解决的是传统 SSM 项目里令人头疼的复杂配置问题,内嵌 Tomcat 让应用可以直接以 jar 包形式运行,这对部署来说是极大便利。SpringMVC 的请求路由、参数绑定、响应封装,在 SpringBoot 里基本靠注解就能搞定。
Vue 负责前端展示与交互。在前后端分离的项目里,Vue 不关心数据怎么存、接口怎么实现,它只做三件事:根据路由渲染对应页面、通过 Axios 调用后端接口拿数据、把用户操作(点击、输入、表单提交)变成 HTTP 请求发出去。Vue 的响应式数据绑定让页面状态和数据的同步变得非常自然,不需要手动操作 DOM。
MyBatis 是数据访问层的核心。它解决的是 Java 对象和数据库行数据之间的映射问题。在驾校预约系统里,学员信息、预约记录、教练时段这些数据要落地到 MySQL,查询结果要组装成 Java 对象返回给前端,这个过程如果用 JDBC 手动写,连接管理、参数设置、结果集映射都是灾难。MyBatis 通过 XxxMapper.xml 文件把 SQL 语句和 Java 方法的对应关系说清楚,持久层这摊事就干净了。
MySQL 是数据存储底座。它在这里的任务很明确:存储业务数据、保证事务的 ACID、支撑条件查询和统计。
2.2 为什么持久层选 MyBatis 而不是 JPA
这个话题几乎每次做项目都会被问。我个人在这个项目里选择 MyBatis 有两个原因。
第一是可控性。驾校预约系统的查询场景比较刁钻:多表关联查询多(学员表关联预约记录表关联教练表)、动态条件多(按日期筛选、按教练筛选、按状态筛选)、分页统计复杂。MyBatis 允许直接手写 SQL,在 XML 里用<if><where>标签拼动态条件,写起来一步到位,可控性极其舒服。JPA 虽然能用Specification或QueryDSL解决问题,但破案成本和学习曲线都更高。
第二是熟悉度。坦白讲,国内很多项目、包括大多数教学资源,都以 MyBatis 为主。新手遇到问题了网上资料一搜就能解决,前人踩坑经验非常丰富。技术和生态的正确匹配,比技术本身“更高级”要重要。
2.3 前端工程结构和后端包结构规划
规划日常项目,最怕结构混乱,改一行代码要在五六个文件夹里翻来找去。我习惯在动手写代码之前就把目录结构定好,前后端各用一套,后面再往里面添砖加瓦就行。
前端的 Vue 工程我通常这样组织:
src/ api/ # 按模块拆分的接口请求文件 auth.js # 登录注册相关接口 coach.js # 教练信息相关接口 reserve.js # 预约相关接口 admin.js # 管理端相关接口 assets/ # 静态资源 components/ # 通用组件 router/ # 路由配置(含守卫) store/ # 全局状态管理 views/ student/ # 学员端页面 coach/ # 教练端页面 admin/ # 管理端页面 login.vue # 登录页 register.vue # 注册页 utils/ request.js # Axios 封装(拦截器)后端的包结构我习惯按功能模块分包,而不是按技术层次分包:
com.example.driving controller/ # 接口层(只做参数接收和响应封装) service/ # 业务逻辑层(核心判断都在这里) mapper/ # MyBatis 数据访问接口 entity/ # 数据库实体类 dto/ # 数据传输对象(请求参数和响应参数) config/ # 配置类(跨域、拦截器、JWT) common/ # 通用类(统一响应、异常处理、常量) util/ # 工具类按功能模块分包的好处是,改一个预约相关的功能,你要动的文件全在 reservation 这个业务链路里,找起来非常快。
3. 驾校预约系统的数据库设计实战
数据库设计是整个系统里最值得花时间的部分,表结构没定好,后面写接口和页面都是在沙滩上盖楼,改起来非常痛苦。预约业务的表设计有几个关键决策点,我逐个展开说。
3.1 核心数据表字段设计与关联关系
驾校预约管理系统的表设计要覆盖用户、教练、预约、学时四条核心业务线。我给出实际在项目中验证过的表设计思路,重点是字段的定义依据和表之间的关联关系。
第一张是用户表(user),设计时要注意区分学员和教练的身份。常见的做法是共用一张用户表加用户类型字段,但考虑到教练有独立的教龄、简介、服务价格等扩展信息,我建议拆成学员表(student)和教练表(coach)两张,登录时用用户类型字段区分。学员表核心字段包括:id、登录账号、密码(加密存储,建议BCrypt)、姓名、手机号、身份证号、报名时间、学员状态(待审核/正常/冻结)。教练表核心字段包括:id、登录账号、密码、姓名、手机号、准驾车型、教龄、个人简介、每小时价格、可预约时段配置、状态。
第二张是教练时段配置表(coach_schedule),这是预约系统里比较重要也比较容易设计失误的一张表。它用来定义教练每天哪些时间段可以预约。主要字段有:id、教练id、日期、开始时间、结束时间、最大预约人数、已预约人数、时段状态(可约/停用)。这张表的数据通常是管理员或教练提前配置的,这比硬编码固定时段灵活得多。
第三张是预约记录表(reservation),这是全系统的核心表。字段设计是这样的:id、预约单号(业务编号)、学员id、教练id、日程时段id、预约日期、开始时间、结束时间、预约状态(待确认/已确认/已完成/已取消/已过期)、创建时间、确认时间、完成时间、取消原因、训练内容备注。
第四张是学时记录表(study_record),用于记录学员每次完成的训练。字段包括:id、学员id、教练id、预约单id(关联到已完成预约)、训练开始时间、训练结束时间、实际学时数、训练内容、学员评价。
五张表之间的关系是:学员表和教练表本身是并行关系,通过预约记录表建立业务关联;教练时段配置表与预约记录表是一对多关系——一个时段可以被多个学员预约(只要不超过最大人数);预约记录表与学时记录表是一对一关系,一次训练对应一条学时记录。
3.2 预约状态机设计与字段约束
预约状态设计,我建议用一组数字常量来表示,而不是直接用字符串。数字枚举的好处是:存储空间小、查询效率高、后端代码里可读性好。我的设计是这样的:
1:待确认(学员提交预约,教练还未处理) 2:已确认(教练确认接收本次预约) 3:已完成(训练结束,学时已经记录) 4:已取消(学员或教练主动取消) 5:已过期(学员未按时赴约,系统自动过期)
状态机流转规则需要在后端 Service 层强制约束,不能只靠前端页面控制。学员取消预约只允许在“待确认”和“已确认”状态下操作;教练确认预约只能从“待确认”变成“已确认”;完成训练只能从“已确认”变成“已完成”;过期处理需要定时任务扫描配合。每次状态变更都要记录时间戳,方便后续对账和排错。
数据库字段层面的约束也很值得强调。一个是预约唯一性问题:同一个教练在同一个时间片段不应该出现两条冲突的预约,我直接在预约记录表建了联合索引(coach_id, start_time, status)来兜底,哪怕是并发请求同时打过来,数据库层面也会拒绝冲突数据。另一个是状态合法性问题:可以在 MySQL 5.7+ 中用 CHECK 约束限制状态字段只能取 1-5,但考虑到通用性和后续状态扩展,我在项目里是靠 Service 层校验保证的。
3.3 关键查询 SQL 的 MyBatis 写法
预约系统的核心查询有两个:一是查询某个教练某个日期剩下的可预约时段,二是查询某个学员的预约历史列表。这两个查询在 MyBatis 里的写法很典型,我贴出来给大家参考。
第一个查询——某教练某天的可预约时段列表,要关联教练时段配置表和预约记录表,计算每个时段剩余名额:
<select id="selectAvailableSchedules" resultType="map"> SELECT cs.id AS scheduleId, cs.start_time AS startTime, cs.end_time AS endTime, cs.max_count AS maxCount, cs.max_count - IFNULL(booked.count, 0) AS remainCount FROM coach_schedule cs LEFT JOIN ( SELECT schedule_id, COUNT(*) AS count FROM reservation WHERE status IN (1, 2) GROUP BY schedule_id ) booked ON booked.schedule_id = cs.id WHERE cs.coach_id = #{coachId} AND cs.schedule_date = #{scheduleDate} AND cs.status = 1 AND (cs.max_count - IFNULL(booked.count, 0)) > 0 ORDER BY cs.start_time ASC </select>这个 SQL 的精髓在LEFT JOIN子查询:先把已确认和待确认的预约按日程时段分组计数,再和时段配置表左连接,计算出每个时段的剩余容量。用IFNULL处理没有预约记录的时段,避免出现 NULL 导致判断失效。
第二个查询——学员的预约历史列表,要关联学员表、教练表和时段配置表,把预约记录展示需要的冗余信息一次查出来:
<select id="selectStudentReservationList" resultType="com.example.driving.dto.ReservationDTO"> SELECT r.id AS reservationId, r.reservation_no AS reservationNo, r.status AS status, r.create_time AS createTime, r.reservation_date AS reservationDate, r.start_time AS startTime, r.end_time AS endTime, c.name AS coachName, c.car_model AS carModel, cs.address AS trainAddress FROM reservation r LEFT JOIN coach c ON c.id = r.coach_id LEFT JOIN coach_schedule cs ON cs.id = r.schedule_id WHERE r.student_id = #{studentId} ORDER BY r.create_time DESC </select>这类查询其实就是把前端需要展示的冗余字段通过 JOIN 一次性取回来,避免在 Java 代码里做二次查询组装。
4. 后端接口设计与核心业务流程代码串联
表结构定好后,后端接口设计就是把这套数据结构变成可调用的业务能力。驾校预约系统的接口设计要把握一个原则:接口按业务动作划分,而不是按数据表划分。拿预约记录表举例,不要设计成通用的“增删改查”四个接口,而要按照业务动作拆分。
4.1 接口清单与统一响应格式
我列出这份接口清单,也是项目里实际使用的接口,覆盖了学员和教练两端的主流程。前端页面开发时直接对着这个清单写,流程不会乱。
| 模块 | 接口路径 | 方法 | 描述 |
|---|---|---|---|
| 认证 | /api/auth/register | POST | 学员注册 |
| 认证 | /api/auth/login | POST | 登录(返回JWT Token) |
| 教练 | /api/coach/list | GET | 教练列表(分页+条件) |
| 教练 | /api/coach/detail | GET | 教练详情 |
| 时段 | /api/schedule/available | GET | 查询教练可用时段 |
| 预约 | /api/reservation/create | POST | 提交预约 |
| 预约 | /api/reservation/cancel | POST | 取消预约 |
| 预约 | /api/reservation/confirm | POST | 教练确认预约 |
| 预约 | /api/reservation/complete | POST | 教练完成训练 |
| 预约 | /api/reservation/myList | GET | 我的预约列表 |
| 学时 | /api/studyRecord/myRecords | GET | 我的学时记录 |
| 管理 | /api/admin/coach/save | POST | 添加或编辑教练 |
| 管理 | /api/admin/schedule/config | POST | 配置教练时段 |
| 管理 | /api/admin/reservation/list | GET | 全部预约列表 |
统一响应格式很重要,前后端联调能不能顺畅,就看响应结构是不是稳定。我使用的格式是:
public class Result<T> { private Integer code; // 200表示成功,其他表示业务异常 private String message; // 提示信息 private T data; // 载荷数据 }登录成功时返回的 data 里包含 token、用户类型、用户基本信息。业务异常时 code 返回自定义的错误码,比如400表示参数不合法、50001表示预约冲突、50002表示时段已满。前端 Axios 拦截器统一处理 code,不等于 200 就弹出 message 提示用户。这样运维同学排查问题也方便,看 code 就知道是哪一类错误,不需要逐条日志翻。
4.2 提交预约的完整代码链路与并发防护
提交预约是整个系统里最核心的后端动作,我来完整串一遍代码链路,这也是面试时最容易被追问的部分。
从前端发起请求到响应返回,完整的链路是:Vue 页面调用 Axios 发送 POST 请求 → SpringMVC 的 Controller 接收参数 → Service 层做业务校验与数据落库 → MyBatis 执行 SQL → 返回统一响应给前端。
Controller 层代码很薄,只做参数接收和响应封装:
@RestController @RequestMapping("/api/reservation") public class ReservationController { @Resource private ReservationService reservationService; @PostMapping("/create") public Result<?> create(@RequestBody @Valid ReservationCreateDTO dto, @RequestAttribute("userId") Long userId) { reservationService.createReservation(dto, userId); return Result.success(); } }Service 层的核心校验与落库逻辑我写得比较详细,这是整个预约流程的关键地带:
@Service public class ReservationServiceImpl implements ReservationService { @Resource private ReservationMapper reservationMapper; @Resource private CoachScheduleMapper scheduleMapper; @Override public void createReservation(ReservationCreateDTO dto, Long studentId) { // 1. 查询教练时段配置,检查时段是否存在且启用 CoachSchedule schedule = scheduleMapper.selectById(dto.getScheduleId()); if (schedule == null || schedule.getStatus() != 1) { throw new BusinessException("该训练时段不存在或已停用"); } // 2. 校验时段日期不能在今天之前,不能预约已经开始的时段 LocalTime startTime = LocalTime.parse(schedule.getStartTime()); if (schedule.getScheduleDate().isBefore(LocalDate.now()) || (schedule.getScheduleDate().isEqual(LocalDate.now()) && startTime.isBefore(LocalTime.now()))) { throw new BusinessException("该时段已过期,请选择未来时段"); } // 3. 校验学员自身是否有时间冲突(同一时间段不能有两个预约) int conflictCount = reservationMapper.selectConflictCount( studentId, schedule.getScheduleDate(), schedule.getStartTime(), schedule.getEndTime()); if (conflictCount > 0) { throw new BusinessException("您在该时间段已有预约,请勿重复预约"); } // 4. 校验教练时段是否已满员(用行锁或乐观锁控制并发) int updated = scheduleMapper.decreaseRemainCount(schedule.getId()); if (updated == 0) { throw new BusinessException("该时段预约人数已满"); } // 5. 生成预约记录,初始状态为待确认 Reservation reservation = new Reservation(); reservation.setReservationNo(generateReservationNo()); reservation.setStudentId(studentId); reservation.setCoachId(schedule.getCoachId()); reservation.setScheduleId(schedule.getId()); reservation.setReservationDate(schedule.getScheduleDate()); reservation.setStartTime(schedule.getStartTime()); reservation.setEndTime(schedule.getEndTime()); reservation.setStatus(ReservationStatus.PENDING_CONFIRM.getValue()); reservation.setCreateTime(LocalDateTime.now()); reservationMapper.insert(reservation); } }这个链路里最值得反复琢磨的是第四步——并发控怎么防超卖。
假设同一个教练半天里只能同时带两辆车,学员 A 和 B 同时提交预约,如果代码只是先查剩余名额再更新数据库,A 和 B 可能同时读到“还剩 1 个名额”,然后两个人同时往库里面插入预约记录,系统就超卖了一个。我用的是“条件更新占位”法:在更新已预约人数的 SQL 里直接判断剩余数量大于零,更新返回影响行数为 0 就说明已经被抢光了。
<update id="decreaseRemainCount"> UPDATE coach_schedule SET booked_count = booked_count + 1 WHERE id = #{id} AND booked_count < max_count </update>这段 SQL 利用数据库行锁机制,在UPDATE执行时该记录会被锁住,其他事务要更新同一行必须等待,这就天然串行化了抢占过程。只有更新成功(影响行数大于 0)的事务才允许继续插入预约记录,从根源上避免超卖。这也是“乐观锁”思想的典型应用。如果项目要求更高并发量,还可以考虑 Redis 预扣库存的方案,驾校场景下这种级别用 MySQL 条件更新已经绰绰有余。
4.3 定时任务处理过期预约
学员预约了时段没来练车,这种单子不能一直挂在“已确认”状态,否则教练的时段被占着却没实际训练,影响业务统计。我写了一个定时任务,每小时扫描一次,把已过期但没被处理掉的预约自动标记为过期状态。
@Component public class ReservationExpireTask { @Resource private ReservationMapper reservationMapper; @Scheduled(cron = "0 0 * * * ?") public void expireReservations() { // 将预约日期是今天之前但状态仍为待确认或已确认的记录置为过期 reservationMapper.expireOverdueReservations(LocalDate.now().minusDays(1)); } }如果希望更精细一些,把“预约当天超过了开始时间还没确认到场”也判定为过期,可以在这个基础上再叠加开始时间判断条件。这个逻辑虽然简单,但体现了一个系统的健壮性,不是只完成页面操作就算结束,后台的扫地任务也要替业务自动兜底。
5. 前端核心页面与权限拦截的实现要点
前端部分看起来是 Vue 技术栈常规操作,但预约系统的特殊性在于角色差异大,权限控制和页面逻辑要比普通的 CRUD 系统复杂。我挑几个关键实现点重点展开。
5.1 前端路由守卫与角色权限拦截
系统有三种角色:学员、教练、管理员。前端不能只靠“登录了就放行”这种简单判断,必须在路由守卫里做角色拦截,否则学员把 URL 改成管理页面地址就能直接访问后台接口,这是典型的越权漏洞。
前端路由配置里,我给每个路由都注入了 meta 信息,标明哪些角色可以访问:
const router = new createRouter({ routes: [ { path: '/student/dashboard', component: () => import('@/views/student/Dashboard.vue'), meta: { roles: ['STUDENT'] } }, { path: '/coach/schedule', component: () => import('@/views/coach/Schedule.vue'), meta: { roles: ['COACH'] } }, { path: '/admin/reservation-center', component: () => import('@/views/admin/ReservationCenter.vue'), meta: { roles: ['ADMIN'] } } ] }) router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (!token) { // 未登录一律去登录页 if (to.path === '/login' || to.path === '/register') { next() } else { next('/login') } return } const userRole = localStorage.getItem('userRole') if (to.meta.roles && !to.meta.roles.includes(userRole)) { // 角色不匹配,跳转到对应首页 next('/forbidden') return } next() })个守卫写法虽然简单,但足够拦截绝大多数越权访问。做项目时很多人只关注接口的安全性,忽略了前端页面本身的暴露问题。前端拦截的意义不在于“安全”,而在于“体验”——用户不该在页面上看到他用不到的功能。
更严谨的做法是在后端接口上做同样的角色校验,我是用 SpringBoot 拦截器统一处理的,在进入 Controller 之前校验 JWT 中的角色是否满足接口要求的角色,这样即使请求被伪造也能被拦截住。前端拦截和后端拦截是两层保险,缺一不可。
5.2 Axios 封装与 Token 刷新处理
Axios 封装是整个前端工程的骨架。我用utils/request.js统一处理三件事:请求头自动携带 Token、响应数据统一解包、错误状态统一处理。先贴出封装的代码:
import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' const request = axios.create({ baseURL: '/api', timeout: 15000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }) request.interceptors.response.use( response => { const res = response.data if (res.code === 200) { return res.data } if (res.code === 401) { localStorage.removeItem('token') router.push('/login') return Promise.reject(new Error('登录已过期')) } ElMessage.error(res.message) return Promise.reject(new Error(res.message)) }, error => { ElMessage.error(error.message) return Promise.reject(error) } ) export default request封装之后,页面里的 API 文件就非常干净,只需要关心接口地址和参数:
import request from '@/utils/request' export function createReservation(data) { return request({ url: '/reservation/create', method: 'post', data }) }关于 Token 刷新,我做了一个简化处理:登录时返回 accessToken 和 refreshToken 两个字段,accessToken 有效期 30 分钟,refreshToken 有效期 7 天。在响应拦截器里判断 code 为 401 时,先用 refreshToken 静默换新 Token,成功则重放原请求,失败才清空登录信息跳登录页。这个逻辑稍微增加点代码量,但对用户体感提升非常明显——预约操作正做到一半突然被弹出去重新登录,体验是很糟糕的。
5.3 学员预约教练页面的交互设计
学员端最核心的页面是“预约教练”页面。我实际做的时候用了三步交互来避免用户迷失:第一步选择教练,展示教练列表,列表项包含头像、姓名、准驾车型、教龄、每小时价格;第二步查看教练可预约时段,点击教练进入详情后默认展示“未来 7 天”的时段日历,每一天下列出该教练当天所有可预约时间段,每个时间段展示剩余名额;第三步确认预约信息,弹出确认框展示学员信息、教练信息、预约时段信息。
第三步提交前的确认框虽然多了一步操作,但我强烈建议保留。用户在这时还能反悔,等到预约已经生成了再取消,状态机又要多走一轮,对教练端和数据库都不友好。好的交互设计不是让用户操作最少,而是让用户的操作尽可能可靠。
时段日历组件可以直接基于 Element Plus 的 Calendar 组件或自己写一个按月排班的组件。我建议不要自己造轮子,用现成的日期选择器加自定义列表渲染,开发效率会高不少。
6. 部署、踩坑与常见问题排查
前后端分离系统的部署比传统单体项目要繁琐,联调环境里跑得顺畅不代表生产环境没问题。我把部署过程和踩坑经验整理在这里,这些都是平时文档里不会写但实操中特别容易卡住的东西。
6.1 前后端打包与部署流程
后端打包时我在 pom.xml 里配置了 Maven 打包插件,最终得到可执行 jar 包。部署命令很简单:
mvn clean package -DskipTests java -jar driving-admin.jar --spring.profiles.active=prod生产环境配置文件application-prod.yml需要修改下面几项:数据库连接地址用户名密码、Redis 地址(如果有用到)、日志文件路径、 JWT 密钥(不能用默认值)、文件上传路径。
前端部署相对要注意一些细节。Vue 项目打包后是纯静态文件,npm run build会生成 dist 目录。我把 dist 整个目录上传到服务器上,使用 Nginx 作静态文件服务器,并把/api路径反向代理到后端服务的 8080 端口。Nginx 配置核心片段:
server { listen 80; server_name your.domain.com; root /home/www/driving/dist; index 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; } location / { try_files $uri $uri/ /index.html; } }其实配置里的try_files $uri $uri/ /index.html;一行为配置 Vue Router 的 history 模式刷新 404 问题——直接访问/student/dashboard时 Nginx 找不到这个物理路径,需要让它回退到 index.html,由前端路由接管路径解析,这样页面刷新就不会报错。如果不做这一步,前端推上生产环境后一刷新就白屏,新手很可能在这里栽跟头。
6.2 开发与生产环境联调的真实踩坑记录
第一坑:跨域请求被拦截。开发时 Vue 跑在 5173 端口,后端跑在 8080 端口,浏览器直接请求就会出现跨域问题。有两个解决方案:开发环境用 Vite 的 proxy 配置代理/api,生产环境用 Nginx 反向代理,这样浏览器看到的请求是同源的,不需要后端处理跨域。很多初学者在线程上用 SpringBoot 的@CrossOrigin注解全开跨域,一旦上了生产环境,Nginx 代理就失效了,还得改代码。这几个方案优先级我建议是:Nginx/Vite 代理 > CORS 过滤器白名单 >@CrossOrigin全开。推荐前两种,因为前端代理方案能让生产环境和开发环境代码一致,省心省力。
第二坑:MyBatis 驼峰映射配置缺失,属性全是 NULL。数据库字段习惯用下划线命名(create_time),Java 属性习惯用驼峰命名(createTime),如果 MyBatis 没有开启驼峰映射开关,查询结果里这些属性就都是 NULL。解决方式是在application.yml配置文件中设置map-underscore-to-camel-case: true。
第三坑:日期时间类型时区问题。MySQL 连接串里没配置serverTimezone=Asia/Shanghai,在高版本 MySQL 驱动下会直接报连接错误,或者时间差了 8 个小时。这个问题在本地开发时不明显,一部署到和服务器时区不同的环境就暴露了。连接串配置建议直接固定:
spring: datasource: url: jdbc:mysql://127.0.0.1:3306/driving_school?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false第四坑:文件上传大小限制。头像上传如果超过 SpringBoot 默认的 1MB 限制会报错,可以在application.yml里调大:
spring: servlet: multipart: max-file-size: 5MB max-request-size: 10MB6.3 预约接口并发压力自测方法
项目写完不能直接交付,至少要自测一下核心接口的并发表现。我用的方式很简单:JMeter 开 20 个线程对同一个教练的同一个时段并发提交预约,观察是否有超卖数据产生。
测试之后要检查两个数据指标:一是最终预约记录总数不能超过该时段最大预约人数;二是数据库中已预约人数字段必须等于实际预约记录数。如果这两个值对不上,说明并发控制逻辑有问题,没有漏掉的话才说明减库存逻辑是靠得住的。
另外建议把关键查询的慢 SQL 日志开启,看看预约列表查询是否有索引缺失问题。在测试环境的数据量下可能看不出差别,但线上数据积累一段时间后,没有索引的表会把接口响应时间从毫秒级拖到秒级。我给预约记录表的student_id、coach_id、schedule_id、status字段都加了普通索引,覆盖大部分查询条件。
7. 这类系统后续可以怎么升级扩展
驾校预约管理系统做完了,不等于项目就到此为止。以这套代码为基础,后续可以往多个方向扩展,这里分享一下我自己的扩展思路,也是想让这个项目具备更长远的参考价值。
考虑到预约场景最常见的痛点不是“约不上”,而是“排好的信息经常变动”,我建议增加微信公众号或小程序的预约入口。移动端的天然优势是通知触达直接,预约成功、教练确认、训练前提醒,都可以通过模板消息主动推送给学员,相比站内信和短信节省成本,体验也更好。思路和技术难度并不大:小程序端复用现有后端接口,鉴权方式换成微信登录授权,通知模块接入模板消息能力即可。
第二梯队是增加支付功能,比如学员购买学时套餐、补差价、教练的按次结算。支付接入的核心难点不在支付本身,而是订单状态和预约状态的联动——支付成功后才有资格预约,退款时需要检查是否已经产生学时记录,这些联动逻辑要理清楚。
如果数据量继续增长,还可以考虑引入 Redis 缓存优化。目前我的实现里,所有预约操作都直接落到 MySQL,高峰期预约接口的查询量会比较大。把“教练未来7天可用时段”这一热点数据缓存到 Redis,缓存命中率会非常高,到期时段通过定时任务刷新,接口响应速度会在现有基础上再上一个台阶。
不管往哪个方向扩展,这套系统的核心架构不动,SpringBoot + Vue + MyBatis + MySQL 的组合依然能支撑很长一段时间。业务复杂度上来了再考虑拆分微服务或者引入消息队列,现阶段没有必要为这些复杂度买单。
我个人的体会是,完成一个这类系统最有价值的收获,不在于技术栈本身有多新,而在于把“学员预约教练”这个看似简单的业务做扎实的过程中,理解了并发控制、状态机、权限管理、前后端联调这些通用的工程问题。把这些问题想通之后,换一个业务场景(比如实验室预约、会议室预约、宠物美容预约)其实都是同一套套路,核心架构和关键逻辑都能直接复用。这也是我为什么一直建议把第一个项目做透、做完整,而不是浅尝辄止地跑一个 Demo。