前前后后折腾了两周,总算是把这套驾校管理系统从零到一完整跑通并部署到了服务器上。这是一套标准的前后端分离项目,后端基于SpringBoot+MyBatis,数据库用的MySQL,前端采用Vue全家桶。整体业务覆盖了驾校日常运营里最常见的几个场景:学员报名建档、教练信息管理、预约练车、考试安排与成绩录入,再加上管理员后台必备的用户登录与权限控制。对正在准备毕业设计、或者刚学完SSM和Vue想找个完整项目练手的人来说,这套系统的源码结构和部署流程都有直接的参考价值。
这篇就把整个项目的设计思路、数据库表结构、核心代码实现、前后端联调方式,以及从本地启动到服务器上线的完整过程全部摊开来讲。不光告诉你每一步怎么做,还会解释为什么要这么做——很多坑不走到那一步是真的想不到。
1. 项目整体设计与实现思路
1.1 业务需求拆解:驾校管理到底要管什么
做系统之前必须先弄清楚业务角色和业务流程。驾校里日常打交道的主要有三类人:管理员(或者叫教务/超管)、教练、学员。围绕这三类角色,系统要解决的核心问题有几个:
- 学员报名后需要建档,记录姓名、身份证号、电话、报名日期、报考车型,后续所有操作都基于这份档案。
- 教练需要维护自己的基本信息、准教车型、可预约的时间段,管理员还要能调整教练的工作状态。
- 学员练车之前要预约时间和教练,预约不能冲突,教练也要看到自己当天的安排。
- 考试要分科目记录,包括科目一、科目二、科目三、科目四的考试日期和成绩,最终判断学员是否通过。
- 管理员还要有数据统计能力,比如每月新增报名人数、各科目通过率。
这些需求落到功能模块上,就是系统管理、学员管理、教练管理、车辆信息管理、预约管理、考试管理、统计报表七个大块。项目里没有塞一堆华而不实的功能,每个模块都是驾校真实运营需要用到的,这样无论是做毕业设计还是自己接私活二次开发,业务逻辑都能经得起追问。
1.2 技术选型为什么是这套组合
技术栈选SpringBoot+Vue+MyBatis+MySQL,不是因为“大家都用”,而是每一项都能对上需求。
后端用SpringBoot,图的是它的自动配置能力和生态成熟度。以前用SSH或者纯SpringMVC搭项目,光配置文件就写一堆,SpringBoot把内嵌Tomcat、自动装配这些事情全部接管了,一个可运行的jar包丢到服务器上就能起服务。项目里需要做登录鉴权、拦截器、全局异常处理,SpringBoot都有成熟方案。
MyBatis在这个项目里比JPA更顺手。驾校管理系统的查询条件变化多,比如学员列表可能要按姓名、状态、报名日期范围组合筛选,这种场景用SQL非常直观可控。MyBatis的动态SQL可以灵活拼条件,同时SQL是手写的,索引怎么走完全可以自己掌控。MySQL则是国内中小项目应用最广的数据库,运维资料多,服务器上装一个用起来也不挑配置。我承认MyBatis-Plus开发效率确实高,但既然标题写的是MyBatis,那就按原生MyBatis讲清楚,这样你对SqlSessionFactory、Mapper绑定这些底层机制反而能理解得更透。
前端用Vue,核心原因是组件化和开发效率。配合Element UI做后台界面,表格、表单、分页、弹窗这些后台管理系统的高频组件都不用自己从零写。项目采用前后端分离,等于把前端页面和后端接口彻底解耦,前端可以独立开发调试,后端接口也可以单独测试,部署的时候前端跑Nginx、后端跑Java进程,互不干扰。
1.3 整体目录结构与模块划分
后端包结构我按职责做了分层,没有搞复杂的微服务架构,单体应用对这类系统完全够用:
- controller:接收请求,做参数初步校验,调用service
- service:业务逻辑,比如预约冲突判断、学员状态流转
- mapper:数据访问层接口
- mapper.xml:MyBatis的SQL映射文件
- entity:数据库表对应的实体类
- dto:前端传来的参数对象,跟实体类分离
- common:统一返回结果、异常处理、常量定义
- config:Web配置、拦截器注册、CORS配置等
- util:JwtUtil这类工具类
前端结构则是标准的Vue CLI工程:views按页面划分,比如Login.vue、StudentList.vue、Appointment.vue、ExamManage.vue;router集中管理路由和前置守卫;api目录下集中存放所有axios请求;utils里封装request.js。这种划分方式看代码不累,每个人负责一个模块也很清晰。
2. 数据库设计与核心业务模块解析
2.1 表结构设计与核心SQL
数据库我总共设计了六张核心表:系统用户表sys_user、学员表student、教练表coach、车辆表car、预约表appointment、考试记录表exam。如果按这个项目再扩展收费功能,也可以加一张费用记录表,但核心业务流程这几张就够了。
学员表是系统的中心,字段设计上有几个关键点。姓名、身份证号、手机号是基础字段,另外用一个status字段表示学员当前状态,0表示在读、1表示已结业、2表示已退学。身份证号要做唯一索引,因为一个学员只能有一条档案。报名时间用datetime类型。
预约表需要同时关联学员和教练,所以用student_id和coach_id两个外键。预约日期和时段要放在一起做唯一约束,防止同一个人在同一时间段重复预约。考试表则用student_id关联学员,subject字段表示科目,score字段记录成绩,result标记是否通过。
建表的时候注意两点:第一,字符集一定要用utf8mb4,不要用utf8,不然学员名字里有生僻字或者表情符号就会乱码;第二,ID字段统一用BIGINT自增,以后数据量大了不用换表结构。这里贴出学员表和预约表的建表SQL作为参考:
CREATE TABLE `student` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `name` varchar(32) NOT NULL COMMENT '学员姓名', `id_card` varchar(18) NOT NULL COMMENT '身份证号', `phone` varchar(11) DEFAULT NULL COMMENT '手机号', `car_type` varchar(8) DEFAULT 'C1' COMMENT '报考车型', `status` tinyint(4) DEFAULT 0 COMMENT '0在读 1结业 2退学', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_id_card` (`id_card`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学员信息表'; CREATE TABLE `appointment` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `student_id` bigint(20) NOT NULL, `coach_id` bigint(20) NOT NULL, `appoint_date` date NOT NULL COMMENT '预约日期', `time_slot` tinyint(4) NOT NULL COMMENT '时段 1上午 2下午 3晚上', `status` tinyint(4) DEFAULT 0 COMMENT '0待确认 1已确认 2已完成 3已取消', `remark` varchar(200) DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_student_slot` (`student_id`, `appoint_date`, `time_slot`), KEY `idx_coach_date` (`coach_id`, `appoint_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='练车预约表';2.2 登录权限模块与角色体系
登录权限是我最早动手做的模块,因为所有页面都需要先过了登录这一关。项目里没有引入Spring Security,原因很简单:这套系统的权限需求只是“管理员能进管理后台操作,教练能看自己的排班和预约,学员能提交预约申请”,不需要复杂的OAuth或者细粒度的数据权限。用JWT配合拦截器已经能非常好地解决鉴权问题。
用户表sys_user里设计一个role字段,后台就用这个字段区分用户类型。密码不是明文存储,BCrypt加密之后存进数据库,这个很重要——真实项目里密码绝对不能明文落库,否则数据库只要泄露一次,所有账号就全完了。JWT的token在登录成功时生成,包含用户ID、用户名、角色三个核心信息,签名密钥维护在配置文件里。
2.3 预约练车与考试流程这两块硬骨头
预约练车的核心问题是怎么防止冲突。一个教练同一时间只能带一个学员,一个学员同一时间也只能有一个预约,所以插入预约数据之前先查一遍有没有冲突。这里我用的办法是在appointment表建唯一索引(student_id, appoint_date, time_slot),数据库层面直接杜绝重复预约。代码里先做一次查询校验,给用户友好提示,数据库唯一约束兜底,双保险。教练那边的冲突则通过索引idx_coach_date来控制,教练某天的某个时段一旦已经被预约,再次插入就无法成功。
考试流程的设计思路是科目状态推进。每个学员可以有多条考试记录,按subject区分科目。管理员录入成绩时更新考试成绩表和学员的当前进度字段。科目一和科目四是理论考试,科目二科目三是实操,系统的逻辑统一用subject字段区分,不做复杂处理。
3. 后端核心实现:SpringBoot+MyBatis实战
3.1 工程初始化与核心配置
创建SpringBoot工程我用的是Spring Initializr,Java版本用的8,SpringBoot版本选了2.7.x——这里特别提醒一句:别一上来就用SpringBoot 3.x,因为它要求JDK 17,而且很多第三方组件的兼容性还是2.x更稳,等这个项目跑通了你再升级不迟。
pom.xml需要引入的核心依赖如下,我用注释标明了各自的作用:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.2.2</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency>application.yml配置里最值得讲的是MyBatis的驼峰映射。数据库字段是下划线命名(create_time),Java实体是驼峰命名(createTime),如果不开启map-underscore-to-camel-case,查出来的createTime就是null。很多人遇到实体字段全是null的第一反应是SQL写错了,其实往往是这行配置没加。还有JDBC连接串的参数,我直接给出了完整的配置:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/driving_school?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: 123456 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.drivingschool.entity configuration: map-underscore-to-camel-case: true jwt: secret: your-secret-key-please-change-me expire-days: 73.2 统一返回结果与全局异常处理
所有接口返回值我都封装成了统一的Result对象,结构是code、message、data三个字段。code为200表示成功,其他值表示各种业务异常。这么做的好处是前端可以统一处理响应逻辑,不用每个接口单独判断。无论成功返回数据还是抛出异常,前端axios拦截器拿到的都是结构一致的JSON,处理逻辑可以收敛到一处。
全局异常处理用的是@RestControllerAdvice配合@ExceptionHandler。捕获异常之后返回Result对象,而不是直接把异常堆栈抛给前端。业务异常我自定义了一个BizException,参数校验失败、数据不存在、业务冲突等场景统一抛出,由全局处理器转成规范的错误响应。这个设计在做毕业设计答辩的时候很加分,因为它直接体现了一个开发者对工程化异常处理的理解。
3.3 JWT登录与拦截器实现
JWT的工具有两个方法:生成token和解析token。生成token时把用户ID和角色放进Claims,失效时间按配置的天数计算。登录成功后,前端拿到token存在localStorage里,之后的每一次请求都在请求头里带上Authorization字段。
后端拦截器继承HandlerInterceptorAdapter,重写preHandle方法。从请求头取出token,解析成功就把用户信息放入ThreadLocal,方便后续的controller和service里直接获取当前登录用户。解析失败或没有带token,直接返回401状态码,让前端跳到登录页。
拦截器注册时要注意配置放行路径。登录接口、静态资源、OPTIONS预检请求必须放行,否则前端跨域预检直接就被拦截了,用户根本登不进去。我这里贴出WebConfig的核心片段:
@Configuration public class WebConfig implements WebMvcConfigurer { @Autowired private JwtInterceptor jwtInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns("/**") .excludePathPatterns( "/api/auth/login", "/api/auth/register", "/error", "/static/**" ); } }3.4 Mapper层与动态SQL实践
MyBatis的Mapper层我讲的细一点,因为这是整个项目里最容易出问题也最体现功力的部分。每个Mapper接口对应一个XML文件,XML的namespace必须和接口全限定名一致,方法名必须和SQL语句的id一致,这两点错了直接启动报绑定异常。
学员列表查询是动态SQL的典型场景。前端传入name、status、beginTime、endTime这些可选条件,后端用MyBatis的where加if标签动态拼接SQL,不需要为每种组合单独写一个方法。这里有一个细节:模糊查询时不要直接写%开头的条件,而是在SQL里用CONCAT拼:
<select id="selectStudentList" resultType="com.drivingschool.entity.Student"> SELECT * FROM student <where> <if test="name != null and name != ''"> AND name LIKE CONCAT('%', #{name}, '%') </if> <if test="status != null"> AND status = #{status} </if> <if test="beginTime != null"> AND create_time >= #{beginTime} </if> <if test="endTime != null"> AND create_time <= #{endTime} </if> </where> ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} </select>动态SQL里还有一个点必须强调:永远用#{}而不是${}。${}是字符串拼接,用户输入的内容直接嵌进SQL里,等于把SQL注入的刀递到别人手里。而#{}是预编译参数占位符,无论输入什么内容都被当成参数值处理。项目里除了order by这种没法预编译的场景外,全部用#{}。
分页这里我没有引入PageHelper插件,直接用的LIMIT偏移量和每页条数。这个思路在数据量不大的场景下没有任何问题,代码也简单。如果后续数据量过十万,再考虑PageHelper或者基于游标的分页方案。
4. 前端Vue搭建与前后端接口联调
4.1 Vue工程结构与基础配置
前端我用Vue CLI创建,默认选Vue 2.6版本配合Element UI,这个组合的文档最齐全。注意如果你用Vue 3,Element UI要换成Element Plus,很多组件的写法有变化。创建完成后通过npm安装element-ui、axios、vue-router、vuex四个依赖。
开发阶段最大的痛点是跨域。前端开发服务器跑在8081端口,后端接口跑在8080端口,浏览器会直接拦截跨域请求。解决办法有后端加CORS配置,也有前端配置开发代理。我强烈建议开发环境用vue.config.js的proxy方案,因为生产环境有Nginx统一处理跨域,开发环境用代理模拟的路径规则和生产环境一致,不会出现两套逻辑:
const { defineConfig } = require('@vue/cli-service') module.exports = defineConfig({ devServer: { port: 8081, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })4.2 Axios二次封装与登录状态管理
axios如果不做封装,每个页面都要重复处理加载状态、错误提示、token注入,代码会非常散。我在src/utils/request.js里统一创建了axios实例,设置了baseURL、超时时间、请求拦截器和响应拦截器。
请求拦截器把localStorage里的token取出来放进headers.Authorization字段。响应拦截器统一处理状态码:200直接返回data,401跳到登录页并清空本地登录信息,其他错误码弹出提示。这样页面里写请求时就非常干净,只需要关心业务数据。
登录页面拿到token后除了存localStorage,我还会存一份用户信息到Vuex里,防止刷新页面后用户信息丢失。菜单的显示隐藏也根据用户角色动态控制,admin看到全部菜单,教练看到工作台和排班页面,学员看到预约中心和考试信息页面。
4.3 路由守卫与菜单权限控制
路由守卫是这个项目里前端必须理解透的概念。项目配置了一个全局前置守卫router.beforeEach,每次路由跳转前检查localStorage里有没有token。没有token且去往的不是登录页,就强制跳转到登录页;有token但访问的是登录页,就跳回首页。这个逻辑放在路由配置里统一处理,而不是每个页面单独判断,代码量省下一大截。
菜单的动态控制我采用的是前端根据角色字段v-if判断的方案。点击左侧菜单跳转到真实路径,后端对每个接口也会校验角色,即使有人手动输入URL跳转,也无法绕过权限拿到数据。前后端两层校验配合起来才算完整。
4.4 学员管理页与预约看板实战
学员管理页是整个项目前端代码的样板模块。页面结构是搜索区、表格区、分页区、新增编辑弹窗。搜索区用el-form配合el-input和el-select,搜索按钮触发query方法重置页面到第一页。表格区用el-table渲染学员列表,状态列用el-tag根据不同状态值显示不同颜色——这个细节看起来很基础,但用户看到彩色状态标签的体验感和纯文本完全不同。
预约页面比较复杂。学员选择教练和时间段之前,表格会先展示该教练未来一周的可约时段。后端封装了一个接口,接收coach_id和日期范围,返回已近满的时间段列表。前端把不可选时段在时间选择器里禁用掉,从入口避免二次点击。这个交互设计很直接,不需要用户先提交再报错。
写前端时反复提醒自己:页面能简单尽量简单,但用户提交的每一项数据都要做必填校验。学员姓名为空不允许提交,手机号格式不正确不允许提交,预约日期不能是过去时间。前端校验拦截一部分问题,后端的参数校验再兜底,千万不能只做一头。
5. 部署上线全流程:从源码到服务器
5.1 本地环境准备与数据库导入
本地把项目跑起来的条件并不苛刻:JDK 1.8、Maven 3.6以上、MySQL 8.0、Node 14以上。数据库部分我准备了一个init.sql脚本,包含建库建表语句和默认管理员账号数据。用数据库客户端执行整个脚本之后,driving_school库就建好了。如果用命令行数据库导入也完全没问题:
mysql -u root -p < init.sql前端依赖安装时如果网络状况不好,npm install可能会卡住。解决办法是配置registry为国内镜像源,再执行安装命令。注意改完镜像源之后最好把node_modules整个目录删掉重新安装,不然容易出现一半依赖来自原源一半来自新源的情况。
5.2 生产构建与jar包运行
前端打包执行npm run build,产物在dist目录。构建完成后从dist目录结构里能看到static文件夹和index.html,这个目录就是后续Nginx要指向的静态资源根目录。
后端打包用Maven打包命令,-DskipTests跳过测试以免构建过程被环境问题卡住。打包完成后target目录下会生成一个可运行的jar包。这个jar包内部内嵌了Tomcat,所以服务器上只要有JDK环境就能直接运行:
mvn clean package -DskipTests java -jar driving-school-1.0.0.jar --spring.profiles.active=prod后台运行我推荐用systemd管理Java进程,配置一个service文件,启动、停止、开机自启都方便。不要直接用nohup加&方式挂在后台,一旦进程挂了没人知道,systemd至少能在进程崩溃后直接拉起并记录日志。
5.3 Nginx反向代理与前端静态托管
生产环境前端页面放在Nginx里,后端接口通过location /api代理到Java服务端口。这样一个域名加一个端口搞定全部访问,浏览器请求的都是同源的/api路径,也就不会有跨域问题。Nginx配置如下,这里有几个细节要重点说明:
server { listen 80; server_name your-domain.com; gzip on; gzip_types text/plain application/javascript text/css application/json; root /opt/driving-school/dist; index index.html; location / { 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 /的try_files配置是历史模式路由下必须写的,否则用户刷新某个子页面时会得到404。location /api/的proxy_pass后面没有带路径,所以原来的/api会被完整传给后端。如果你的后端接口路径不想要/api前缀,就把proxy_pass写成http://127.0.0.1:8080/,这样前端请求/api/auth/login会被转发到http://127.0.0.1:8080/auth/login,路径里的/api会被替换掉。这段逻辑建议操作的时候打开Nginx的access.log观察实际转发请求,一清二楚。
5.4 服务体系部署的额外考量
如果服务器内存比较紧张,Java进程启动时可以通过JVM参数限制内存使用,比如-Xms256m -Xmx512m。MySQL数据库在低配服务器上也可以把innodb_buffer_pool_size调小一些。另外数据库连接串里的serverTimezone必须和服务器时区保持一致,服务器时区不对会导致查出来的时间差八个小时,很多人部署后时间不对都是栽在这上面。
服务器系统防火墙的安全策略需要把80端口和数据库端口放行,但数据库的3306端口建议只对应用服务器内网开放,别暴露在公网上。生产中数据安全是第一位的,千万不要图方便开着公网访问数据库。
6. 常见问题排查与避坑实录
6.1 后端启动失败与数据库连接类错误
项目跑起来首先遇到的大部分问题都出在数据库连接上。报错信息里最常见的是时区和SSL相关。加入serverTimezone=Asia/Shanghai能解决时区报错,useSSL=false能解除SSL警告。还有MySQL 8的驱动默认会要求allowPublicKeyRetrieval,这个参数也最好显式加上,否则会报Public Key Retrieval is not allowed。端口被占用的情况也很常见,使用SpringBoot内置Tomcat报端口占用时,先看下是不是有其他Java进程占用了8080,直接改配置里的server.port即可。
数据库导入SQL脚本时报语法错误,十有八九是用了不兼容的问价版本。用MySQL 8的客户端连接MySQL 5.7的服务器,或者反过来,都会出现莫名其妙的语法错误。建议本地和服务器都用MySQL 8,版本保持一致,省掉研究兼容性的时间。
6.2 前端启动与联调中的跨域问题
前端开发环境接口报跨域,首先确认vue.config.js修改后是否重启了开发服务器。proxy配置不是热更新的,改完代理之后必须Ctrl+C再重新npm run serve,否则怎么刷新都没效果。其次确认请求路径是不是以 /api 开头,代理规则是按路径前缀匹配的,路径不对代理不生效。
生产环境联调时如果前端能打开但接口全部请求失败,重点看Nginx的error.log。502 Bad Gateway说明Nginx连不上后端端口,先确认Java进程还活着,再确认proxy_pass端口和Java进程端口一致。404的话检查location /api的路径拼接规则,看转发后路径是否和后端Controller的映射路径能对应上。
6.3 MyBatis绑定异常与其他日常坑
启动项目时出现Invalid bound statement (not found)错误,几乎都是这三个原因:XML文件的namespace跟Mapper接口的全限定名不一致;接口里的方法名跟XML里的statement id不一致;或者XML文件没有被打包到classes目录。第三个原因在Maven项目里最常见,解决方法是确认pom.xml里把src/main/resources下的xml文件包含了,或者在打包时排除资源文件的配置里把xml排除规则去掉。
Mapper层查询出的实体字段全是null,检查map-underscore-to-camel-case是否开启。想调试SQL语句,在application.yml里加上日志配置打印SQL日志,开发时非常有用。
6.4 部署后的低配环境优化
部署到低配服务器后,首次访问页面可能会明显卡顿。前端资源加载慢的问题用Nginx开启gzip压缩能有效缓解。如果还有富余的时间,建议把Nginx的静态资源缓存也配置上,index.html不缓存,但JS和CSS等带hash的文件可以长缓存。后端接口响应慢,排查下是数据库查询慢还是Java处理慢,数据库慢看SQL走了索引没有,用EXPLAIN验证一下。
前后端分离项目部署完毕后最后检查一件事:把后端的application.yml里所有调试开关关掉,SQL日志关掉,错误堆栈不要直接返回给前端。这些设置不调整,相当于把自己系统的内部结构暴露给用户。
最后分享一个我自己的使用体会:做这套项目的时候我一开始把数据库外键约束建得特别全,结果后面删除学员时得手动清理预约和考试记录,逻辑绕来绕去,删起来提心吊胆。改成逻辑删除(is_deleted字段)之后才舒服很多。另外,如果你打算把这套系统拿去做毕业设计答辩或者演示,建议提前在数据库里预置好一批演示数据,启动完打开页面就有内容看,这个细节在验收环节特别加分。