1. 驾校预约到底卡在哪手工排班的混乱让我决定做这套系统说实话最初决定做一套基于SpringBootVue的驾校预约学习系统不是因为我多喜欢写业务代码而是被某驾校前台那本厚厚的纸质预约本彻底逼疯了。每次学员打电话约车前台都要翻本子、对时间、确认教练有没有空遇到教练临时调课还要挨个打电话通知一整天下来光协调时间就要耗掉大半精力。更麻烦的是同一个时段经常被口头占掉两个学员到现场才发现撞车教练和学员都一肚子火。所以这个项目的出发点非常朴素把“预约”这个动作从线下搬到线上让学员自己看得到哪个时段可约、哪个教练有空让教练自己维护可约时间让管理员能实时看到全场的预约情况。技术上选了Java生态最常见的三件套——SpringBoot做后端、MyBatis操作MySQL、Vue写管理界面配合完整源码和初始化SQL做成一套真正能跑、能演示、能二次开发的完整项目。这篇文章我打算把从需求分析到数据库设计、从后端接口到前端联调的全过程拆开讲重点说清楚那些网上教程不会写的东西并发预约怎么防超卖、状态怎么流转、本地跑起来会踩哪些坑。1.1 业务现场的三类角色与日常混乱先还原一下真实的驾校预约业务。跟我对接的某驾校规模大概二十几辆车、三十多名教练每月在训学员四五百人。日常约车流程是这样的学员想练车得提前一天打电话给前台前台翻纸质记录本找某个教练的空闲时段记下来然后学员当天到场再签字确认。听起来不复杂但实际运行中的问题特别多。第一类是时间冲突。本子上记录的是“某教练某天上午可约几个名额”但教练经常因为路考带考、车辆维修、临时有事调整安排记录本跟不上变化就会出现学员到场了教练不在、或者两个学员被约到同一个教练同一时段的情况。第二类是爽约率高。没有线上约束学员口头预约后临时不来的情况很常见教练空等一小时课时白排。第三类是统计困难。月底要算每个教练带了多少学员、多少课时全靠前台翻本子手工统计错漏是家常便饭。这三类问题其实就是系统的核心需求来源。我把它们翻译成系统语言就是时间片要标准化、预约要留痕可追溯、状态要能流转。1.2 从痛点倒推出来的功能清单基于上面这些痛点我没有急着画原型而是先列了一张角色-功能对照表这是整个项目后面所有开发的依据。角色核心功能解决的原生痛点学员注册登录、浏览教练与时段、发起预约/取消预约、查看我的预约记录解决约车靠打电话、看不到空闲时段的问题教练维护可约时段、查看当日预约名单、确认/拒绝预约解决排班被动、时间被随意占用的痛点管理员管理学员/教练账号、维护车辆与场地、查看全部预约、数据统计解决月底手工统计、账目不清的问题功能清单确认后我又做了一轮减法。很多演示项目喜欢堆功能比如加个论坛、加个在线支付、加个消息推送。我都没做理由很简单预约系统的核心闭环是“学员约-教练确认-到课-归档”所有功能都应该围绕这个闭环展开。功能越多演示时越容易出bug评审时越难把核心逻辑讲清楚。1.3 系统边界哪些功能我刻意没做这里想多说一句边界意识。我见过不少类似项目把支付、短信、人脸识别都塞进来结果每个功能都做得半吊子。我的原则是做深不做宽。这套系统里我刻意砍掉了三块一是没有做在线支付驾校学费都是线下收预约阶段不需要动钱二是没有做消息推送而是用站内通知加预约状态变化来替代避免申请短信服务商的麻烦三是没有做练车路线的定位功能因为实际驾校场地固定用不上。这样砍完之后整个系统的功能树非常干净每个模块都能自圆其说。演示的时候你可以在十分钟之内把“学员预约→教练确认→管理员核销”这条主线走完评审的人和试用的用户都能一眼看懂。2. 技术选型的真实理由SpringBootVueMyBatis不是随便拼的技术选型这部分很多文章喜欢说“SpringBoot是目前最流行的框架”就完了。但选型这件事背后都是有原因的尤其是预约系统这种业务逻辑偏重的场景选错框架后面写代码会非常痛苦。我下面按前后端分开说清楚我的考量。2.1 后端为什么是SpringBootMyBatis而不是JPA后端选SpringBoot基本没什么悬念。它内嵌了Tomcat不用单独配服务器自动配置省掉了大量XML配置生态里跟MySQL、MyBatis的整合资料遍地都是。这套项目面向的目标是能跑、能演示、能二次开发所以用了SpringBoot 2.6.x这个非常成熟的版本网上踩坑记录也全出问题基本搜得到答案。真正需要想清楚的是持久层框架。现在Java后端的持久层方案主要就是MyBatis和Spring Data JPA两派。JPA胜在开发快实体类一标注解CRUD基本不用写SQL但问题在于预约系统里有大量“多表联查条件动态拼接”的场景比如“查某教练某天剩余的时段”“查某学员的预约记录并带出教练姓名和车牌号”。JPA用JPQL或者Specification写这类查询语法绕且不容易调优最后还是要落到原生SQL。MyBatis则相反它把SQL完全交给你XML里写联查、写动态条件、写锁语句都很直觉SQL性能问题一眼就能看出来。代价是简单CRUD也要自己写SQL开发量稍微大一点。对于预约系统这个场景我认为可控制的SQL比少写代码更重要。所以我最终选型是SpringBoot MyBatis MySQL 8。2.2 前端为什么是Vue2Element UI前端选型上我用的Vue 2.6 Element UI。我知道现在Vue 3已经出来很久了但如果你的目标跟这个项目一样——快速交付一个管理后台加业务页面Vue 2 Element UI依然是当下资料最多、应用最普及的组合。选它的核心理由有三个。第一Element UI的表格、表单、日期选择器、弹窗这些组件几乎是给管理后台量身定做的。预约系统的页面形态就是大量表格加表单开箱即用能省掉大量样式工作量。第二Vue 2的响应式机制和生命周期网上有海量的中文教程遇到问题基本都有现成答案。第三跟后端联调时Vue的axios封装和路由守卫写起来非常直接不会在框架理解上消耗太多精力。另外说明一点如果你有精力这套系统的前端结构是可以平滑迁移到Vue 3 Element Plus的组件用法差异不大后文我会提一下迁移注意点。2.3 整体调用链与项目目录结构整套系统的调用链其实很简单一句话就能说清浏览器里的Vue页面通过axios请求后端RestControllerController调用Service处理业务逻辑Service通过MyBatis的Mapper接口访问MySQL数据以JSON格式返回给前端渲染。工程结构上我分成了前后端两个独立目录后端是标准的Maven单工程前端是Vue CLI初始化的单页应用。后端的包结构按controller / service / mapper / entity / common分层而不是按功能分。这样做的好处是新手拿到代码后能很快顺着“请求进controller→service做判断→mapper查库”这条线读下去。前端则按views/router/api三块组织所有的axios请求统一放在api目录下页面组件只负责渲染和交互。3. 数据库设计预约系统的核心是“时间片”和“状态”如果说这套系统有一个最值得反复讲的点那一定是数据库设计。预约系统的业务逻辑并不复杂复杂的是数据层要同时支撑“时间片不冲突”和“状态可追溯”这两个硬要求。我花在这部分的时间几乎占整个项目的一半。3.1 六张核心表的字段与关系我把整个系统的表收敛到了六张核心表没有过度拆分也没有大宽表。表名作用关键字段sys_user统一用户表学员/教练/管理员共用id, username, password, real_name, role_type, phonecoach教练信息扩展表id, user_id, car_no, teach_type, service_yearscourse课程/科目信息表id, course_name, teach_type, duration_minutesday_plan教练每日可约时段表id, coach_id, plan_date, start_time, end_time, total_slots, booked_slots, statusappointment预约记录表整个系统的核心id, student_id, coach_id, plan_id, course_id, appoint_date, time_slot, statusfeedback学员对教练的评价表id, appointment_id, student_id, coach_id, score, content这里想重点解释两个设计选择。第一个是user表为什么要用role_type区分角色而不是建三张用户表。驾校的学员、教练、管理员虽然有各自扩展信息但登录鉴权、密码、手机号这些基础属性完全一样。用一张表加角色字段配合SpringBoot的拦截器做权限判断整个登录流程只要写一套简单得多。教练的扩展信息车牌、教龄、准教车型单独放coach表通过user_id关联这也符合“基础信息与扩展信息分离”的原则。第二个是day_plan和appointment两张表的设计。我一开始也纠结过要不要把“时段”直接存在appointment表里后来发现不行。因为教练需要先声明“我哪天哪些时间段可以带学员”学员再来这些时间段里占坑。如果时段信息只存在于预约记录中教练就没法设置可约时间。所以day_plan负责定义“可预约的坑”appointment负责记录“哪个坑被谁占了”。3.2 防止重复预约的两层数据库约束预约系统的数据库设计里最关键的不是表多不多而是约束够不够硬。我用了两层约束来保证预约数据不出错。第一层是唯一索引。以“同一个可约时段同一位学员只能有一条预约记录”为例我在appointment表上加了一条联合唯一索引CREATE TABLE appointment ( ... UNIQUE KEY uk_plan_student (plan_id, student_id), KEY idx_coach_date (coach_id, appoint_date) );这条索引解决的是“同一个人对同一个时段重复提交”的问题。没有它学员连点两次预约按钮就可能在同一时段产生两条记录。有了它第二次insert会直接报DuplicateKeyException由后端捕获后翻译成“您已预约该时段”。第二层是容量控制这才是解决“不同学员抢同一个时段”的关键。day_plan表里有两个字段total_slots表示这个时段总共能收几个人默认1即一对一教学booked_slots表示当前已约人数。容量判断不能靠先select再update因为并发请求下两个事务可能同时读到booked_slots0然后都通过校验都执行插入。正确做法是让数据库在一条UPDATE语句里原子地完成“检查未满人数加一”这个细节我会在下一章节展开。这两层约束缺一不可唯一索引管住重复人原子更新管住超卖量。3.3 状态字段的枚举设计与流转规则预约系统的第二个核心是状态。appointment表里的status字段我用的是int值配合代码里的枚举类而不是直接存中文也不是用String存英文。status值含义触发动作0待确认学员提交预约等待教练确认1已确认教练确认接受学员的时段被正式锁定2已完成学员完成该次练车管理员归档3已取消学员在练车前取消或教练拒绝后系统自动取消4爽约学员未在约定时间到达教练标记为爽约用int存状态的好处一是数据库存储空间小、索引效率高二是代码里写枚举后状态含义非常清晰不会出现字符串大小写不一致导致匹配失败的问题。我在代码中用一个AppointmentStatusEnum枚举类统一管理这些常量所有业务代码都引用枚举而不是裸的数字这样后续要加状态只需要改一个文件。4. 后端实现真正难的是并发预约和状态流转数据库设计好之后后端开发的难度其实就集中在三块预约接口的并发安全、状态流转的业务规则、权限控制。我逐个说实现思路和踩坑细节。4.1 预约接口的完整逻辑与防超卖预约接口是整套系统的核心。前端传来学员id、可约时段plan_id后端Service先做基础校验——这个时段是否存在、是否还处于可预约状态校验通过后执行一条原子化的占坑SQL这是防超卖的关键update iddecreaseFreeSlots UPDATE day_plan SET booked_slots booked_slots 1 WHERE id #{planId} AND booked_slots lt; total_slots /update这条UPDATE的巧妙之处在于它把“检查余量”和“占用名额”合并成了一个原子操作。InnoDB引擎在执行这条语句时会锁住day_plan这一行先判断booked_slots是否小于total_slots条件成立才更新。哪怕50个请求同时打到同一个plan_id也会被行锁自然排队最终只有一个请求能更新成功其余请求affected为0直接返回“该时段名额已满”。占坑成功之后再插入预约记录Transactional(rollbackFor Exception.class) public AppointmentResult createAppointment(AppointmentReq req) { DayPlan plan dayPlanMapper.selectById(req.getPlanId()); if (plan null || plan.getStatus() ! 1) { return AppointmentResult.fail(该时段已不可预约); } int affected dayPlanMapper.decreaseFreeSlots(plan.getId()); if (affected 0) { return AppointmentResult.fail(该时段名额已满); } Appointment app new Appointment(); app.setStudentId(req.getStudentId()); app.setCoachId(plan.getCoachId()); app.setPlanId(plan.getId()); app.setAppointDate(plan.getPlanDate()); app.setTimeSlot(plan.getStartTime() - plan.getEndTime()); app.setStatus(AppointmentStatusEnum.WAIT_CONFIRM.getValue()); try { appointmentMapper.insert(app); } catch (DuplicateKeyException e) { throw new RuntimeException(您已预约该时段请勿重复提交); } return AppointmentResult.success(); }这里有两个细节值得说一下。一是接口只收plan_id不收time_slot。因为time_slot本身是day_plan表里的数据前端如果能传time_slot就意味着学员可以绕过教练的排班直接指定任意时段这是典型的越权输入。后端拿到plan_id后自己查出时段这种设计在评审时会非常加分。二是插入预约记录时如果触发了唯一索引异常整个事务会回滚占坑时加上的booked_slots也会被一并还原不会出现“名额扣了但预约记录没插入”的账不平问题。Transactional在这里保证了两个操作要么都成功要么都失败。4.2 教练确认与学员取消的状态流转实现预约创建后状态是“待确认”接下来分两条线走教练确认线学员取消线。教练端进入“待确认列表”看到学员的预约申请可以确认或拒绝。确认操作就是把status从0改成1同时等待管理员后续按实际情况核销。拒绝操作则把status改成3并给学员端返回“该时段已被释放”。这里有个细节教练确认时也要加一个状态判断只有status0的记录才能被确认否则两个人同时操作一条记录可能把已取消的预约又改回已确认产生脏数据。学员端取消预约我加了一条业务规则只能在练车开始前24小时取消超过24小时系统禁止取消只能联系管理员处理。这条规则在代码里就是一次时间比较if (appointment.getAppointTime().isBefore(LocalDateTime.now().plusHours(24))) { return AppointmentResult.fail(距练车时间不足24小时如需取消请联系管理员); }学员取消成功后status改成3还不够还必须把day_plan里的booked_slots减回来同时释放对应的名额。这个“释放”动作同样要放进同一个事务里否则会出现预约取消了名额还被占着的问题。4.3 基于JWT的登录鉴权与角色权限登录鉴权我用的方案是JWT加自定义拦截器没有引入Spring Security。原因很简单这套系统只有三种角色权限控制集中在“哪些接口哪些角色能访问”拦截器加自定义注解完全够用引入Security反而要配一堆过滤器链和配置类。流程是这样的登录接口校验用户名密码后生成一个JWT把用户id和角色类型放进token里返回前端前端每次请求在Header里带上token后端定义一个LoginInterceptor在preHandle方法里解析token把用户信息放进ThreadLocal再配合一个RequireRole注解打在Controller方法上拦截器里判断当前用户角色是否匹配。实际用下来这套方案代码量在两百行左右很好维护。唯一要注意的是JWT的过期时间我设置的是8小时并且在前端每次请求成功后都会刷新一次保证学员练车期间不会突然掉线。4.4 MyBatis联表查询的SQL细节MyBatis在这个项目里的核心价值体现在联表查询的SQL写法上。比如学员端“我的预约”列表需要同时展示教练姓名、车牌号、课程名和当前状态。我直接在Mapper XML里写了一条联查SQL而不是在Service层循环调用单表查询。select idselectMyAppointments resultTypemap SELECT a.id, a.appoint_date, a.time_slot, a.status, c.real_name AS coach_name, c2.car_no, co.course_name FROM appointment a LEFT JOIN coach c2 ON a.coach_id c2.user_id LEFT JOIN sys_user c ON c.id c2.user_id LEFT JOIN course co ON a.course_id co.id WHERE a.student_id #{studentId} ORDER BY a.appoint_date DESC, a.time_slot ASC /select联查表这里有一个容易踩的坑coach表里存的是教练扩展信息而教练的姓名在sys_user表里所以要先join coach再join sys_user拿到real_name。如果一开始设计时直接把name字段冗余到coach表虽然能少一次join但用户改姓名时就得同步两张表反而麻烦。我选择不冗余保持数据单一来源。5. Vue前端预约页面不是简单提交一个表单前端部分预约页面的体验直接决定了这套系统给人的第一印象。很多人以为前端就是套一套UI框架把接口数据铺到表格里其实预约页面的难点在于时段矩阵的呈现、操作后的状态即时更新、以及未登录状态下的路由拦截。5.1 教练-时段矩阵的界面交互最核心的预约页面我没有用Element UI现成的日历组件而是自己用el-table拼了一个“教练×时段”矩阵。行是教练列是上午、下午拆分出的固定时段单元格里显示“可约/已满/待确认”可约的单元格可以被点击发起预约。选择这个方案的理由是驾校的预约粒度普遍是“半天”或“1.5小时”这种固定时段不是按具体小时随意选的用日历组件反而多余。矩阵表格的信息密度高教练和时段的对应关系一眼可见。点击可约单元格后弹出一个确认框里面有课程类型、教练信息和时段范围学员确认后调接口成功后当前单元格立即变成“待确认”前端不需要整页刷新。这里有一个交互细节我反复调过同一个时段如果已经被当前登录学员预约单元格不能只显示“可约”否则学员会重复提交然后收到“该时段已被预约”的错误提示。所以后端返回时段列表时会额外带一个myBooked字段前端据此把已预约的单元格做置灰处理交互干净很多。5.2 路由守卫与axios拦截器前端登录鉴权分两层。第一层是vue-router的路由守卫在beforeEach钩子里检查本地存储里有没有token没有token的统一重定向到登录页。第二层是axios的请求拦截器和响应拦截器。axios.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers[Authorization] Bearer token; } return config; }); axios.interceptors.response.use( response response.data, error { if (error.response error.response.status 401) { localStorage.removeItem(token); router.push(/login); } return Promise.reject(error); } );拦截器写好之后所有页面的请求代码都不用重复处理token和登录失效的逻辑后端返回401时自动踢回登录页。这一层实现很简单但很多新手容易漏掉请求拦截器导致登录页写完了其他接口全部401其实就是没把token加到请求头里。5.3 预约操作前后的状态联动预约系统的前端还有一个“状态联动”的难点。同一块数据在学员端、教练端、管理员端出现时的含义不一样。比如一条预约记录学员端显示的是“我的预约”教练端显示的是“待确认申请”管理员端显示的是“全部记录”。我的做法是前端所有预约相关的操作成功之后不是直接改本地data而是回调重新拉取当前页面对应的列表接口。刚开始有人会觉得这样多了一次请求浪费性能但实际数据量就几百条重拉一次几乎无感却能彻底避免“本地改了状态但列表没刷新”这种一致性bug。我在项目中封装了一个refreshData方法所有增删改操作成功后统一调用这个经验在联调阶段帮我省了不少事。6. 从0到1跑起来初始化、联调和踩坑记录最后这部分写给打算把项目跑起来实际使用或者二次开发的读者。环境、初始化、联调过程中我踩过的坑很多都是查了半天文档才解决的记录下来帮大家省点时间。6.1 开发环境版本与数据库初始化脚本先明确环境版本不同版本组合会出现一些莫名其妙的问题。我实际稳定的组合是JDK 1.8、Maven 3.6.3、MySQL 8.0.x、Node 14.x、Vue CLI 4.x。后端用IDEA打开前端用VS Code打开。数据库初始化我准备了一个init.sql脚本里面包含了建库、建表、插入基础数据的全部语句。这里有一个我很在意的点初始数据的密码不是明文而是先用MD5加密后的值插入保证登录流程跟真实环境一致。初始化完成后默认有三个账号分别对应三种角色方便你登录体验不同端的功能。后端配置文件application.yml里有两点必须按你本机环境改数据库用户名密码以及时区配置。时区这地方我第一次没写结果所有时间字段在JSON序列化出来后都比正常时间少了8小时排查了半天发现是MySQL连接串缺了serverTimezone参数。6.2 我用JMeter复现了并发重复预约在开发过程中我一直担心并发问题只是理论上存在实际会不会触发。为了验证原子化占坑的方案是否真的可靠我用JMeter做了一个简单的并发测试一个教练的一个可约时段total_slots默认1同时发起50个学员的预约请求。第一次测试时故意把占坑逻辑换回“先select余量再update加一”的写法。结果50个请求里有7个都返回了“预约成功”同一个时段的booked_slots被加到了7数据库里也确实插了7条记录——超卖问题真实复现了。原因就是那7个请求几乎同时读到了booked_slots0全部通过了校验再各自执行加一数据库里人数自然对不上。改成原子化条件更新之后再跑同样的测试50个请求只有1个成功其余全部提示“该时段名额已满”。这个实验让我彻底理解了“业务校验只是前置数据库原子操作才是底线”这句话。如果你要拿这套系统去答辩强烈建议把测试过程记录下来这是很好的加分素材。6.3 时区、跨域、驼峰映射三个经典坑联调阶段我先后踩过三个经典坑每个都值得单独说一下。第一个是时区问题。MySQL 8默认的时区跟本地不一致连接串里必须加serverTimezoneAsia/Shanghai否则LocalDateTime类型的字段在前后端之间传输时会莫名偏移8小时。第二个是跨域问题。前端跑在8080端口后端跑在8081端口前端直接发请求会被浏览器拦截。解决方式是在后端加一个全局CORS配置类允许指定地址跨域访问。注意这里不要用allowCredentials配合allowedOrigins(*)同时设置部分浏览器会直接报错要么指定具体来源要么用allowedOriginPatterns。第三个是MyBatis的驼峰映射。数据库字段我习惯用下划线命名比如appoint_date、real_name而Java实体属性是驼峰命名appointDate、realName。如果不在application.yml里配置map-underscore-to-camel-case: true查询结果里这些字段会全部为null而且不会报错非常隐蔽。坑表现解决方式时区偏移时间字段少8小时连接串加serverTimezoneAsia/Shanghai跨域拦截浏览器请求直接失败后端全局CORS配置避免通配符与withCredentials冲突驼峰不映射实体字段全部为null但不报错MyBatis开启map-underscore-to-camel-case这三个坑单独看都不难难在它们都不会报明显的错误只能靠排查经验或者日志逐层定位。我把它们记下来项目跑起来后请第一时间在配置文件里确认。6.4 拿到源码后怎么改成自己的项目最后聊一下二次开发。如果你从网上下载到类似的源码第一件事不是急着改功能而是先把自己的环境跑通确认前后端能正常联调。跑通之后再按照“加字段→改实体→改Mapper→改Service→改前端页面”的顺序去做修改。以“给预约记录加一个备注字段”为例完整的改动链条是在appointment表加remark列、在Appointment实体加remark属性、在MyBatis的XML里insert和select语句里加上remark、在Controller的请求参数类里加字段、在前端预约弹窗里加一个输入框并在提交时带上。这套项目好改就是因为各层分得清晰每一层都只做自己该做的事。千万别一上来就大改表结构否则很快会陷入改一处坏三处的循环。关于Vue 3迁移的想法我也提一句如果想把前端升级到Vue 3重点是先把Element UI换成Element Plus再逐个检查组件的属性变化比如size和插槽的用法差异。业务逻辑本身几乎不用动因为axios封装和路由守卫在Vue 3里写法基本一样。这套系统做完之后我自己最大的体会是预约类系统的难点从来不在框架而在怎么用数据库约束和状态设计把业务规则焊死。当你看到50个并发请求里只有一个能成功预约其余全部被挡在原子化占坑之外的时候对“后端是系统工程”这句话的理解会深入很多。如果你正在做类似的课设或者想通过一个完整项目把SpringBoot、MyBatis、Vue这条技术栈串起来把这个预约闭环吃透比做一百个登录注册demo都有用。