前阵子帮一个本地驾校做了套内部管理系统。说起来不算什么高深项目,但这类业务系统特别考人——需求琐碎、角色分明、字段多、状态流转复杂,而且每天都有真实的人在使用。最初驾校手里是一摞Excel表,学员信息散落在好几台电脑里,约课靠打电话,训练记录靠手填,月底统计报表更是让人头大。
后来我把这套系统重新用 SpringBoot + Vue 这套前后端分离架构梳理了一遍,交付时带着 MySQL 脚本、完整源码和一套说明文档,才算把这块硬骨头啃下来。这期间踩了不少坑,也沉淀了一套可以反复复用的落地套路。这篇博文就把整套实现思路完整拆开:从业务需求盘点到后端模块设计、数据库表关系、前端页面组织,再到打包部署和文档交付,最后把开发过程中真正遇到的坑也一并讲清楚。
这套东西适合谁?如果你正在做毕业设计,或者公司打算从零搭一套内部管理后台,又或者想完整走一遍 SpringBoot 全栈项目的实战流程,都可以直接照着这套思路来。它不矫情、不含糊,是那种可以真正上线运行的代码结构,不是光有 Demo 的玩具工程。
1. 为什么驾校需要一套管理系统:业务场景与需求盘点
1.1 传统驾校管理的核心痛点
驾校这个行业,业务链路其实非常长。招生报名只是第一步,后面跟着学员建档、缴费记录、科目学习、教练安排、学时统计、约考登记、考试结果、车辆维护……每一条线都是一个独立流程,但它们又彼此纠缠在一起。Excel 表最要命的地方在于,人跟人之间共享信息靠传文件,改没改、谁改的、什么时候改的,完全说不清。
我接手时对方提的第一个需求是“把学员档案管好”,但深入聊下去就发现,真正让他们白天焦头烂额的其实是一件事:约课的冲突管理。一个教练一天最多带几个学员,哪个学员练了多久,哪些学时还没打完,如果都靠人工记忆去排,必然出错。系统上线后最大的变化不是“录入更方便”,而是“所有状态在一个地方流转,责任清晰”。
1.2 系统使用角色与权限边界
做管理系统的第一件事,永远是把人分清楚。驾校管理系统至少有三种角色,再加上一个可以临时扩展的招生营销角色。
| 角色 | 核心诉求 | 主要操作面 |
|---|---|---|
| 系统管理员 | 全局掌控、数据维护 | 所有模块的增删改查、数据统计、教练排班、车型管理 |
| 教练员 | 查看自己的任务、录入训练结果 | 查看课表、确认约课、填写训练记录、上报车辆故障 |
| 学员 | 查看个人进度、自助约课 | 查看课程信息、约课/取消约课、查看学时、查看考试结果 |
实际项目里很多找上门的“软件外包客户”会把前台客服也算一个角色,但因为权限逻辑可以复用,我通常只在代码里预留一张角色表,具体菜单权限用 RBAC(基于角色的访问控制)去做。这样后面想扩展“招生专员”角色,只要在数据库里加一条记录,再分配菜单权限就行,不用改代码。这个设计决策很关键,千万别把角色写死成枚举散落在各处。
1.3 功能清单与技术边界
把需求收敛成一张功能清单,方便后面拆表、拆接口:
- 系统管理:用户管理、角色管理、菜单管理、操作日志
- 学员管理:学员列表、报名建档、证件上传、学员状态流转(在读/暂停/结业/退学)
- 教练管理:教练信息、教练状态、带教学员分配
- 约课管理:按教练查看时间槽、学员预约、取消、过期处理
- 学时记录:训练开始结束时间、训练科目、教练填写评语
- 考试管理:科目一/二/三/四约考记录、考试成绩登记、不合格自动进入补考流程
- 车辆管理:车辆档案、年检到期提醒、维修记录
- 统计报表:学员通过率、教练工作量、月度收入汇总
技术边界上,我选了 Vue 3 + Element Plus 做前端,要求 Node 16 以上跑得动;后端 SpringBoot 2.7.x + MyBatis-Plus + MySQL 8,JDK 用 1.8 或者 11 都行;Redis 6 做登录状态缓存和验证码存储;接口文档用 Knife4j 生成,交付时直接给 HTML 文档。
提示:SpringBoot 版本不要追最新,2.7.x 是兼容性最稳的一个版本线。我见过不少朋友直接上 SpringBoot 3.x,结果 MyBatis-Plus 和部分旧依赖一起报错,最后卡在环境上,项目本身的逻辑反而没时间写。
2. 后端工程落地方案:SpringBoot 模块化设计
2.1 技术栈选型与目录结构
后端这一层,我不会用任何花哨的微服务架构。一个驾校管理系统最多几十个并发,单体应用就是最优解。但单体不代表乱写,工程目录要按业务垂直切分,方便后续维护。
我的标准目录长这样:
com.example.driving ├── config # 跨域、Knife4j、MyBatisPlus配置 ├── controller # 接口层 │ ├── admin │ ├── coach │ └── student ├── service # 业务逻辑层 ├── mapper # 数据访问层 ├── entity # 实体类 ├── dto # 接收参数的DTO,不要直接拿实体接前端数据 ├── vo # 返回给前端的视图对象 ├── common # 统一返回结果、异常处理、基础工具 └── interceptor # 登录拦截器、权限拦截器很多新手喜欢把实体类直接当返回参数往外抛。短期看没问题,但这会带来两个隐患:一是密码、手机号这种敏感字段容易在不经意间被序列化出去;二是如果表结构变了,前端接口也得跟着变。所以我在项目一开始就约定:接收参数用 DTO,返回数据用 VO,实体类只跟数据库打交道。这个约定后期非常省心。
2.2 登录鉴权与权限控制的实现
登录这块我采用 JWT + Redis 的组合。用户登录成功后生成一个 token,前端每次请求在请求头带上Authorization,后端拦截器统一校验。
Redis 在这个方案里负责两件事:一是把 token 拉黑,实现退出登录;二是存验证码,防止登录暴力破解。JWT 本身是无状态的,签发之后服务端无法主动让它失效,所以只依赖 JWT 而不加 Redis 的话,“强制下线”功能很难做。
核心代码大概长这样:
public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if ("OPTIONS".equals(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (StringUtils.hasText(token) && JwtUtil.validateToken(token)) { String userId = JwtUtil.getUserId(token); request.setAttribute("userId", userId); return true; } response.setStatus(401); return false; } }在 WebMvcConfig 里把需要放行的路径(比如登录接口、验证码接口)配置成白名单,其他接口一律走拦截器。权限控制对菜单、按钮两级做:菜单权限通过后端返回的路由列表控制,按钮权限通过注解控制,比如@PreAuthorize("hasAuthority('student:delete')")。
2.3 核心业务接口的抽象与复用
驾校系统的核心业务,可以抽象成四个字:查、约、记、统。
- 查:学员、教练、车辆、缴费信息的列表查询,带关键词搜索 + 分页
- 约:学员约教练的时间段,包含冲突检测
- 记:训练学时的录入,状态变更都打日志
- 统:柱状图、饼图、导出 Excel 用的统计接口
接口设计上,我习惯统一这种风格:
POST /api/admin/student # 新增学员 PUT /api/admin/student # 更新学员 GET /api/admin/student/{id} # 详情 POST /api/admin/student/query # 分页查询(带条件) DELETE /api/admin/student/{id} # 删除分页查询用 POST 而不是 GET,原因是查询条件往往很长,POST 可以把 JSON 直接丢给后端,也方便未来加复杂组合条件而不改请求方式。MyBatis-Plus 的Page对象配合 LambdaQueryWrapper,两行代码就能搞定大部分分页查询。
2.4 文件上传、导入导出与统计报表
学员报名通常要上传身份证照片、驾驶证照片,所以写一个FileController是必须的。本地开发时把文件存储到项目指定的磁盘目录,application.yml里配一个file.upload-path,将来要迁到 OSS 也方便,只需要替换一个 service 实现类。
导出报表我用 Hutool 的 ExcelWriter,一句话就能生成 xlsx 文件:
ExcelWriter writer = ExcelUtil.getWriter(true); writer.addHeaderAlias("name", "姓名"); writer.addHeaderAlias("phone", "手机号"); writer.write(rows, true);统计接口返回的数据结构要和前端图表约定清楚。饼图要[ {name, value} ],柱状图要{ columns: [...], rows: [...] },这类约定写在项目文档里,联调时能省一半沟通成本。
3. 数据库设计的实战推演:从字段到表关系
3.1 核心表结构与字段说明
数据库是这类管理系统的地基,设计得好不好,直接决定后面的接口写起来顺不顺手。我建的表包括:
- sys_user(登录账号)
- student(学员信息)
- coach(教练信息)
- vehicle(车辆信息)
- course_reservation(约课记录)
- study_record(学时记录)
- exam_record(考试记录)
- training_class(培训班次)
- payment_record(缴费记录)
拿最核心的student表举例,字段不用太复杂,但该有的必须有:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 自增主键 |
| sys_user_id | bigint | 关联登录账号 |
| name | varchar | 姓名 |
| id_card | varchar | 身份证号 |
| phone | varchar | 联系电话 |
| sex | tinyint | 性别 |
| status | tinyint | 0在读 1暂停 2结业 3退学 |
| entry_date | date | 报名日期 |
| class_id | bigint | 所属班次 |
唯一值得注意的是,student和sys_user是一对一关系,但两者要解耦。学员信息里的姓名、身份证这些自然属性放 student 表,登录名、密码、角色关联放 sys_user 表。这样将来如果学员毕业了要清理账号,不会误删业务数据,反之亦然。
3.2 约课与学时记录的数据流转
约课这块是最容易出 bugs 的地方。我的设计是:教练先维护自己的可约时间段(time slot),学员选择时间段提交预约;预约记录的状态字段是0待确认、1已确认、2已完成、3已取消。
关键点在插入当天约课记录之前,要查一次该教练在这个时间段是否已有其他status != 3的记录,存在则拒绝重复预约。这个校验必须放在数据库层面加一个唯一索引作为兜底,我当时的做法是给(coach_id, slot_date, slot_time)建联合唯一索引,否则并发情况下两条请求可能同时通过应用层校验,产生数据冲突。
学时记录表要记录教练 ID、学员 ID、训练日期、开始时间、结束时间、训练科目、教练评语。实际业务中,学时常数和约课记录不是一回事:一个约课时间段可能因为学员迟到只练了半个钟头,所以学时表应该在训练完成后由教练单独填写,不要直接引用约课记录里预设的时长。
3.3 考试与结业流程的状态机设计
考试记录的状态机值得单独讲。驾考的科目一、科目四是在电脑上考理论,科目二、科目三是上路实操,每个科目都要记录考试日期、考试地点、成绩、是否合格。用状态机控制:
- 初始状态:待考试
- 合格:标记通过,进入下一科目
- 不合格:进入补考流程,状态置为待补考
- 补考通过:继续下一科目
- 全部科目通过:学员状态自动变为“结业”
最简单的做法就是加一个current_subject字段,从 1 到 4 递增。当科目四合格后,更新学员状态为结业。这种状态流转逻辑可以写一个单独的ExamService.nextStep(studentId)方法统一处理,尽量不要在 Controller 里散落 write 逻辑。
4. 前端 Vue 工程:管理后台的页面组织与交互实现
4.1 前端目录结构与路由规划
前端我选择 Vue 3 + Element Plus + Axios + ECharts。目录结构按业务模块划分,而不是按技术类型划分:
src ├── api │ ├── student.js │ ├── coach.js │ ├── reservation.js │ └── login.js ├── assets ├── components ├── layout ├── router ├── store └── views ├── login ├── dashboard ├── student ├── coach ├── reservation ├── exam └── system路由设计上采用动态路由:登录后先从后端拉取当前用户有权限的菜单,再通过router.addRoute动态注册。这样管理员和教练登录后看到的后台完全不一样,而不是靠按钮隐藏来欺骗用户。
4.2 表格页、表单页、统计页的通用写法
后台管理系统百分之八十的页面本质是:搜索表单 + 表格 + 分页 + 新增/编辑弹窗。把这些页面组件化之后,后端管理系统的开发速度会翻倍。我是这么封装的:
// api/student.js export function queryStudentPage(data) { return request({ url: '/api/admin/student/query', method: 'post', data }) }页面里统一用TablePage组件接收配置项:searchFields、tableColumns、api,然后内部自动处理加载状态、分页、刷新。新增编辑弹窗统一用DialogForm包裹,表单校验规则写在配置里。这套封装写完之后,后面每加一个模块基本就是复制一个目录,改改配置和字段,半小时出一个小模块。
4.3 前后端联调的接口约定
前后端联调最害怕各说各话。我在项目文档里固定了两条约定:
第一,接口返回格式统一:
{ "code": 200, "message": "操作成功", "data": {} }Axios 响应拦截器里统一判断code,code 不是 200 的话直接弹出 message 内容。业务上不需要每个接口再手动处理错误分支。
第二,分页结构统一:
{ "total": 100, "records": [] }Element Plus 的el-pagination正好接收这两个字段,前端拿到后直接塞进去即可,不用再做二次转换。这儿有个细节:MyBatis-Plus 的分页默认字段是records和total,后端 VO 就按这个格式返回,不要顺手改成list,不然前后端来回对齐字段会浪费很多时间。
5. 项目打包部署与文档交付:让源码真正跑起来
5.1 本地开发环境的准备
一个完整项目交付给别人的第一关,永远是环境。我把环境要求写进了 README 的第一段:
- JDK 1.8 或 11
- Maven 3.6+
- MySQL 8.0
- Redis 6.x
- Node.js 16+
- 开发工具:IDEA + VSCode 即可
数据库脚本放在sql/driving_school.sql,里面包含建库、建表、初始数据。初始数据必须自带一个管理员账号,比如admin/admin123,方便对方启动后立刻看到系统。我第一次交付时只给了建表脚本没给初始数据,对方连登录都登录不了,体验极差,后来所有项目都默认把初始账号写进脚本。
5.2 后端打包与前端构建的细节
后端打包就一条命令:
mvn clean package -DskipTests需要注意application.yml里的数据库连接、Redis 地址不要写死成localhost,用${DB_HOST:localhost}这种占位符风格,部署时通过环境变量覆盖即可,既方便本地跑,也方便服务器部署。
前端构建要注意这里:
npm run build构建产物在dist/目录。部署时我一般不做前后端完全分离,而是把 dist 直接复制到后端项目的src/main/resources/static/下重新打包成一个 jar。这样对方只需要java -jar driving-school.jar一条命令就能把前后端一起启动,运维成本降到最低。
但开发阶段必须配置跨域,后端单独跑在 8080,前端跑在 5173(Vite 默认端口)或 8081。跨域用 CorsFilter 全局放开,并且允许携带 token 请求头:
@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }5.3 部署时的常见问题与应急处理
我总结几个发生率最高的部署问题,哪怕你照着文档一步步走,也难免碰到:
一是数据库时区问题。连接串里必须加serverTimezone=Asia/Shanghai,否则插入时间比实际时间少八小时,很多人排查半天找不到原因。二是 MySQL 8 的驱动名是com.mysql.cj.jdbc.Driver,老代码里写的com.mysql.jdbc.Driver虽然也能跑,但会在启动日志里刷警告,建议直接改掉。三是前端打包路径问题,Vite 的默认base是/,如果部署在域名子路径下,页面会白屏。统一在vite.config.js里写base: './',让它用相对路径。
6. 这套系统开发过程中的实战心得与避坑指南
6.1 业务逻辑上最容易出错的地方
约课并发和状态更新是我在这个项目里踩得最深的坑。学员端在凌晨集中抢热门教练的时段,接口压力不算大,但数据库层面可能有碰撞。如果刚好有两个学员同时预约同一个时间段,应用层校验通过后,数据库唯一索引会直接报错。前端需要把 duplicate key 异常翻译成“该时段已被预约”的友好提示,不要直接抛 500。
另一个容易错的是删除逻辑。学员、教练这些核心业务表,不要真正 delete 物理删除,而是在表中加deleted字段,逻辑删除。原因很简单:历史数据是有价值的,学员退学后,他的缴费记录和学时记录还要用来统计财务报表。MyBatis-Plus 的@TableLogic注解可以全局实现逻辑删除,一条注解搞定。
6.2 体验层面的优化要点
管理后台的体验好不好,往往不体现在界面美不美,而体现在操作效率。我在这个项目里做了几个小优化,对方使用后反馈非常明显:
- 学员列表支持身份证号、手机号模糊搜索,报名时用手机号一敲就出结果
- 教练排班支持按星期批量设置,不用一天一天重复添加
- 统计报表导出 Excel 时,带上前端当前筛选条件,而不是把所有数据导出来
- 操作按钮加了权限控制,无权限的按钮直接不渲染,减少误操作
这些东西看起来零零碎碎,但真正决定一个系统“能不能用起来”的,恰恰就是这些细节。
6.3 二次开发与扩展建议
这套系统的架构本身预留了不少扩展点。如果你拿到源码之后打算继续往上加东西,我建议优先做三件事:
第一,把驾校的线上报名小程序接进来,学员在小程序里自助报名、支付订金,后台自动建档。第二,增加消息通知模块,考试日期、预约确认、学时提醒都通过短信或微信模板消息推送给学员,这能大幅降低前台人员的工作量。第三,把车辆年检、保险到期的提醒做成定时任务,在管理后台首页展示到期列表。
如果业务量真的起来了,单靠 MySQL 也够撑很大一阵子。只有当你要做跨校区部署的时候,才需要考虑把报表模块拆出来做成独立的统计服务,平时真不用过早追求微服务化。
最后分享一个我在多个项目里验证过的经验:一套项目的交付质量,从数据库脚本里是否带了初始数据、README 里是否写了环境版本号、接口文档是否和代码同步更新就能看出来。代码写得好是基本功,能把源码、数据库、文档整理成一套任何人拿到都能跑起来的交付物,这才是被人认可的专业度。希望这份基于 SpringBoot + Vue 的驾校管理系统实现思路,能帮你把项目做得又快又稳。