刚把这套基于 SpringBoot 和微信小程序的驾校预约管理系统从零到一完整做通的时候,我最大的感触是:预约类项目最难的根本不是增删改查,而是怎么把“同一辆车、同一个教练、同一个时段”背后那堆剪不断理还乱的时间冲突管住。练车预约、教练排班、学员取消改期、爽约扣课时,每一环都藏着坑。这篇内容不只是讲页面长什么样,我会把后端表设计、预约并发控制、小程序登录适配、部署上线的踩坑记录都摊开来说,适合正在做预约类毕业设计、或者想自己接驾校小程序项目的开发者参考,哪怕你是第一次接触 SpringBoot 或微信小程序,跟着走也能落地。
1. 驾校预约业务拆解:先搞清三个角色的真实冲突
1.1 学车场景里,到底谁在和谁抢时间
驾校预约表面上是“学员选个时间,教练接单”,实际运营里至少有三种角色在打架:学员、教练、驾校经营者。
学员想要的是周末能约上、晚上能约上,并且临时有事能顺利取消;教练想要的是课表别排得太满,自己能留出休息喝水的时间,学员没来能第一时间知道;经营者想要的则完全不同——每一节课时最好都别空着,但也要防止教练超负荷排课,练车质量下降。
这三种诉求叠加在一起,就成了一张非常敏感的排班表。我们需要的不是简单做一张“预约记录表”,而是先定义清楚数据模型的核心约束:每个课时是一个固定时间段,每个教练同一时间段只允许存在一个排班,每个时间段只能被一个学员预约。这个约束,才是驾校预约系统的灵魂。
1.2 预约状态流转:从“待开始”到“已完成”的完整生命周期
写代码之前,我先花了一晚上整理预约状态机。这个动作特别重要,因为如果状态定义不清晰,后面每次调整接口都要连带改数据库字段,非常痛苦。
系统里至少需要这些状态:
- 待开始:学员预约成功,课时还没有到时间;
- 已确认:教练查看排班后手动确认,或者系统自动确认;
- 练车中:学员到场签到,或教练点击开始上课;
- 已完成:课时正常结束;
- 已取消:学员或教练主动取消;
- 爽约:学员没到且没有提前取消。
这几种状态之间的流转关系,我用表格固定了下来:
| 当前状态 | 触发动作 | 操作方 | 下一个状态 |
|---|---|---|---|
| 待开始 | 教练确认 | 教练 | 已确认 |
| 待开始 | 学员取消(提前N小时) | 学员 | 已取消 |
| 待开始 | 定时任务扫描发现已过开始时间 | 系统 | 爽约 |
| 已确认 | 学员到场签到 | 学员/教练 | 练车中 |
| 已确认 | 学员取消(提前N小时) | 学员 | 已取消 |
| 练车中 | 课时结束 | 教练/系统 | 已完成 |
| 待开始 | 教练取消 | 教练 | 已取消 |
这里有个容易忽略的业务判断:取消预约之后,已扣的课时怎么处理。驾校一般不退钱,而是“退课时”。所以我在设计上把课时扣减放在预约成功那一刻,学员取消后自动返还课时数,但如果爽约则不再返还。这个逻辑如果不在表设计阶段想清楚,后面做对账会非常乱。
1.3 为什么是 SpringBoot + 微信小程序,而不是纯网页或 App
这个项目选型不算激进,但比较稳。微信小程序最大的优势是免安装、打开即用,学员在地铁上刷一眼就能看到明天的练车时段;对驾校来说,不需要让每个学员下载 App,也省了注册账号的麻烦,微信授权登录直接把身份拉起来。
后端选 SpringBoot 是因为它生态太成熟了:Spring MVC 做接口、MyBatis-Plus 做数据访问、Spring 事务管并发,网上资料多,踩坑时也能快速找到方案。小程序端只负责展示和交互,后端把所有业务规则收拢成接口,后续就算要再做一个管理后台,直接复用同一套 API 就行。
不过要提前说清楚边界:微信小程序的getPhoneNumber获取真实手机号接口,要求小程序是企业主体且已认证,个人主体没法用。如果只是个人开发者做演示,需要把登录逻辑改成“手机号+验证码”或者“用户名+密码”,这个后面我会细说。
2. 从零搭出工程结构:后端模块划分与数据库设计
2.1 SpringBoot 工程目录与依赖版本选择
我先按后端、小程序端、管理端三个端分开建工程。这里只聊其中最重要的 SpringBoot 后端,我的工程目录结构大致是这样:
driving-school/ ├── src/main/java/com/example/driving/ │ ├── config/ # 全局配置、WebMvc、拦截器、MyBatisPlus配置 │ ├── controller/ # 接口层 │ ├── service/ # 业务逻辑 │ ├── mapper/ # MyBatis-Plus 数据访问 │ ├── entity/ # 数据库实体 │ ├── dto/ # 请求和响应对象 │ ├── common/ # 统一返回、异常处理、常量、枚举 │ └── task/ # 定时任务:清洗排班、扫描爽约 ├── src/main/resources/ │ ├── mapper/ # 自定义SQL XML │ └── application.yml └── pom.xmlpom.xml 里最关键的依赖是这几个:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>这里必须提一下 SpringBoot 版本选择的坑。如果为了稳,建议 JDK 8 + SpringBoot 2.7.18 + MyBatis-Plus 3.5.3 这套组合,网上教程最多,踩坑也最容易搜到答案。如果非要用 SpringBoot 3.x,JDK 至少要 17,而且很多旧版 MyBatis-Plus 写法要调整。我自己第一次就在这上面翻车:本地用新版跑得好好的,部署服务器只有 JDK 8,启动直接失败,白白折腾了大半天。
2.2 核心表结构与字段设计
数据库我设计了七张核心表:用户表、教练表、学员表、课程套餐表、时间段表、教练排班表、预约记录表。另外加了一张操作日志表,方便排查问题。
最核心的是coach_schedule和appointment_record两张表,我直接给出关键字段:
CREATE TABLE `coach_schedule` ( `id` bigint NOT NULL AUTO_INCREMENT, `coach_id` bigint NOT NULL COMMENT '教练ID', `car_id` bigint DEFAULT NULL COMMENT '教练车ID', `schedule_date` date NOT NULL COMMENT '排班日期', `start_time` time NOT NULL COMMENT '开始时间', `end_time` time NOT NULL COMMENT '结束时间', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0空闲 1已预约 2已锁定', `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', PRIMARY KEY (`id`), UNIQUE KEY `uk_coach_date_time` (`coach_id`, `schedule_date`, `start_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;CREATE TABLE `appointment_record` ( `id` bigint NOT NULL AUTO_INCREMENT, `appointment_no` varchar(32) NOT NULL COMMENT '预约单号', `student_id` bigint NOT NULL COMMENT '学员ID', `coach_id` bigint NOT NULL COMMENT '教练ID', `schedule_id` bigint NOT NULL COMMENT '排班ID', `course_package_id` bigint DEFAULT NULL COMMENT '课程套餐ID', `appointment_date` date NOT NULL, `start_time` time NOT NULL, `end_time` time NOT NULL, `status` tinyint NOT NULL DEFAULT '0' COMMENT '状态:0待开始 1已确认 2练车中 3已完成 4已取消 5爽约', `cancel_reason` varchar(255) DEFAULT NULL, `create_time` datetime NOT NULL, `update_time` datetime NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;注意coach_schedule这个唯一索引:同一个教练同一天同一个开始时间只能有一条排班记录。这从数据库层挡住了重复排班的问题。预约记录则把schedule_id当成业务唯一线索,一个排班只能对应一条有效预约,后面并发控制全靠它。
2.3 接口划分:直接对照微信小程序端的 REST API
在设计接口时,我尽量只给小程序端暴露必要的接口,把权限校验统一放到后端。主要接口如下:
| 功能 | 接口路径 | 请求方 | 说明 |
|---|---|---|---|
| 微信登录 | POST /api/auth/login | 学员/教练 | code 换 openid |
| 获取手机号 | POST /api/auth/phone | 学员 | 新接口 code 换手机号 |
| 获取驾校信息 | GET /api/school/info | 学员 | 首页展示 |
| 获取教练列表 | GET /api/coach/list | 学员 | 可按日期过滤 |
| 查询可约时段 | GET /api/schedule/list | 学员 | 按教练+日期查询 |
| 提交预约 | POST /api/appointment/create | 学员 | 核心接口 |
| 取消预约 | POST /api/appointment/cancel | 学员/教练 | 前置校验 |
| 确认预约 | POST /api/appointment/confirm | 教练 | 教练端操作 |
| 开始练车 | POST /api/appointment/start | 教练 | 状态改为练车中 |
| 我的预约列表 | GET /api/appointment/my | 学员 | 区分待开始/历史 |
| 教练当日课表 | GET /api/appointment/coach/today | 教练 | 教练端工作台 |
这个清单基本能满足一个驾校预约小程序的全部核心场景。如果后面需要加考试预约、评价系统,也只需要在这套结构上扩展表,不影响核心预约流程。
3. 预约防冲突与并发控制:这个系统的灵魂
3.1 教练排班的时段生成逻辑
驾校的课时不会是像会议室那样随意选择的,通常按固定课长切分。我这边按 45 分钟一个课时,从每天 08:00 到 12:00、下午 14:00 到 18:00 生成时段,中间的 12:00-14:00 是教练休息时间,不排课。
排班我采用“后端定时任务自动生成未来七天排班”的方式,每天凌晨跑一次:
@Scheduled(cron = "0 0 1 * * ?") public void generateSchedule() { List<Coach> coaches = coachService.list(); for (Coach coach : coaches) { for (int i = 0; i < 7; i++) { LocalDate date = LocalDate.now().plusDays(i); List<ScheduleTime> slots = buildSlots(date); for (ScheduleTime slot : slots) { CoachSchedule schedule = new CoachSchedule(); schedule.setCoachId(coach.getId()); schedule.setScheduleDate(date); schedule.setStartTime(slot.getStart()); schedule.setEndTime(slot.getEnd()); schedule.setStatus(0); try { scheduleService.save(schedule); } catch (DuplicateKeyException e) { // 已生成,跳过 } } } } }注意DuplicateKeyException必须捕获,不然定时任务每天跑都会崩。这里用唯一索引兜底,比每次先查询再判断更可靠。
3.2 学员预约提交的事务与行锁
做预约提交接口时,最怕的是两个学员同时抢同一个时段。后端如果只做“先查一下状态,再插入”这种两步操作,并发情况下几乎必然出现超卖。
我的做法是在 Spring 事务里,先把对应的排班记录锁住,再判断状态:
@Transactional(rollbackFor = Exception.class) public AppointmentResult createAppointment(AppointmentRequest request) { // 1. 锁定排班记录 CoachSchedule schedule = scheduleMapper.selectByIdForUpdate(request.getScheduleId()); if (schedule == null) { throw new BusinessException("排班不存在"); } if (schedule.getStatus() != 0) { throw new BusinessException("手慢了,这个时段已被约走"); } // 2. 校验学员是否存在未完成的预约 Long cnt = appointmentMapper.countActiveByStudent(request.getStudentId()); if (cnt > 0) { throw new BusinessException("你还有未完成的课时,先处理完再约新的"); } // 3. 占用排班 int rows = scheduleMapper.updateStatus(schedule.getId(), 0, 1); if (rows == 0) { throw new BusinessException("时段状态已变化,请刷新后重试"); } // 4. 插入预约记录 AppointmentRecord record = buildRecord(request, schedule); appointmentMapper.insert(record); return AppointmentResult.success(record); }对应的 Mapper SQL 是这样:
<select id="selectByIdForUpdate" resultType="CoachSchedule"> SELECT * FROM coach_schedule WHERE id = #{id} FOR UPDATE </select> <update id="updateStatus"> UPDATE coach_schedule SET status = #{targetStatus}, version = version + 1 WHERE id = #{id} AND status = #{originStatus} </update>FOR UPDATE会在事务提交前一直持有这条排班记录的行锁,第二个学员的请求只能等待。等第一个事务提交后,第二个请求再执行时,读到的状态已经是 1,直接抛出“已被约走”。这样并发预约就被稳稳挡住了。
3.3 为什么不能只靠唯一索引解决冲突
有人可能会问:给预约记录加一个uk_student_schedule(student_id, schedule_id)唯一索引,不也能防重复吗?它能防住同一个学员重复约同一个排班,但防不住两个不同学员同时抢同一个排班,因为两条预约记录里的schedule_id都是同一个,插入时数据库并没有办法判断“这个排班是否已经归属给其他学员”。
所以唯一索引只能作为最后一道物理兜底,核心的防冲突逻辑还是要靠“更新排班状态时校验原状态”这一招。UPDATE ... WHERE status = 0如果影响行数为 0,说明状态已经被别人改过了。这个写法天然具备原子性,比“先查询再更新”安全得多。
3.4 取消、改期与爽约扣课时的落地
取消预约看起来简单,但一定要加时间限制。我在接口里要求学员只能在“预约开始时间前 2 小时”取消,超过这个时间不允许取消,只能按时到课或爽约。这个限制是为了保护教练的时间安排,不然学员早上 8 点的课 7:50 取消,教练已经出门了,体验很差。
取消的逻辑和预约对称,事务里也要先锁排班,再把状态改回空闲,最后把预约记录置为已取消。改期本质上就是“取消旧预约+创建新预约”,但是要注意:新预约还是得走完整的防冲突校验,不能悄悄插入。
爽约则由一个定时任务兜底,每 15 分钟扫描一次:
@Scheduled(fixedDelay = 900000) public void autoMarkNoShow() { List<AppointmentRecord> records = appointmentMapper.selectExpiredPending(); for (AppointmentRecord record : records) { // 把状态置为爽约 appointmentService.markNoShow(record.getId()); // 扣课时 studentService.deductCourseCount(record.getStudentId(), 1); } }这个定时任务在真实场景里很有用。如果不写,系统里会积压大量“待开始但已经过时间”的脏数据,学员下次登录一看还有一节未完成,预约接口直接被拦下来,就很莫名其妙。
4. 微信小程序端从登录到预约表单的落地细节
4.1 微信登录与手机号获取的前后端配合
微信小程序的登录不是传统意义上的账号密码登录。小程序端先调用wx.login()拿到一个临时code,后端拿这个code去微信接口换openid和session_key,这样后端就能唯一识别用户。
从 2022 年开始,小程序获取用户手机号也从“解密 encryptedData”改成了“用 code 换手机号”的方式。小程序端用button组件的开放能力:
<button open-type="getPhoneNumber" bindgetphonenumber="onGetPhoneNumber">微信手机号快速登录</button>async onGetPhoneNumber(e) { if (e.detail.code) { const res = await request.post('/api/auth/phone', { code: e.detail.code }); if (res.code === 0) { // 登录成功,缓存 token } } }后端的/api/auth/phone再用这个 code 调用微信接口:
String url = "https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=" + accessToken; // 请求体里传 code,返回 phone_info.phoneNumber这里要重点提醒:只有企业主体且完成微信认证的小程序才能获取真实手机号。个人主体开发时这个接口直接无权限。所以我在项目里做了一个兼容方案:个人主体下走“表单手机号 + 短信验证码”登录,企业主体下走微信手机号拉取,用一个 authType 字段区分,前端登录页根据配置隐藏/显示对应按钮。
4.2 顶部导航栏高度适配:iPhone 刘海屏不再遮挡内容
如果你在小程序里使用了自定义导航栏,就一定会碰到“顶部内容被状态栏和胶囊按钮挡住”的问题。这个问题在 iPhone 上特别明显,刘海屏加状态栏高度差异很大。
我采用了动态获取胶囊按钮位置的方式,而不是写死高度:
const menuButton = wx.getMenuButtonBoundingClientRect(); const windowInfo = wx.getWindowInfo(); const statusBarHeight = windowInfo.statusBarHeight; const navBarHeight = (menuButton.top - statusBarHeight) * 2 + menuButton.height;然后在设置导航栏容器样式时用上这些值:
.nav-bar { padding-top: calc(var(--status-bar-height) + var(--nav-bar-height) / 2); }我在app.js的globalData里存储statusBarHeight和navBarHeight,所有页面在onLoad里直接读取,不用每个页面重复计算。真机测试时,尤其要在 iPhone X、iPhone 12、iPhone 14 Pro 上都看一眼,顶部千万不要做固定高度,否则一定会有人反馈被遮挡。
4.3 预约页面:日期、教练和时段列表的联动
预约页是用户量最大的页面,体验好坏直接影响整个系统的可用性。我的页面结构分三块:顶部日期选择器、教练横向列表、当日时段网格。
日期选择器用小程序原生picker的mode="date",并且设置start为今天。但驾校预约通常只看未来几天,不需要看历史日期,所以我把可选日期过滤为[今天, 今天+6天]。
当天选好日期和教练后,页面请求:
GET /api/schedule/list?coachId=3&date=2025-06-20后端返回当天所有时段,以及每个时段的状态。小程序端用不同颜色区分状态:灰色已约满、橙色待开始、绿色可预约。点击可预约时段,会弹出确认信息,包括教练、日期、时间、费用/课时,用户确认后调用提交预约接口。
提交按钮必须加防重点击处理,否则用户双击,就可能发出两个一模一样请求,后端关系不大但体验很差。我在小程序端用一个submitting标志位,请求期间置为 true,结束后再放开。
4.4 开发期接口联调:本地调试与真机预览的差异
开发阶段最容易卡住的是接口请求域名问题。微信开发者工具里,本地请求http://localhost:8080默认是过不去的,需要在“详情-本地设置”里勾选“不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书”。
但到了真机预览,这个勾选不再生效,后端必须使用 HTTPS 域名,并且在小程序后台配置 request 合法域名。如果你自己还没买域名,可以先把后端跑在内网,用开发者工具联调;要做真机演示,建议提前准备一台有公网 IP 的云服务器,部署 SpringBoot 后用 Nginx 反代,配上 SSL 证书。没有域名和证书,真机小程序根本调不通,这不是代码问题,是平台限制。
5. 部署上线前必须处理的数据与权限坑
5.1 SpringBoot 版本和 JDK 版本不匹配,是最早的坑
这个坑我真的很想重点讲。现在网上很多案例直接给 SpringBoot 3.x + JDK 17 的配置,但很多同学本机是 JDK 8,拷贝下来就报错。
经验之谈:如果你只是做驾校预约这种 CRUD 为主的管理系统,不用纠结新版本特性,直接选 SpringBoot 2.7.18 + JDK 8,最稳定。整个项目里没有任何地方需要 Java 17 的新语法。等到以后确实需要虚拟线程、GraalVM 这类新能力,再考虑升级到 3.x。
另外,Spring Boot 2.7 里spring-boot-starter-web用的是 Spring MVC 5,MyBatis-Plus 3.5.3 兼容性很好,网上搜“MyBatis-Plus 条件构造器”全是这套组合的答案,不容易卡住。
5.2 数据访问层的时区与字段自动填充
数据访问层的坑,最常见的是“时间差了 8 小时”。MySQL 连接串里必须加上时区参数:
spring: datasource: url: jdbc:mysql://localhost:3306/driving_school?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai如果不加,Jackson 序列化LocalDateTime时也经常出现时间偏移。我在后端统一配置了全局时间格式:
@Bean public Jackson2ObjectMapperBuilderCustomizer JacksonCustomizer() { return builder -> builder.serializerByType(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); }这样小程序端拿到的所有时间字段都是字符串,不需要自己做时间格式转换。
字段自动填充上,我用了 MyBatis-Plus 的MetaObjectHandler,给create_time、update_time统一赋值。不要在每个 service 里手动 set 时间,否则写多了必然漏掉一两张表,排查起来很麻烦。
5.3 预约重复提交与幂等处理
即使后端有FOR UPDATE防并发,但业务上还有一个场景很难防:学员点了预约,支付完成或课时扣减后,前端因为网络问题没有收到返回结果,用户又点了一下“提交预约”。如果后端没有幂等处理,就会生成两条预约记录。
我的做法是在前端生成一个requestId,也就是业务请求唯一标识,后端在 Redis 里用SETNX做幂等:
String key = "appointment:req:" + requestId; Boolean first = redisTemplate.opsForValue().setIfAbsent(key, "1", 5, TimeUnit.MINUTES); if (!first) { throw new BusinessException("请勿重复提交"); }如果请求第一次成功执行,这个 requestId 在 5 分钟内不会再有第二次入口。数据库层再用appointment_no唯一索引兜底,双保险。这个方案非常简单,但能挡住大部分重复提交问题,测试时用真机连续点五次按钮,验证最终只生成一条预约记录,就能确认幂等逻辑生效。
5.4 小程序包体积超限与分包加载
最后说一个小程序端的硬性限制:原生小程序单个包体积不能超过 2MB,超过之后上传时直接报错。我在项目里引入了 Vant Weapp 组件库,又放了几张尺寸很大的教练照片,很快就踩到了“主包超限”的问题。
解决方案有几个:
- 把 Vant Weapp 按需引入,不要一次性
usingComponents注册全部组件; - 图片不要直接放本地,统一用外链图床或云存储;
- 使用小程序分包:登录页、首页、预约页放主包,教练详情、历史记录、个人中心放入
subPackages。
分包加载后,主包控制在 1.5MB 以内,上传一次通过。这个经验也适用于其他小程序项目,不只是驾校系统。
做完这个项目,我自己最大的体会是:版本选型不要太激进,设计阶段把状态机画清楚比着急写代码重要得多,并发控制一定要考虑“两个用户同时抢一个资源”的场景。微信小程序端的问题大多不是逻辑复杂,而是兼容性和平台规范,每次改动后都要真机跑一遍。如果你也在做类似的预约类系统,先把这三个点盯住,整个项目会顺利很多。后面我还计划在这个项目里加入考试预约、教练评价、课时统计报表这些模块,预约核心逻辑已经跑通,往上加东西就只是时间问题了。