最近很多朋友拿着这套基于SpringBoot+Vue的驾校管理系统源码,卡在部署和二次开发上。有的是刚学Java没多久的在校生,数据库脚本导进去了页面却白屏;有的则是想接私活的小团队,功能跑通了但是不知道怎么改预约逻辑。我前后把这套系统从零搭起来又拆开看了几遍,把业务逻辑、表设计、前后端联调的细节都理了一遍。这篇文章不吹不黑,就从实际开发的角度把整个系统讲透,包括为什么这样设计、每张表为什么长这样、核心业务代码怎么写,以及本地跑通这套源码必踩的几个坑。无论你是拿它做毕业设计、学习前后端分离项目,还是想给驾校做一套信息管理系统,照着往下走基本不会有大问题。
1. 驾校管理系统到底要管什么:业务痛点与功能模块拆解
1.1 驾校日常业务的三个核心痛点
驾校和一般的培训机构不一样,它同时管着人、车、场地、考试四类资源。我调研过好几个驾校的实际运作流程,发现日常经营绕不开三个麻烦事。
第一个是约车排班乱。一个教练一天带十几个学员,每个学员练车时间不同,场地车辆就那么多,没有系统全靠微信和口头约定,教练和学员之间经常因为时间对不上闹矛盾。第二个是学时统计难。现在很多地区考驾照要求学时达标才能约考,学员练了多久、还剩多少学时,靠纸质签到表格记录,月底一核对全是漏洞。第三个是报名考试流程重。学员从报名到科目一、科目二、科目三、科目四,中间涉及资料审核、考试预约、成绩登记,一个人负责几十个学员就直接忙不过来。
1.2 这套系统的功能模块地图
这套驾校管理系统把上述问题拆成了六个核心模块:学员管理、教练管理、车辆管理、预约练车、考试管理、系统管理。学员管理负责录入学员档案、查看学时进度和考试状态;教练管理维护教练信息、所带学员列表以及教练的排班表;车辆管理则记录车辆的型号、车牌、当前状态(空闲/使用中/维修)以及绑定教练。预约练车是系统的核心模块,学员在线选择教练、日期、时间段,后端做冲突校验;考试管理实现考试报名、成绩录入和审核流程;系统管理包含用户登录、角色权限分配、菜单配置,是前后端分离项目里比较标准的那套RBAC设计。
从业务角度讲,这套系统覆盖了驾校日常经营80%以上的场景,CTO或驾校老板要看的统计报表、教练要用的排课视图,都属于可以二次扩展的部分。
1.3 适合谁拿来学习和二次开发
如果你是学生,这项目覆盖了SSM/Vue前后端分离最典型的技术栈,能直接当毕业设计的素材;如果你是想快速接单的开发者,这套系统的基础模块可以直接复用到健身房管理、体育馆预约、美容院管理这类“人+资源+时间预约”模式的项目上,改改表结构就能交付。所以别把这套系统只当“驾校专用”,它的价值更在于把增删改查、权限控制、预约冲突、文件上传等通用能力串在了一起。
2. 技术选型:为什么是SpringBoot+Vue+MyBatis+MySQL
2.1 后端框架:Spring Boot几乎是Java Web现在的默认答案
这套源码后端基于Spring Boot,原因很简单——Spring Boot把Spring MVC、事务管理、连接池、日志等繁杂配置都做了自动化,启动一个Web服务只需要一个启动类加几个注解。对比传统的Spring XML配置,Spring Boot省去了大量bean定义和依赖注入的显式声明,这对学生上手和后期维护都友好得多。
骨架层面,Spring Boot项目通常这样组织:Controller层接收请求并做参数校验,Service层承载业务逻辑和事务管理,Mapper(DAO)层通过MyBatis操作数据库,entity(或者叫pojo、domain)层映射数据库表。这套分层方式看似传统,但对于驾校管理系统这类以CRUD为主的业务系统,反而是最稳定、最好扩展的。
2.2 前端框架:Vue 3 + Element Plus的组合
近两年前端圈最大的变化就是Vue 3成为主流,组合式API(Composition API)配合Vite构建工具,开发体验比Vue 2的Options API加Webpack轻快不少。驾校管理系统的后台界面需要大量表单、表格、弹窗、日期选择器,直接用Element Plus组件库能省掉一大半UI工作。用它做出来的界面效果也比较像正经的管理系统,不会让人觉得是学生作业。
如果你拿到的源码是Vue 2版本的,也别急着升级到Vue 3,先跑通再说。Vue 2 + Element UI在组件生态上仍然稳定,很多老项目都在生产环境跑得好好的。核心是吃透生命周期、组件通信、路由和状态管理这四个东西。
2.3 ORM与数据库:MyBatis的XML自由度
数据库访问层为什么选MyBatis而不是Spring Data JPA?对于驾校系统这种查询条件复杂、表和表之间关联多的场景,MyBatis的XML文件可以把SQL写得很直白,比如学员多条件查询(姓名模糊匹配、状态筛选、报名时间范围)、预约记录和学员表教练表的三表关联,直接在XML里手写SQL,可控性最强。MyBatis的动态SQL标签(if、where、foreach、set)也极大减少了拼接SQL的繁琐代码。
2.4 版本搭配最容易翻车的几个组合
这里我要特别提醒版本问题。如果后端用的Spring Boot 3.x,必须配JDK 17及以上,MyBatis对应要用mybatis-spring-boot-starter 3.0以上版本,且javax.servlet包名的依赖要换成jakarta.servlet。如果后端用的Spring Boot 2.7.x,JDK 8或11都能跑,兼容性更好。前端Node.js版本建议16以上,Vite 4以上要求Node 14.18+,太老的Node版本启动时会直接报错。MySQL这边,8.0及以上版本是主流,驱动名是com.mysql.cj.jdbc.Driver,和5.x时代的com.mysql.jdbc.Driver不同,这也是老代码跑不起来的高频原因之一。
下面这个表格总结了我推荐的版本组合,照着配基本不吃亏:
| 组件 | 保守方案 | 新特性方案 |
|---|---|---|
| JDK | 8 或 11 | 17 |
| Spring Boot | 2.7.x | 3.2.x |
| MyBatis Starter | mybatis-spring-boot-starter 2.3.x | 3.0.3+ |
| Vue | Vue 2.7 + Element UI | Vue 3.4 + Element Plus |
| Node.js | 14 LTS | 18 LTS 或 20 LTS |
| MySQL | 5.7 | 8.0+ |
3. 数据库设计:驾驶学校管理系统核心表是怎么建模的
3.1 用户权限表:user + role + menu 三件套
这类管理系统99%都会做用户权限,驾校系统也不例外。user表存登录账号、密码(BCrypt加密后的密文)、姓名、手机号、用户类型(管理员/教练/学员)、所属教练ID(如果是学员则存关联的教练)等基础信息。role表存角色名称和角色编码,menu表存菜单树,功能菜单可能是两级或三级结构,用parent_id字段做自关联。user_role和role_menu是两张关联表,把用户和角色、角色和菜单分别建立多对多关系。
登录流程图不用多想,就是经典的“登录成功 -> 查询用户角色 -> 查询角色菜单 -> 返回菜单树和按钮权限给前端”。这套设计直接决定了后面Vue动态路由的写法,所以建表时一定要把menu表里的组件路径、路由路径、按钮标识这几个字段都留好。
3.2 学员与教练:档案数据的字段设计
学员表(student)不是简单存一个姓名手机号就完了。实际业务里需要冗余很多字段,比如:身份证号(用于考试报名)、报名类型(C1/C2)、报名日期、当前科目进度(1/2/3/4)、累计学时、所属教练ID、状态(正常/停训/结业)。之所以把“当前科目”和“累计学时”字段直接冗余到学员表里,是因为这些字段会在首页统计、列表展示中高频查询,如果每次都用子查询去统计学时表,数据量大了之后性能会很难看。空间换时间,在业务系统里是合理选择。
教练表(coach)除了姓名、手机号、驾龄、准教车型这些基础资料,还需要一个排班状态字段,标记今天是否休息、当前是否有课。车辆表(vehicle)则包含车牌号、车辆型号、车辆类型(手动/自动)、当前状态(空闲/使用中/维修保养)和归属教练ID。
3.3 车辆与预约:时间冲突怎么靠索引兜底
预约表(appointment)是业务核心,字段设计要有全局观。我见过很多新手把预约表设计成只有一个教练ID加一个日期字段,结果一辆车或一个教练在同一天能被约出去好几回。正确做法是:appointment表保存学员ID、教练ID、车辆ID、预约日期、开始时间、结束时间、状态(待练/已完成/已取消)、实际开始时间和实际结束时间。这样一个教练在一天内可以有多条时间不重叠的记录,系统通过“同一教练同一天的时间区间不能重叠”这个约束来保证排班正确性。
为高频查询建立联合索引非常关键。最常出现的SQL是“查某天某教练的已占用时间”,所以教练ID + 预约日期是一个必须的联合索引;另一个高频查询是“查某学员的预约记录”,那学员ID加预约日期也应该建索引。索引不是越多越好,但这两个查询是实打实每次打开页面都会执行的。
3.4 考试与学时:状态机字段的设计经验
考试表(exam)围绕科目一、科目二、科目三、科目四四个阶段设计,核心字段是学员ID、考试科目、考试日期、考试成绩(合格/不合格)、考试状态(待审核/已通过/已驳回)。考试报名流程里其实藏着一个审批状态机,从学员提交报名申请,到教练或管理员审核,再到考试结果录入,每一步的状态变化都是基于前一步的状态限制进行的。一旦不控制状态流转,比如说“成绩还没录入就可以预约下一次考试”,业务上就乱了。
学时表(study_record)记录学员每次练车的时长,每次预约完成之后,由教练端点击“开始/结束”,系统自动计算间隔分钟数并累加到学员表里的累计学时字段。这个设计在代码实现上要注意避免重复累加,通常是用记录表的状态位来控制,比如state从0变1时累加一次,保证幂等。
4. 后端核心业务逻辑:三层架构里最值钱的三段代码
4.1 预约练车:冲突校验的两种写法和取舍
预约功能是驾校系统里最容易写崩的地方。简单版本的逻辑是:前端提交教练ID、日期、开始时间、结束时间,后端先去查同一天同一个教练是否已有时间重叠的记录,没有重叠就插入,有就返回提示。但这里有个并发问题——如果两个学员同时提交预约,后端的“查询-判断-插入”这三步之间如果被并发线程插队,就可能查出相同的结果然后都插入成功,产生脏数据。
解决并发下的冲突重复,最稳妥的方式不是加分布式锁,而是在数据库层面做一个唯一约束,或者用悲观锁。由于驾校系统并发量本身不大,我会推荐“先查再插”配合数据库唯一索引兜底。创建一个唯一索引(coach_id, appoint_date, start_time, end_time)是做不到的,因为区间不是精确相等的,所以更实际的做法是使用MySQL的排他锁:在事务中先SELECT ... FOR UPDATE锁定该教练当天的记录范围,再执行插入,就能保证同一时间只有一个事务在做校验。这段核心代码放Service层,至少能用来讲明白“数据库事务+锁”这个知识点。
4.2 学时统计:一次练车到底算半个小时还是四十五分钟
学时统计的坑不在SQL,而在业务口径。有的驾校规定一次预约时长固定45分钟,不管实际来不来都算45分钟;有的驾校则要求以教练点击“开始练车”到“结束练车”的实际间隔为准。这套源码采用的是第二种方式:教练端每次练车前点击开始,结束后点击结束,后端拿到两个时间点后计算分钟数,向上取整到分钟,再累加到学员学时的总数字段。
代码实现上要注意一个很隐蔽的坑:计算学时只能用实际开始/结束时间,而不能用预约的开始/结束时间,否则就会出现学员没来练车也算学时的问题。另外,开始和结束必须通过同一个接口事务来更新,如果分成两个独立接口,教练可能忘记点结束,导致一条“超长学时”记录。所以较完善的设计是在教练首页做个提示,或者做一个定时任务,把超过预约结束时间2小时还没结束的记录自动结束。
4.3 考试报名:审核流程的权限控制
考试报名这一块,关键在于把“谁能审核、谁能录入成绩、谁能查看成绩”按角色分开。教练可以在自己学员列表里发起考试报名,管理员或总教练负责审核,录入成绩也是管理员的权限。这里的权限控制不仅要在前端隐藏按钮,后端也要通过注解或拦截器做权限校验,防止普通学员直接调用考试录入接口篡改成绩。
在Spring Boot里实现后端权限校验的方式有很多,最简单的做法是自定义HandlerInterceptor拦截器,在preHandle方法里判断当前登录用户的角色编码是否在允许访问的列表里,不在就返回401或403。用拦截器的好处是不依赖额外框架,适合MyBatis这套轻量级组合;当然你也可以用Spring Security + JWT做,但那种配置成本偏高,对于毕业设计和中小型项目有点用力过猛。
4.4 MyBatis XML里值得封装的几个通用写法
用久了MyBatis你会发现,驾校系统这种项目里面CRUD代码量最大,但真正有复用价值的写法就那么几个。
第一个是通用分页查询。用PageHelper分页插件,或者自己写LIMIT #{offset}, #{pageSize},配合动态SQL的where条件。我建议直接用PageHelper,架在MyBatis里非常轻,查询前调PageHelper.startPage(pageNum, pageSize),后面紧跟的查询自动带上分页,返回的PageInfo里就有total、pages等现成属性。
第二个是批量操作。考试录入成绩、批量修改学员状态这种场景,用foreach标签写batch update,效率和代码整洁度都远高于一条条循环。
第三个就是动态SQL的判断组合。多条件查询学员列表时,如果不确定前端到底传了哪几个条件,用 + 组合最舒服。这里要提醒一个关键细节: 标签里的test表达式,字符串判空必须写成test="name != null and name != ''",不能只写一个name != null,不然前端传一个空字符串过来,SQL会多一个name = '',查出来数据就不对。
下面贴一段典型的学员分页条件查询XML,感受一下实际写法:
<select id="selectStudentPage" resultType="com.example.entity.Student"> SELECT s.id, s.name, s.phone, s.reg_type, s.register_date, s.subject_progress, s.total_hours, c.name AS coach_name FROM student s LEFT JOIN coach c ON s.coach_id = c.id <where> <if test="name != null and name != ''"> AND s.name LIKE CONCAT('%', #{name}, '%') </if> <if test="regType != null and regType != ''"> AND s.reg_type = #{regType} </if> <if test="subjectProgress != null"> AND s.subject_progress = #{subjectProgress} </if> <if test="startDate != null"> AND s.register_date >= #{startDate} </if> <if test="endDate != null"> AND s.register_date <= #{endDate} </if> </where> ORDER BY s.register_date DESC </select>注意SQL里面大于号小于号要转义成>和<,这是新手很容易被XML报错折磨半天的原因。
5. Vue前端:动态路由、请求封装与约车日历的落地实现
5.1 登录拿Token:Axios拦截器要做什么
前端登录流程首先是用户输入账号密码,调后端接口拿到JWT Token,然后前端把Token存到localStorage或Pinia里。所有后续请求通过Axios统一加上请求头Authorization: Bearer ${token}。这一步比较正规的写法是在Axios实例上设置请求拦截器,而不是每个接口手动加token头,代码一多你就知道统一拦截器多省事了。
响应拦截器也要处理,核心是两个逻辑:当接口返回401时自动清除本地登录状态并跳回登录页;当接口返回业务错误码(比如预约时间冲突)时,弹出Element Plus的Message提示,不把错误堆到浏览器控制台就算完。
// axios 封装的响应拦截器示例 service.interceptors.response.use( (response) => { const res = response.data if (res.code !== 200) { ElMessage.error(res.msg || '系统异常') if (res.code === 401) { store.dispatch('logout') } return Promise.reject(new Error(res.msg)) } return res }, (error) => { ElMessage.error(error.message || '网络异常') return Promise.reject(error) } )5.2 后端返回菜单,前端动态注册路由
动态路由是这类管理系统的标配功能。后端根据登录用户的角色查询出该角色拥有的菜单列表,返回一个树形结构,前端在登录成功后使用Vue Router的addRoute方法把这些页面动态注册进路由表。这样做的核心原因是:不同角色的登录用户看到的左侧菜单完全不一样,管理员看到系统管理菜单,教练看到排班和学员管理,学员只看到预约和我的学时,功能不会越权暴露。
静态路由只保留登录页、404页和首页框架三种,其余页面全部走动态注册。写动态路由时要特别注意一个问题:addRoute的路由记录不能重复添加,否则控制台会报“Duplicate route”警告。解决办法是在每次addRoute之前先调用router.removeRoute来清理旧路由,或者在登录成功时记录已添加的routeName列表。
后端返回菜单的时候,菜单表里存的component字段值是类似“student/StudentList”的字符串,前端需要使用import.meta.glob或Vite的glob语法把视图组件批量引入,再通过映射关系找到对应组件。这一步是动态路由最不好理解的地方,很多新手卡在这里,把component字段写成全路径字符串然后各种加载不出来。正确写法类似下面这样:
const modules = import.meta.glob('../views/**/*.vue') function mapMenuToRoutes(menus) { const routes = [] menus.forEach(menu => { const component = modules[`../views/${menu.component}.vue`] if (component) { routes.push({ path: menu.path, name: menu.name, component, meta: { title: menu.title, icon: menu.icon } }) } }) return routes }5.3 约车日历:Element Plus Calendar组件的二次封装
预约练车页面是学员端最核心的页面,交互形式适合做成日历样式——学员可以看到某位教练本月每一天还有哪些时间段可约,点击选中某个时间段提交预约。Element Plus自带的Calendar组件能展示日期和单元格内容,但还不能直接选时段,所以一般在组件内部通过date-cell的插槽自定义内容。
实现思路是:月视图切换时把当前月份发送给后端,后端查询该教练当月在预约表里的所有记录,返回给前端按日期分组。前端在渲染每个日期单元格时,把该日期的已约时间段展示成小标签,并标记剩余可约时间段。为了减少请求次数,可以只按月份请求一次接口,前端本地做缓存,切换月份的时候再发一次请求。这样用户体验比较顺滑,后端也相对轻松。
5.4 按钮权限:前端指令让“隐藏”更彻底
页面级权限用动态路由控制之后,按钮级权限也要跟上。比如学员页面里的“添加学员”按钮,只允许管理员看到,教练账号登录后就不应该渲染这个按钮。实现方式有两种:一种是在按钮外面套v-if判断当前用户角色;更优雅的做法是自定义一个v-permission指令,让按钮元素绑定时传入所需权限码,指令内部判断当前用户是否拥有该权限码,没有就直接把元素移除。
// 自定义权限指令 app.directive('permission', { mounted(el, binding) { const requiredPerm = binding.value const userPerms = store.state.user.permissions if (requiredPerm && !userPerms.includes(requiredPerm)) { el.parentNode?.removeChild(el) } } })这里要说个常识:前端隐藏按钮只是体验优化,不能作为安全手段。关键的接口权限一样要在后端拦截器里控制,前端仅仅防君子不防小人。
6. 从零跑通这套源码:本地部署的完整操作清单
6.1 先说环境,后装工具
拿到源码以后不要急着盲目跑,先把环境核对一遍。我列一个最小可用清单:JDK 8或17分别对应SpringBoot 2.x或3.x;Maven 3.6以上;Node.js 16+;MySQL 8.0;一个趁手的IDE,后端推荐IntelliJ IDEA,前端可以用IDEA或者VS Code。
有一个经常被忽略的步骤是Maven仓库的镜像源配置。国内直接访问Maven中央仓库经常超时导致依赖下载失败,需要在maven的settings.xml里配置阿里云镜像。如果这一步不做,项目启动大概率会卡在“Downloading...”然后报一堆红叉。
6.2 导入数据库脚本
数据库的初始化脚本一般放在源码的doc/sql目录下,文件名可能叫driving_school.sql或init.sql。打开MySQL命令行或Navicat,先执行建库语句,再执行建表和初始化数据语句。需要注意的是,如果脚本里是有外键约束的,导入顺序不能错,先建主表再建关联表。另外,MySQL 8.0默认字符集是utf8mb4,建库时建议显式指定:
CREATE DATABASE IF NOT EXISTS driving_school DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci;导入成功后,重点检查一下menu表里有没有数据、admin账号有没有初始密码。很多源码默认密码是admin123,但用了BCrypt加密存储,所以你想登录之前必须先确保初始化脚本里已经插入了正确的加密密文,否则登录接口比对密文永远失败。
6.3 修改后端配置并启动
打开后端项目的application.yml,最需要改的是数据源配置:数据库URL、用户名、密码。URL里要带上serverTimezone=Asia/Shanghai和useSSL=false这两个参数,否则可能遇到时区报错或SSL连接问题。
spring: datasource: url: jdbc:mysql://localhost:3306/driving_school?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver启动Spring Boot主类之前,还要检查一下项目里的MyBatis配置:mapper-locations指向的路径是否正确,type-aliases-package是否配置。很多源码把mapper的XML文件放在resources/mapper目录下,如果application.yml里没指定,MyBatis启动时就会提示“Invalid bound statement (not found)”,这是高频错误。
如果后端端口是8080,直接启动后访问http://localhost:8080/doc.html(如果集成了swagger或knife4j)或者直接看控制台日志“Started Application in x seconds”。控制台没报错基本就成功了一大半。
6.4 启动前端Vite开发服务器
前端项目目录下执行npm install安装依赖。这一步也有个坑:如果直接npm install很慢或报错,建议使用npm配置镜像源,或者直接用pnpm。装完依赖后执行npm run dev,Vite会启动一个开发服务器,默认端口通常是5173。
前端项目里和后端联调的配置在vite.config.js文件的proxy里,生产环境请求通过代理转发到后端8080端口,避免跨域。开发环境下页面访问的接口路径是/api开头,转发规则大概长这样:
server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }这一步配置如果缺失或者target端口写错,前端页面虽然能打开,但所有列表都是空的,打开浏览器开发者工具一看,全是503或502代理错误。
6.5 验证整条链路是否跑通
前后端都起来之后,用管理员账号登录。进去以后先看首页统计,再点开学员管理,确认列表有数据,接着创建一个新学员,再用手动SQL查一下student表确认插入成功。如果能走通“登录-查询-新增-修改-删除”整个流程,核心链路就算通了。接下来再用教练账号登录,走一遍开始练车、结束练车、学时累加的流程,确认学时统计正常。最后用学员账号登录,尝试约车,再回到管理端看预约记录,确认冲突校验生效。
7. 本地部署和实际改动中踩过的真实坑
7.1 MySQL 8.0驱动与时区问题:排查链路还原
我第一次拿到类似的SpringBoot项目,启动时直接报错“The server time zone value is unrecognized”。当时我第一反应是代码问题,后来仔细把异常信息看完,才发现是JDBC连接串里没带时区。MySQL 8.0的驱动对时区要求比5.x严格,不带serverTimezone参数直接拒绝连接。解决方式是在URL后面加上serverTimezone=Asia/Shanghai。
紧接着又报了“SSL connection error”,这是因为MySQL 8.0默认开启了SSL验证,而本地开发通常不需要。解决方法是在URL参数里加useSSL=false。这类问题的核心排查思路是:不要看到红字就慌,先把堆栈信息从下往上找根因,所有连接被拒、通讯链路失败的报错,绝大多数集中在配置参数上而不是代码逻辑上。
7.2 MyBatis驼峰映射没开,查询结果全为null
学员列表打开以后,页面表格的表头全是英文,行的数据全是空的,但是接口返回的JSON里明明有数据,前端字段名也对得上,这问题十有八九出在MyBatis的驼峰映射配置上。数据库字段是register_date这种下划线风格,Java实体字段是registerDate驼峰风格,默认情况下MyBatis不会自动把下划线字段映射成驼峰属性,结果就是属性值全为null。检查application.yml里的map-underscore-to-camel-case是否为true,如果是false或者没配置,打开页面自然一片空白。
mybatis: configuration: map-underscore-to-camel-case: true这里建议把所有下划线风格的SQL字段都检查一遍,只靠这个配置就能解决90%的字段null问题。如果个别字段还是null,再去看实体类里是否缺getter/setter,或者是否在resultMap里手动映射了字段。
7.3 改了前端接口,半天不生效:Vite代理缓存问题
有次我在开发环境调整前端代理,把target端口从8081改成8080,刷新页面后发现请求还是打到8081,后端一直没收到请求。排查后发现是Vite的proxy配置在dev server启动时就构建好了,修改vite.config.js之后必须重启dev server才能生效,不是改了文件保存就完事。重启之后请求路径对了,问题立刻消失。这次经历给了一个教训:遇到“改动之后不变”的场景,优先怀疑缓存和重启问题,而不是去翻业务代码。
7.4 预约并发下出现脏数据:从业务代码到数据库锁
这个问题是在做压力测试时暴露的。我用两个账号同时抢某教练最后一个可约时间段,两个请求居然都提示预约成功,但数据库里只该有一条记录能够成立,结果却查到了两条。排查步骤分两步。第一,先看代码,发现Service层确实做了冲突校验,但“查询可用性”和“插入预约记录”之间没有加锁,并发请求之间互相读到的是旧数据。第二,我在事务里改成先SELECT ... FOR UPDATE锁住该教练当天的时间记录,再执行插入。锁加上之后重新压测,同一时段只有一个事务能通过校验,另一个事务会等待并在锁释放后刷新数据,发现冲突并返回提示。这个问题从本质上看不是MyBatis或Spring Boot的bug,而是典型的并发控制问题,任何做预约类系统都会碰到。
7.5 前端npm install老挂:镜像源和依赖版本的双重问题
很多同学在npm install这一步耗时半小时最后还报错,大部分原因是默认npm源访问太慢,以及某些依赖版本与Node版本不兼容。处理方式是先用npm config get registry查看当前源,如果不再是淘宝镜像就切换;同时查看源码里的package.json,确定Vite、Element Plus、Vue Router的版本要求是否和本地Node版本匹配。Node 18通常能兼容大部分前端项目,Node 20配Vite 5也没问题,最怕的是用Node 14去装Vite 5,直接就装不上。
8. 二次开发时值得优先做的几个优化方向
把系统跑通只是起点,如果真想拿这个项目撑起面试或真实业务,有几个优化方向非常值得做。第一,给学时统计加一个定时任务,每天凌晨自动汇总前一天所有练车记录,生成日报表数据放到统计表里,避免后续做报表时反复聚合大表。第二,把文件上传部分扩展一下,很多驾校需要学员上传身份证照片、体检表、驾校合同扫描件,这套系统如果只支持单机存储,后续可以把MinIO集成进来做对象存储,代码改动量不算大,但项目的完整度会提升一大截。第三,预约模块可以加一个“取消预约”的业务闭环,学员取消后释放教练时间,并给教练前端一个推送或醒目提示。这些优化点都是在实际业务里高频出现的,比单纯堆CRUD更有说服力。
驾校管理系统的技术栈本身不算前沿,但它把前后端分离、权限控制、预约业务、状态流转这些知识点结合得比较紧密,非常适合用来理解一个真实业务系统是怎么从设计到落地的。我也劝一句,遇到报错不要第一时间复制粘贴去搜索引擎,先学会看堆栈、分模块排查、定位到具体是哪一层出了问题,这个能力比多写几个页面重要得多。把这套系统从头到尾跑通并自己改两个功能之后,你会对SpringBoot和Vue的整体协作方式有一个比看任何教程都深刻的认知。