
做驾校管理系统这个项目是我目前觉得性价比最高的前后端分离练手选题之一。它不像电商系统那样业务面太宽也不像博客系统那样结构过于简单而是恰好踩在“业务复杂度适中、数据关系有深度、技术栈足够主流”的平衡点上。一套下来SpringBoot、Vue、MyBatis、MySQL这四样全都能实战一遍业务上还覆盖了学员管理、教练排班、预约练车、考试管理、财务统计这些有真实逻辑的场景用来做毕业设计、跳槽时的面试项目或者入职前拿来熟悉前后端协作流程都很合适。这篇文章不是泛泛讲概念而是把从建库到部署的过程完整梳理一遍。我会把重点放在三块一是数据库设计和预约练车的并发冲突处理这是面试官最喜欢追问的点二是后端SpringBoot加MyBatis的核心实现包括JWT鉴权、统一返回、多表联查三是前后端打包部署的完整方案。跟着这套思路走你复现出来的不是一个只能跑通页面的Demo而是能直接上服务器跑的项目。1. 项目起步技术栈为什么这样搭1.1 前后端分离到底解决了什么问题驾校管理系统如果做成传统JSP那种单体应用代码确实也能跑但维护起来非常难受。学员端要看课时进度教练端要维护可预约时段管理员端要做学员、车辆、考试、财务的综合管理三个角色的操作界面差别太大硬塞在一套模板引擎里前端文件会迅速膨胀。而且JSP页面和后端Java代码耦在一起改一个按钮样式都可能要重启服务开发效率很低。改成前后端分离之后前端只负责渲染和交互后端只暴露JSON接口数据和页面之间没有直接绑定。这样对驾校系统这种多角色、多状态的项目来说好处很直接权限模型可以完全放到后端控制前端只管根据角色显示不同的菜单和按钮接口可以单独用Postman联调前端开发不用等后端把页面模板写完部署的时候Vue构建出的dist目录既可以被Nginx托管也可以直接塞进SpringBoot的static目录弹性很大。从项目练手的角度讲前后端分离也逼着你把接口设计想清楚。每个接口该接收什么参数、返回什么结构、出错了怎么提示这些在分离项目里必须提前定义好而这个习惯恰恰是实际工作中最需要的。1.2 版本选择是个大坑说说我的组合技术栈写起来很简单真正动手才发现版本坑一堆。我这里直接给一套经过验证的组合JDK用1.8SpringBoot用2.7.x这是2.x系列最后的稳定版本。SpringBoot 3.x虽然已经出来很久但最低要求JDK 17很多学校机房和线上服务器还停留在JDK 8为了跑项目去折腾环境切换纯属浪费时间。MyBatis用3.5.x配合PageHelper做分页。可能有人会问为什么不用MyBatis-Plus确实Plus用起来更省事CRUD都不用自己写SQL但驾校系统里预约查询、考试统计这类复杂查询绕不开手写SQL而且面试时MyBatis的XML映射、动态SQL、参数绑定这些都是高频考点。把原生MyBatis玩明白再去用Plus基本就是降维打击。前端我建议Vue 2.7加Element UI这个组合组件文档全踩坑资料多网上能搜到的问题基本都有人趟过。如果你是从零学前端也可以直接上Vue 3加Element Plus原理上是一样的但跟着本文复现时保持2.7版本会少很多阻碍。Node.js版本用16左右太新的版本跑老项目偶尔会有依赖兼容问题。MySQL直接用8.0连接驱动用com.mysql.cj.jdbc.Driver建库时字符集统一指定utf8mb4。这里多说一句MySQL里utf8实际上最多支持3字节学员姓名或者考试备注里一旦出现emoji表情直接报错utf8mb4才是完整的4字节字符集建表时先把这个写对能省掉很多乱码问题。1.3 功能模块拆解业务边界先画清楚动代码之前我把整个系统的功能模块和对应技术落点先列成了一张表后面写代码、建表、设计接口都是按这个分组来推进的模块核心功能涉及的角色关键技术点系统管理登录、退出、用户维护、密码修改所有用户JWT鉴权、拦截器学员管理学员档案新增、修改、查询、状态变更管理员CRUD、分页查询教练管理教练信息维护、授课时段设置管理员、教练时段配置车辆管理车辆信息、车辆状态维护管理员状态字段设计预约练车学员约车、教练确认、取消预约学员、教练时段冲突校验考试管理考试安排、成绩录入、通过率统计管理员、教练多表联查、聚合统计财务管理报名缴费、补考费、退款记录管理员金额字段、流水记录这七个模块基本就是驾校管理的全部核心业务。如果后续项目想扩充可以加一个公告管理模块或者把课时统计细化到每个学员每次练车的消耗明细这些在现有表结构上扩展都不难。功能边界先想清楚写代码的时候就清楚自己要做到什么程度不会写着写着跑偏。2. 数据库设计先画好表格再写代码2.1 核心表结构用户、教练、学员、车辆怎么串起来数据库设计我走了两次弯路才定下最终方案。第一次把学员和教练的字段全部塞进一张用户表结果学员有学时、教练有教龄和准驾车型字段很乱。第二次拆开又没想清楚关联关系查询时join得头晕。最终采用的方式是一张sys_user表统一管登录账号和角色再用student和coach两张扩展表维护各自的业务字段通过user_id关联。核心建表语句我贴一下这几张表是整个系统的地基CREATE TABLE sys_user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, role_type VARCHAR(20) NOT NULL COMMENT ADMIN/COACH/STUDENT, status TINYINT DEFAULT 1 COMMENT 1启用 0禁用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;CREATE TABLE student ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL UNIQUE, name VARCHAR(50) NOT NULL, gender VARCHAR(10), phone VARCHAR(20), id_card VARCHAR(18), enrollment_date DATE, status VARCHAR(20) DEFAULT STUDYING COMMENT STUDYING/GRADUATED/TERMINATED, total_hours DECIMAL(6,1) DEFAULT 0 COMMENT 累计学时 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;coach表的结构和student类似不过业务字段换成了准驾车型、从业年限、每小时费用。vehicle表就是车牌号、车型、状态状态我用AVAILABLE、MAINTENANCE、RETIRED三个值维护而不是用布尔值因为车辆的状态不止可用和不可用两种。预约表booking是整套系统逻辑最重的表它同时关联学员、教练、车辆三个主体CREATE TABLE booking ( id BIGINT AUTO_INCREMENT PRIMARY KEY, student_id BIGINT NOT NULL, coach_id BIGINT NOT NULL, vehicle_id BIGINT, booking_date DATE NOT NULL, start_time TIME NOT NULL, end_time TIME NOT NULL, status VARCHAR(20) DEFAULT PENDING COMMENT PENDING待确认/CONFIRMED已确认/COMPLETED已完成/CANCELED已取消, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_coach_time (coach_id, booking_date, start_time, end_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;考试记录表exam_record和支付表payment相对独立只需要关联student_id。这样设计之后整个系统的数据关系就是用户表作为账号主体学员和教练表作为业务主体预约表作为中心枢纽把学员、教练、车辆串起来考试和支付围绕学员展开。逻辑清晰查询路径也短。2.2 预约练车的时段冲突靠什么兜底预约练车是驾校系统里最容易出并发问题的场景。想象一下同一时间有两个学员都在提交预约请求都想约3号教练明天上午10点如果只在前端做了个简单的时间校验后端没有兜底两个请求同时到达就会出现一个教练同时被两个人预约的情况。这在生产环境是绝对不允许的。解决思路我用了两层。第一层教练先维护每天的可用时段。教练在系统里把第二天拆好了时段比如上午9点到11点拆成9:00-10:00和10:00-11:00两档学员只能在教练开放的时段里选从源头限制了冲突的可能。第二层数据库层面加唯一索引(coach_id, booking_date, start_time, end_time)当两个请求试图插入完全一样的数据时数据库直接报唯一键冲突后到的请求就会失败这是最硬的兜底。但唯一索引只能拦截完全相同的时段如果业务上允许学员自定义任意起止时间那9:00-10:00和9:30-10:30这种区间重叠就没法靠索引拦截了。这种情况需要业务层做重叠校验核心SQL就是判断已有预约的时间段是否与新预约存在交集SELECT COUNT(*) FROM booking WHERE coach_id #{coachId} AND booking_date #{bookingDate} AND status IN (PENDING, CONFIRMED) AND start_time #{endTime} AND end_time #{startTime}这个SQL的逻辑是如果已有预约的开始时间早于新预约的结束时间同时已有预约的结束时间晚于新预约的开始时间那两者一定重叠。这个判断条件值得背下来凡是涉及会议室预订、教室排课、场地租赁这类业务都是这个套路。如果查询结果大于0就在Service层抛出业务异常提示该时段已被预约同时给前端返回一个相对友好的错误消息。2.3 考试与财务模块的状态设计考试模块的坑主要出在科目类型和结果状态的枚举设计上。科目一、科目二、科目三、科目四我直接用SUBJECT1到SUBJECT4四个字符串常量而不是用数字1到4这样查数据时一眼就能看出是哪科不用去翻注释。考试结果用PASS和FAIL两个值再加上一个SCHEDULED初始状态表示已安排未出成绩。财务模块要注意金额字段不能用FLOAT或者DOUBLE会有精度丢失。报名费、补考费、退款金额全部用DECIMAL(10,2)Java实体里对应BigDecimal。支付记录单独建一张payment表和报名、补考这些业务解耦每笔钱的流入流出都有记录这样管理员做财务报表时直接查表汇总就行。我在实际开发中遇到过用double存金额然后对账对不上的情况排查半天发现是精度的锅所以这块从一开始就该写对。3. 后端实现SpringBoot加MyBatis的关键代码3.1 后端项目结构是怎么组织的我见过很多同学写SpringBoot项目Controller里直接把业务逻辑全写了Service层空壳Mapper层只做简单的增删改查。这种写法项目小的时候还能跑一旦业务复杂起来就完全失控。我把项目结构按职责分成了四层每层只做自己该做的事com.drive ├── controller # 接收请求、参数校验、返回结果 ├── service # 业务逻辑、事务控制 ├── mapper # MyBatis接口 ├── entity # 数据库实体类 ├── vo # 前端展示对象避免把数据库字段全暴露出去 ├── dto # 接口接收参数封装 ├── config # 跨域、拦截器、MyBatis配置 ├── common # 统一返回结果、异常处理、工具类 └── security # JWT生成与解析包结构一旦定下来写代码的时候就知道某个类该放哪里不用到处找。vo和dto这两个包是很多人会忽略的特别是预约查询这种需要关联学员名、教练名的聚合查询不可能直接把实体返回给前端必须要有一个专门的查询结果对象来承接多表联查返回的数据。3.2 统一响应与全局异常处理前端需要统一的数据格式否则每个接口返回结构都不一样前端处理起来会很痛苦。我定义了一个Result类所有接口都返回这个结构public class ResultT { private Integer code; private String msg; private T data; public static T ResultT success(T data) { return new Result(200, 操作成功, data); } public static T ResultT error(Integer code, String msg) { return new Result(code, msg, null); } }但光有统一返回还不够还必须配合全局异常处理否则某个Service层抛了个空指针SpringBoot默认会返回一堆看不懂的错误信息前端拿到的也不是约定的JSON结构。我写了一个GlobalExceptionHandler用RestControllerAdvice做全局拦截业务异常统一返回500参数校验失败返回400未登录或token过期返回401。这样前端只需要在Axios响应拦截器里判断这几个状态码就能统一处理弹错和跳登录页的逻辑。3.3 JWT登录认证与权限拦截登录认证这块我选择了JWT而不是传统的Session方案。前后端分离项目如果还用Session会依赖Cookie和Session存储跨域部署时处理起来很麻烦。JWT的核心思路是用户登录成功后后端生成一个token字符串返回给前端前端每次请求都把token放在请求头里后端通过拦截器解析token就知道当前用户是谁、是什么角色。JWT工具类我封装了创建和解析两个方法public class JwtUtil { private static final String SECRET drive-system-secret-key; private static final long EXPIRE_TIME 7 * 24 * 3600 * 1000L; public static String createToken(Long userId, String role) { return Jwts.builder() .claim(userId, userId) .claim(role, role) .setExpiration(new Date(System.currentTimeMillis() EXPIRE_TIME)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Claims parseToken(String token) { return Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody(); } }拦截器这边我继承了HandlerInterceptor在preHandle方法里从请求头取Authorization字段解析token然后把用户信息放到ThreadLocal里方便后面的Service层直接获取当前用户。拦截器注册的时候要排除登录、注册这两个接口否则用户没登录就没法调登录接口了这是新手很容易踩的坑。权限控制上其实不需要引入一套完整的Spring Security框架驾校系统只有三种角色用拦截器加角色匹配就够了。我在自定义注解RequireRole上写清楚允许访问的角色在拦截器里取出token中的角色和注解要求匹配不匹配就返回401提示无权限。这样每个接口的权限需求写得很清楚运维起来也方便。3.4 MyBatis多表联查和动态SQL实战MyBatis的XML配置是面试爱问、项目里真正高频使用的地方。以预约记录分页查询为例前端需要展示学员姓名、教练姓名、车牌号、预约时间、状态这些数据分散在四张表里必须用JOIN查出来。对应的Mapper XML长这样select idselectBookingPage resultTypecom.drive.vo.BookingVO SELECT b.id, s.name AS studentName, c.name AS coachName, v.plate_number, b.booking_date, b.start_time, b.end_time, b.status FROM booking b LEFT JOIN student s ON b.student_id s.id LEFT JOIN coach c ON b.coach_id c.id LEFT JOIN vehicle v ON b.vehicle_id v.id where if teststatus ! null and status ! AND b.status #{status} /if if teststudentName ! null and studentName ! AND s.name LIKE CONCAT(%, #{studentName}, %) /if /where ORDER BY b.booking_date DESC, b.start_time DESC /select这里用where标签而不是直接写WHERE 11MyBatis会自动处理多余的前导AND代码看起来干净不少。if标签是动态SQL的核心有值时拼接条件没值时忽略条件。再加上PageHelper的分页插件查询前调用PageHelper.startPage(pageNum, pageSize)后面紧跟的这条查询SQL就会自动带上分页参数非常方便。还有一个细节是resultType我直接写VO类前提是SQL里的列别名和VO字段名一致。如果字段名对不上要么给列起别名要么配置mapUnderscoreToCamelCasetrue这个配置在application.yml里打开数据库的下划线命名就能自动映射成实体的驼峰属性整个人都舒服了。4. 前端实现Vue页面与接口对接4.1 前端工程结构与路由规划Vue这边我用Vue CLI创建的标准工程结构src下面分成api、router、views、components、store、utils几个目录。api目录里每个模块建一个文件比如student.js、booking.js把接口请求集中管理页面里不直接写URL这样后端接口一变只需要改一个文件不用全局替换。路由规划是这套前端的关键。三种角色登录后看到的菜单完全不同管理员能进学员管理和财务模块教练看到的是带教任务和时段设置学员只看得到约车和我的记录。我用动态路由的思路在路由表里给每个页面配置meta.roles字段登录后根据当前用户角色过滤出可见的路由再通过router.addRoutes动态加入。const routes [ { path: /login, component: Login }, { path: /, component: Layout, meta: { requiresAuth: true }, children: [ { path: students, component: StudentList, meta: { roles: [ADMIN] } }, { path: coaches, component: CoachList, meta: { roles: [ADMIN] } }, { path: booking, component: Booking, meta: { roles: [ADMIN, COACH, STUDENT] } }, { path: exams, component: ExamManage, meta: { roles: [ADMIN, COACH] } }, { path: my-records, component: MyRecords, meta: { roles: [STUDENT] } } ]} ]前端路由守卫里再拦一道判断用户没有登录就强制跳转登录页没有角色权限就提示无权访问。前后端各拦一次界面体验好后端数据也安全。4.2 Axios封装和登录状态统一处理前端每个页面都直接调axios.get的话重复代码会非常多。我把请求实例统一封装在utils/request.js里利用Axios的拦截器统一处理token注入和响应处理const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) config.headers[Authorization] token return config }) request.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.msg) if (res.code 401) { localStorage.removeItem(token) router.push(/login) } return Promise.reject(res) } return res.data }, error { ElMessage.error(网络异常请稍后重试) return Promise.reject(error) } )封装完之后组件里调接口只需要写request.get(/booking/list)返回的已经是对接过的数据不用每个页面都写一遍错误处理。登录状态的统一管理也在这个文件里完成了token失效自动跳登录页页面里甚至不需要关心token何时过期。4.3 预约练车页面日历视图和时段选择预约练车的前端页面是整个项目交互最复杂的部分我用一个日历组件展示当前月份的可用日期点了日期之后下方加载该日期教练开放的时段列表。每个时段组件会显示状态属性绿色表示可约灰色表示已经被约满或者过期。学员点击时段弹出确认框确认后调用创建预约的接口。这里有几个交互细节值得注意。时段列表在学员进入页面时就要加载不能等点击了日期再请求否则会有明显的加载空白期。提交预约时按钮要做防重复提交处理我用一个loading状态控制请求期间按钮不可点避免用户手快点了两次产生两条重复预约数据。成功提示之后要立刻刷新时段列表把刚刚约掉的时段标记为灰色避免用户再约一次。教练端对应的页面是时段管理教练选择日期后系统根据他配置的规则自动生成可用时段。比如设置“每天上午9点到11点每小时一档”前端动态生成时段数组教练可以删除某个不合适的时段。这些时段数据同步到预约页面的时段列表里整个约车业务的前后端流程才完整闭环。5. 打包部署与高频问题排查5.1 从本地开发到线上部署的完整构建流程本地开发时后端SpringBoot跑在8080端口Vue开发服务器默认跑在8081端口Vue的跨域问题通过vue.config.js里的代理解决module.exports { devServer: { port: 8081, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, pathRewrite: { ^/api: } } } } }这样前端请求/api/booking/list时开发服务器会代理到http://localhost:8080/booking/listCORS问题在本地开发阶段完全不需要后端解决。打包部署时流程分两步。先在后端项目根目录执行mvn clean package -DskipTests打包出一个可执行的jar包再在前端项目根目录执行npm run build生成dist静态目录。后端jar包传输到服务器后运行nohup java -jar drive-system.jar logs.log 21 即可。前端dist目录放到Nginx的html目录或者自定义路径下。如果暂时没有部署到服务器的条件也可以先把dist目录里的文件拷贝到SpringBoot项目的src/main/resources/static目录下重新打包这样Vue的页面和后端接口一起被打进jar包单进程就能跑起来适合本地演示用。但这种方案只适合小规模演示正式环境还是强烈建议前后端分开部署。5.2 Nginx配置前后端分离项目Nginx在前后端分离项目里承担了两层职责托管前端静态资源以及反向代理后端的/api接口。我把配置文件贴在下面直接可以用server { listen 80; server_name your-domain.com; # 前端静态资源 location / { root /opt/drive/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; } # 静态文件访问比如学员头像、考试照片 location /uploads/ { alias /opt/drive/uploads/; } }这里最关键的是try_files $uri $uri/ /index.html;这一行。因为前端用了Vue Router的history模式URL里没有#号用户浏览器直接访问/booking刷新时Nginx找了一圈发现没有这个文件如果不加回退规则就会返回404加了之后Nginx会把请求回退到index.html由Vue Router接管路由页面才能正常显示。5.3 部署阶段遇到的十个常见问题部署阶段的问题基本集中在环境配置和路径问题上我整理了几个典型的场景直接做成表格分享出来现象根因解决办法前端请求接口报401登录成功后token没有持久化刷新页面就丢了用localStorage保存tokenStore初始化时重新读取后端启动报时区错误数据库连接URL缺少serverTimezone参数URL追加serverTimezoneAsia/ShanghaiMySQL 8连接报SSL错误连接URL没有禁用SSL追加useSSLfalse和allowPublicKeyRetrievaltrue前端开发时接口一直404代理路径没配对确认请求前缀是/api且代理配置的target端口正确Vue刷新页面404Nginx缺少history模式回退规则加try_files $uri $uri/ /index.html;后端8080端口被占用本地调试残留进程执行netstat -ano查端口对应PID后杀掉或换端口启动上传的图片访问不了路径alias配置错误确认Nginx的location /uploads/和实际存储目录对齐JWT token一重启就失效Secret直接写死但系统时间对不上确认服务器时间正确或者用固定Secret保证重启后token可解析分页查询总数不对PageHelper依赖顺序问题确保PageHelper.startPage()在查询SQL之前执行学员列表搜索无效动态SQL的条件没走到检查if标签里字段名与参数名是否一致这些问题大部分我都在实际部署时踩过一遍。第一行的token问题是我自己犯过的一开始把token存在了Vue的Store里一刷新Store从内存中清空登录状态立刻丢失前端要重新登录才能用。换成localStorage之后刷新页面再从本地存储把token读回来就正常了。这项目后面的路还能怎么走整套系统跑通之后回头看这个项目的价值除了技术栈覆盖得够全面更重要的是把业务状态流转和并发处理都想了一遍。预约练车的唯一索引加业务校验这种双保险思路可以平移去处理会议室预订、工位预约之类的类似场景。多角色权限控制的实现方法换到任何一个后台管理系统上同样适用。如果后续想继续扩展可以往两个方向深入。文件上传这块目前学员头像和身份证照片还只能存在本地目录接入MinIO做对象存储是很好的下一步分布式部署时静态资源不会丢。缓存这块教练排班和热门时段的查询频率很高用Redis缓存能显著降低数据库压力缓存失效策略和穿透保护也值得研究。再往后如果业务量变大预约接口要考虑Redis分布式锁替代现在的数据唯一索引兜底方案整个系统就从单体练手项目慢慢长成了更像线上服务的架构。最后说一句个人体会。停在我复现这个项目的过程中最大的收获不是这些框架代码本身而是养成了一个习惯扩展任何功能之前先把数据表结构想清楚问自己如果并发出现会怎样。有了这个底层的思考习惯写出来代码的可靠性差别非常大。如果第一次做没有头绪不要急着锤代码先照着文章里这套结构把表建好把角色权限理顺项目就已经成功了一半。