1. 项目概述与整体架构
我接手这个驾校预约学习系统的源码项目时,第一反应是这玩意儿看着不大,实际上业务链路并不短。表面上是“约课-上课-消课”三个动作,但背后牵扯到教练排课、学员账号体系、课程状态流转、计时规则、支付对接,翻来覆去全是细节。传统驾校的约课方式大多还停留在电话预约或者驾校前台手动登记,碰上高峰期,一个学员一天给教练打七八个电话,教练还得翻本子记时间,最后对不上账是常有的事。所以这类系统的核心价值就一句话:把“人的时间”变成“可被查询、可被占用的资源”,让教练的时间透明化,让学员的预约在线化。
整个系统基于SpringBoot2 + Vue3 + MyBatis-Plus + MySQL8.0这套组合来构建,前后端完全分离。选择这个技术栈不是图它花哨,而是这三件事很关键:第一,SpringBoot2的生态成熟,接手的人多,遇到问题网上能查到的资料多;第二,MyBatis-Plus能把单表CRUD的样板代码砍掉一大半,开发效率提升非常明显;第三,Vue3的组合式API配合Vite,开发体验和运行性能都比Vue2时代上了一个台阶。MySQL8.0则是刚好匹配当前主流部署环境,自带窗口函数、更好的JSON支持,事务隔离级别也稳定。
从业务模块角度看,系统可以拆成四个大块:
- 学员端小程序/Web页面:注册登录、浏览教练、查看可约时段、提交预约、取消预约、查看历史记录
- 教练端:设置可授课时间、查看今日课程、确认课程开始/结束、查看学员评价
- 管理后台:教练管理、车辆管理、课程审核、学时统计、财务报表
- 公共模块:短信通知、支付回调、节假日设置、系统配置
我拿到源码后第一件事不是急着跑起来,而是先看数据库脚本,把所有表捋了一遍。这套系统的表结构设计有值得借鉴的地方,也有明显可以优化的点,后面我会逐一拆开来讲。整体来说,它的实体关系和业务状态流转设计得比较完整,适合作为教学案例,也适合直接在此基础上做二次开发。
这套源码适合什么人看?我觉得有三类人可以参考:一是正在做毕业设计或课程设计的Java Web学习者,可以拿它作为完整项目的样板;二是驾培行业相关的技术人员,需要快速搭建预约系统的MVP版本;三是想了解前后端分离项目工程化的初级开发者,看清楚一个真实业务系统是怎么组织代码的。如果你只是想跑通一个Demo,那照着步骤来大约一小时就能起来;如果你想把它真正用在生产环境,那还需要把权限、支付、消息推送这些模块做加固,后面我都会提到。
2. 数据库设计与表结构拆解
数据库是整个预约系统最核心的底子。我原以为这类系统表不会太多,结果看完SQL脚本发现一共十几张表,仔细理了一遍,发现设计者确实把业务的几个关键维度都考虑到了:人、车、时段、订单、流转记录。
2.1 核心表梳理
先说人,涉及到三张表:学员表、教练表、管理员表。学员表和教练表本质上都是用户,但字段差异比较大,设计者选择分开建表而不是用一张user表加角色字段。这个选择我认为在实际业务里是对的。学员需要存学时、进度、驾校报名编号;教练需要存驾驶证号、教龄、准教车型、评分、状态。如果强行合并成一张表,要么字段冗余严重,要么得用大量的可空字段,查询时还得各种判空,反而别扭。不过更好的做法是做一个base_user表存公共字段,再分别做student和coach扩展表,这样登录认证时只查base_user,取业务数据时再关联扩展表,两头都能兼顾。
再说车,车辆表和车型表分开设计。车型决定了哪些教练能开、哪些学员能学,因为在C1和C2的区别下,教学内容和考试项目都不一样。车辆表里记录了车牌号、车型ID、状态(空闲/使用中/维修)、上次保养时间,这些字段对于后续做车辆调度和保养提醒非常有用。还有些细节,比如车辆跟教练的绑定关系是放在车辆表里直接存coach_id的,还是单独搞一张关联表,这取决于业务流程是“固定车配固定教练”还是“每次临时分配”。这套源码用的是前者,驾校场景里确实常见这种固定搭配。
时段表是预约系统能跑起来的关键,我单独把它拎出来讲。这张表存的是教练的可约时间片段,每个片段包含教练ID、日期、开始时间、结束时间、状态。状态字段很关键,它不只是“可约/不可约”两个值,我看到的源码中至少包含:开放、已预约、锁定、已完成、已取消。这里有个设计经验:时段表不要只存预约成功的数据,而应该在教练配置排班的时候就把时段铺好,用状态来标记当前的占用情况。这样做的好处是,学员端查询日历的时候可以直接查这个表,不需要再去和订单表做复杂的联合判断,性能上友好很多。
2.2 预约主表与状态流转
预约记录表是整个业务的主表,字段包括了预约编号、学员ID、教练ID、时段ID、课程类型、状态、费用、创建时间等。预约编号建议用业务规则生成,比如日期+教练ID+随机数,这样对账的时候人眼可读,而不是直接用自增ID。
状态的流转需要重点设计。我总结这套系统的状态机大致是这样的:
- 待支付(下单后未支付)
- 已支付(支付成功,等待上课)
- 已取消(学员或教练主动取消)
- 已完成(教练确认课程结束)
- 已过期(超过预约时间未上课)
- 已退款(取消后原路退回)
状态机设计得好不好,直接决定后期业务扩展的复杂度。比如“学员迟到15分钟后是否允许变成过期”这类规则,如果状态机里没有预设,就得靠定时任务扫描订单,这时候状态字段设计得是否干净就很关键。我的建议是:状态字段的值保持原子性,一个状态只表达一个意思,不要搞“已完成且已评价”这种复合状态。评价状态单独用字段标记,或者单独建一张评价表,都行,但不能把多个维度塞进一个字段里。
2.3 表结构设计的可优化点
虽然这套源码的表设计整体可用,但有几个地方我实际用下来觉得可以调整:
第一,缺少学时流水表。学员从报名到拿证,学时记录应该是一张独立的流水表,每次课程完成后插入一条记录,包含学员ID、教练ID、课程类型、学时数、完成时间。现在这个系统把学时累加值直接存在学员表里,数据量小的时候没问题,但一旦出现争议需要追溯明细,没有流水表会非常被动。驾校行业合规检查的时候经常要查学时记录,没有流水表就是个明显的坑。
第二,教练评价表与订单表的关系是靠订单ID关联的,这个没问题,但评价表缺少“匿名标志”字段。实际业务中,不少学员有心理顾虑,不敢给教练打低分,怕被穿小鞋。支持匿名评价能在一定程度上提高评价的真实性。
第三,缺少审计日志表。谁在什么时间改了哪个教练的排班,谁把某个时段的预约强制取消了——这些操作在正式环境里都是需要留痕的。源码里有操作日志的接口,但实现得比较简陋,只记录了操作人和操作时间,没有记录操作前后的数据快照。要上线生产环境,建议加一张审计表或者引入现成的审计框架。
关于索引设计,预约记录表里高频查询条件是学员ID+日期范围,建议建联合索引;时段表的查询条件是教练ID+日期+状态,也要建联合索引。MySQL8.0支持函数索引和降序索引,如果你需要按日期倒序查最近记录,可以适当用上,但别滥用,索引不是越多越好,写入性能也要考虑。
3. 后端核心实现思路
后端代码整体基于SpringBoot2构建,包结构是标准的controller/service/mapper/entity四层,在这个基础上加了config、common、utils这几个辅助包。整体风格清晰,没有那种单个类几千行的坏味道,适合阅读和学习。
3.1 为什么用MyBatis-Plus而不是原生MyBatis
这个项目用MyBatis-Plus替代了大部分手写SQL,这是我很认可的一个取舍。以驾校预约系统中的“分页查询学员约课记录”为例,如果是原生MyBatis,你得自己写select * from reservation where student_id = #{id} order by create_time desc limit #{offset}, #{size},然后还要自己写count查询,还要处理page参数传递。用MyBatis-Plus,直接写:
LambdaQueryWrapper<Reservation> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Reservation::getStudentId, studentId) .orderByDesc(Reservation::getCreateTime); Page<Reservation> page = reservationMapper.selectPage(new Page<>(pageNum, pageSize), wrapper);两行代码搞定,而且类型安全的写法不容易写错字段名。但要注意,MyBatis-Plus方便归方便,它默认的逻辑删除、自动填充这些功能需要知道怎么配。逻辑删除字段默认是deleted,如果你表里的字段名不叫这个,需要显式声明。这个项目里就踩过这个坑,后面问题排查部分我会细说。
对于复杂的多表关联查询,比如“查询某教练未来七天的预约情况,同时带出学员姓名和课程名称”,这种情况就不适合硬用MyBatis-Plus的wrapper硬拼,应该直接在Mapper里写一个@Select注解或者XML文件里的自定义SQL,用JOIN把结果集拼出来。我给这个系统补充这类查询时,就是直接在XML里写SQL的。实践原则很简单:单表操作交给MyBatis-Plus,多表复杂查询用手写SQL,各干各擅长的事。
3.2 SpringBoot的事务与并发预约处理
预订系统并发问题的核心是:一个教练在某天的特定时段,只能被一个学员约到。如果同时有两个学员刚好都在抢同一个时段,数据库层面必须保证不会出现“两个人都约成功”的情况。这靠的不只是前端按钮置灰,后端要兜住。
我看了源码的实现,预约时段的插入顺序是:先查时段状态,如果状态是“开放”,再更新状态为“已预约”,然后插入预约记录。这里有一个经典的并发bug:两个事务同时查到状态都是“开放”,然后都去更新,都能更新成功吗?在MySQL默认的RR隔离级别下,如果使用的UPDATE是update schedule set status = 'booked' where id = ? and status = 'open'这种带条件的更新,那么只有第一个事务能拿到行锁,第二个事务会被阻塞,等第一个事务提交后再检查where条件,发现status已经不是open了,影响行数为0,就不会执行后续插入。所以核心SQL语句必须写成“条件更新”,而不是先查后更新分开操作。
我给这套系统做加固时,在预约记录插入前加了一个分布式锁(基于Redis),用教练ID加时段ID做锁key,防止同一时间点两个请求都走到数据库层。对于单体应用来说,这样处理已经足够了。另外还要注意的一点是,更新时段状态和插入预约记录必须在同一个事务里,否则可能出现预约记录插进去了,但时段状态还是开放的,后续其他人还能再约这个时段,数据就全乱了。看源码时我特意确认了这两个操作在同一个service方法里,并且加了@Transactional注解,这块处理是到位的。
3.3 登录鉴权方案与密码安全
系统采用JWT做无状态鉴权。用户登录成功后,后端生成一个包含用户ID和角色信息的token,前端存储在localStorage,每次请求在http header里带上Authorization: Bearer <token>即可。
这里有个容易被忽略的细节:JWT的secret不能写死在代码里,必须放在配置文件里,并且生产环境要用环境变量注入。源码里是用application.yml配置的,还做了默认值兜底,这算及格。但如果你要部署到生产,建议改成从环境变量读取,比如${JWT_SECRET},这样即使代码仓库泄露,token也不会被人伪造。
密码存储方面,源码用的是MD5加盐。说句实在话,MD5在现代计算能力面前已经不太够看,推荐至少升级到SHA-256加盐,或者直接用BCrypt。Spring Security的BCryptPasswordEncoder用起来非常简单,改造成本很低,安全性提升却是质变。如果项目只是课程设计或内部系统,MD5加盐还能接受;真要对外开放,建议直接换BCrypt,这才是对用户负责的处理方式。
3.4 定时任务与过期订单处理
预约场景里有一些动作不需要用户主动触发,而是系统在后台自动完成的。最典型的是待支付订单超时关闭:学员下单后30分钟内不支付,订单自动取消,对应的时段自动释放。这套源码用Spring的@Scheduled注解实现了一个简单定时任务,每分钟扫描一次待支付订单,超时的就做状态迁移。
这里有个经验想分享:定时任务扫描订单时,不要一条SQL把所有超时订单查出来然后循环处理,会有性能问题。更好的做法是用一条UPDATE语句直接批量更新状态:
UPDATE reservation SET status = 'cancelled', cancel_reason = 'timeout' WHERE status = 'pending_payment' AND create_time < DATE_SUB(NOW(), INTERVAL 30 MINUTE)然后再单独查出来做后续动作,比如通知学员、释放时段。批量更新加事务控制,既高效又不容易出并发问题。如果后续系统要部署在多实例上,记得把@Scheduled加上分布式任务锁,不然多个实例同时跑定时任务,会造成重复处理。比较轻量的方案是用ShedLock,或者直接在定时任务里用Redis的setnx做一个互斥标记。
4. 前端Vue3实现与交互细节
前端部分用Vue3 + Vite + Pinia + Element Plus,整体架构是标准的单页应用。Vite带来的开发体验提升非常明显,热更新几乎是无感的;Element Plus提供了一套风格统一的基础组件,让开发者可以集中精力写业务逻辑,而不是去调各种UI细节。
4.1 项目结构与组合式API组织方式
看源码的目录结构,前端分为views、components、api、store、router、utils这几个模块。views下面按业务模块分组:student、coach、admin三个目录,分工明确。有个细节做得不错:api目录下每个业务模块对应一个JS文件,逐个导出该模块的接口请求函数。这样的好处是views里的组件不需要直接写axios调用,而是统一调用api模块的函数,接口地址和参数集中管理,后端接口变了,只改一处就行。
Vue3的使用上,源码全面采用了组合式API。以学员预约页为例,页面逻辑大致是这样的:
const { proxy } = getCurrentInstance() const loading = ref(false) const coachList = ref([]) const selectedCoach = ref(null) const weekDates = ref([]) const scheduleMap = ref({}) const loadCoaches = async () => { loading.value = true try { const res = await api.getCoachList({ status: 1 }) coachList.value = res.data } finally { loading.value = false } } const loadSchedule = async (coachId, date) => { const res = await api.getCoachSchedule({ coachId, date }) scheduleMap.value = res.data }这里用ref管理响应式数据,用函数抽离业务逻辑,比选项式API的this满天飞清爽太多。新手朋友可能觉得ref、reactive、computed这些API容易搞混,我自己的经验是:基础类型用ref,对象类型可以用reactive也可以统一用ref,在script setup里template中ref是不需要写.value的,但在script逻辑里一定要写。这个细节是初学者踩坑最多的地方。
4.2 状态管理与路由权限控制
Pinia作为状态管理框架,在Vue3项目里已经是事实标准了。这套系统的store里主要存了用户信息、token、角色、菜单权限。页面刷新的时候会丢store里的数据,所以需要在router.beforeEach里做一次用户信息回填,从localStorage读取token,再调用getUserInfo接口恢复登录状态。
路由权限控制这里,源码采用的方式是:前端在meta里写上需要的角色,然后在路由守卫里判断当前用户角色是否匹配。这种方案实现简单,但并非安全边界。真正的权限校验在后端接口也要做一遍,前端只是控制页面入口展示,后端必须保证即使有人绕过前端直接调用接口,也能正确校验权限。我用这套源码时就特意检查过后端的拦截器,确认它针对admin接口和普通用户接口做了权限区分,这个意识是对的。
另外,Vue3项目做动态路由是一个常见的难点。如果你想在登录后根据后台返回的权限动态注册路由,需要调用router.addRoute(),并且要注意在退出登录时重置路由,否则用户角色切换后路由是累加的,可能出现权限残留。源码里没有做动态路由,而是把所有路由静态注册完,再靠meta的角色控制来拦,整体简单够用,但多租户场景下建议升级为动态路由方案。
4.3 日历组件与时段选择交互
预约系统最核心的交互是时段选择,这个交互设计直接影响用户体验和约课成功率。源码里的实现是用Element Plus的Calendar组件改造的,加上自定义的slot来渲染每个时段的预约状态,点击时段后弹窗确认预约信息。
这里我想分享一个自己踩过的经验:时段的展示和点击需要有明确的视觉状态区分。灰色代表不可约/已过期,绿色代表可约,橙色代表已被别人约了,蓝色代表我自己约的。颜色区分之外,最好再在tooltip里显示时段的具体时间范围。用户刚上手时,对于“上午9点到10点”这种时间段到底是几点开始几点结束,往往需要反复确认,在界面上直接写明就少了很多困惑。
还有个小细节是时区问题。前端传日期字符串时一定要格式化好,比如2025-01-15,不要直接传new Date()给后端,否则很容易出现时区偏移导致日期差一天的情况。我帮别人排查过几次这类问题,最后发现都是前后端日期格式没统一。建议前端全部用字符串格式传输日期,后端用@DateTimeFormat注解或全局的Jackson配置统一解析,尽量避免直接传递时间戳。
4.4 接口请求封装与错误处理
前端axios请求封装这块,源码里做了一个request工具类,统一设置了baseURL、超时时间、请求拦截器和响应拦截器。请求拦截器里做token注入,响应拦截器里做统一错误处理:401跳登录页,403提示无权限,500弹出错误信息。
这里有一个非常容易踩的坑:响应拦截器做了统一错误提示以后,业务代码里又做了一遍提示,结果一个错误弹两个toast,体验很差。我的习惯是约定:拦截器只处理网络级错误和未登录这类全局问题,业务级的错误码在业务代码里处理;或者反过来,拦截器统一处理但让业务代码通过返回状态判断是否需要额外提示。不管怎么约定,团队内部必须写清楚,否则就会出现这种重复提示的劣化体验。源码在这个问题上处理得还算干净,我看它拦截器只弹了全局错误,业务层的错误都通过返回的{ code, message, data }结构让页面自己处理了,这个模式值得学习。
5. 系统部署与实操过程
这个项目从源码到跑起来,看着步骤不多,但有几个配置细节对不上就会白忙活一场。我完整跑了一遍部署流程,把每一步的关键操作和容易出错的地方记录下来。
5.1 环境准备与版本注意事项
必装环境:JDK8及以上(SpringBoot2.5+推荐JDK8或JDK11)、Maven3.6+、Node.js 14+(Vite3需要14.18以上,Vite4要求16+,建议直接用18版本)、MySQL8.0+、Redis(如果启用分布式锁和缓存)。
版本匹配问题重点关注这几个:
- SpringBoot2.x和JDK的兼容性:SpringBoot2.7官方支持到JDK17,低于2.5的版本用JDK17可能出现字节码兼容问题。为保险起见,直接用JDK8或者JDK11最稳。
- Node版本和Vite的版本:Vite3跑在Node14.18以上没问题,如果你环境装的是Vite5,那Node版本就要求更高。不要盲目升级前端依赖版本,能跑就不要乱动。
- MyBatis-Plus版本和SpringBoot2的适配:尽量用MyBatis-Plus 3.5.x对应SpringBoot2的分支版本,不要用为SpringBoot3设计的版本,否则starter的自动装配会失效。
5.2 MySQL初始化与常见配置坑
拿到源码后,先在MySQL里创建数据库:
CREATE DATABASE IF NOT EXISTS driving_school DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;然后执行源码里的sql/init.sql脚本。MySQL8.0执行SQL脚本时有个常见坑:脚本里如果用了CREATE TABLE IF NOT EXISTS,重复执行虽然不报错,但不会更新表结构;如果脚本里有数据插入,重复执行会插入重复数据。建议每次初始化都先DROP DATABASE再重建,避免残留数据影响业务验证。
接着修改后端的application.yml配置文件:
spring: datasource: url: jdbc:mysql://localhost:3306/driving_school?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: yourpassword这里两个参数是必须的:serverTimezone=Asia/Shanghai解决数据库时区与系统时间不一致导致时间字段差8小时的问题;allowPublicKeyRetrieval=true解决MySQL8.0的caching_sha2_password认证插件在某些驱动版本下连接报错的问题。这两个参数我写上以后基本能避开80%的连库问题。
5.3 前端运行与接口代理配置
前端启动前,先执行依赖安装:
npm install npm run dev默认端口一般是3000或5173(Vite的默认端口)。前后端分离开发时需要通过配置代理解决跨域问题,在vite.config.js里配置:
export default defineConfig({ server: { port: 3000, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })配置这个代理后,前端的/api开头的请求会转发到后端8080端口,开发环境下就不用让后端额外开启CORS了。生产部署时,通常用Nginx做反向代理,把前端的静态文件和/api接口放在同一个域名下,同样不需要后端开跨域。生产环境禁用后端CORS,这是一个基本的安全习惯,尽量让前后端通过域名传递或者代理转发解决跨域,不要让后端对任意来源开放接口调用。
5.4 打包构建与生产部署
前端构建:
npm run build构建产物在dist目录。把dist下的文件放到Nginx的html目录,并在Nginx配置里加上:
location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Host $host; }后端打包:
mvn clean package -DskipTests生成的jar包在target目录下,用java -jar启动即可。需要后台运行的话,推荐用systemd服务或者直接用nohup java -jar xxx.jar > app.log 2>&1 &。实际部署中我还会给JVM设置内存参数,比如-Xms512m -Xmx1024m,防止在内存较小的服务器上因为堆内存设置过大导致频繁GC,或者设置过小导致OOM。
生产环境建议多关注一下日志配置。源码里默认用的是logback,输出到控制台,这不行。至少要做到:分级输出到文件、按天滚动、保留最近30天、关键业务(登录、支付、预约)单独输出到一个业务日志文件。排错的时候你会发现,日志的价值不亚于代码本身。
6. 常见问题与排查技巧实录
我把实际运行这套系统过程中遇到的高频问题整理成一张速查表,这些问题初学者几乎都会碰到,提前知道怎么排查能省不少时间。
6.1 MyBatis-Plus的逻辑删除与字段自动填充问题
逻辑删除是MyBatis-Plus的一个经典坑。如果你在实体类的deleted字段上加了@TableLogic注解,那么默认所有查询会自动加上deleted = 0的条件。这个功能方便,但很容易让人困惑:为什么我明明在数据库里看到有记录,查询结果却是空的?我遇到的一个实际问题是,数据库里历史数据没有初始化deleted字段,默认值是NULL,而MyBatis-Plus生成的SQL是deleted = 0,NULL和0比较结果为NULL,条件不成立,所以数据永远查不出来。解决方案是先把历史数据的deleted字段统一刷成0。
字段自动填充方面,MyBatis-Plus可以通过MetaObjectHandler实现创建时间和更新时间的自动填充。源码里定义了handler,也在实体类上加了@TableField(fill = FieldFill.INSERT)注解,但如果你修改表结构时漏掉了时间字段的默认值,插入时可能会报时间字段为空的错误。排查技巧是:看SQL日志中插入语句是否带有这些字段值,MyBatis-Plus的自动填充是拦截insert操作填充的,不是数据库层面的默认值,所以两者都不配就会出问题。
6.2 数据库连接报公钥检索错误
“Public Key Retrieval is not allowed”是MySQL8.0连接时最经典的报错。解决办法就是在JDBC URL后面加allowPublicKeyRetrieval=true。这个参数的含义是允许客户端向服务端请求公钥,用于caching_sha2_password认证方式的数据传输加密。如果用的是connect_timeout比较小的客户端,公钥请求过程中可能超时,也可以顺带把connectTimeout调大一些。
顺带说一下MySQL8的认证方式变更。MySQL8默认用户认证插件是caching_sha2_password,老版本的MySQL驱动(5.1.x)不支持这个插件,连接时报错或者发出警告说你正在使用不安全的认证。解决方案就两个:升级mysql-connector-java到8.0.x版本,或者把MySQL用户的认证插件改成mysql_native_password。推荐前者,改认证插件只是临时解法,迟早还得升级驱动。
6.3 Vue3响应式数据不更新的情况
Vue3的响应式是基于Proxy实现的,绝大多数情况下的数据变更都能被捕获,但有几种情况很隐蔽。比如用ref定义数组,然后通过下标直接修改:arr[0] = xxx,这种写法在Vue3里是响应式的,Proxy能拦截到,但如果你用的是普通对象而不是reactive/ref包裹,那就不行。再比如给reactive对象新增一个之前不存在的属性,这在Vue2时代是大坑,但Vue3的Proxy可以正确监听,所以如果你发现新增属性后页面没更新,优先排查是不是用未经响应式转换的对象改了数据。
另外一个常见问题是在setTimeout或接口回调里修改状态,页面不更新。这通常不是Vue3的问题,而是代码里引用的this指向变了,或者在使用组合式API时忘记调用proxy.$forceUpdate()——其实不用forceUpdate,只要数据本身是响应式的,修改时就会自动触发更新。排查时要先确认数据真的变了,还是在渲染函数里引用了错误的数据源。
6.4 JWT过期与用户身份丢失
这个系统里,JWT过期后前端会收到401错误,然后跳转登录页重新登录。这里有个体验问题:如果用户正在填写一个复杂的预约表单,突然被踢去登录页,填到一半的数据全丢了。更好的做法是:前端在axios响应拦截器里检测到401时,先尝试调用刷新token的接口,通过refresh token换取新的access token,然后重放原请求。不过刷新token机制会引入refresh token的安全管理问题,如果只是内部系统,简单跳登录页也能接受。源码没有实现refresh token,属于可以接受但不完善的设计。
如果出现登录后频繁掉线的情况,先查两个地方:JWT过期时间配置是否是够长,以及前后端传递token的header名称是否一致。有时候后端取的是Authorization头,前端请求拦截器写的是Token头,差一个单词,结果就是所有请求都401,看起来像token失效,实际是根本没传对位置。
6.5 跨域问题排查
开发模式下配置了代理后仍然报跨域,通常有两个原因:一是代理配置没生效,前端请求还是直接发到了别的地址;二是后端接口本身做了CORS配置,和前端的代理配置冲突。特别是在SpringBoot里同时用了@CrossOrigin注解和全局CORS配置时,重复配置可能导致一些奇怪的响应头问题。我的排查顺序是:先打开浏览器的Network面板,看请求的实际URL是否走了代理;再检查响应头里是否有Access-Control-Allow-Origin;再确认后端是否配置了allowedOriginPatterns,而不是直接用allowedOrigins("*")。实际生产环境里,建议后端关闭CORS支持,统一由Nginx或者网关层处理跨域,这样问题最容易收敛。
6.6 定时任务重复执行问题
如果项目部署了多个实例,定时任务会重复执行。我之前帮一个朋友排查过类似问题:系统里有一个每天晚上自动结算学时的任务,他部署了两台服务器,结果每天学时会多算一倍。原因就是两台服务器上的@Scheduled都会触发,各自处理了一遍数据。
轻量解决方案有两种。第一种是用Redis实现分布式锁,在任务执行开始时尝试set一个key带过期时间,成功获取锁的实例才执行任务。第二种是用ShedLock框架,它专门为Spring定时任务设计,基于数据库或Redis存储锁信息。代码侵入也不大,值得推荐。如果只是单机部署,就不用考虑这个问题,但提前写清楚这个隐患对以后扩展有好处。
7. 几个值得留意的设计教训与扩展方向
代码量不大,但把这个项目从头到尾过了一遍之后,我对预约类业务系统的理解更具体了。这里分享几个我自己的经验判断,未来不管是拿这套源码做二次开发,还是自己从零写一套类似系统,都能用得上。
第一点是并发控制一定要从数据层兜底。没有哪个团队能保证前端不出并发bug,也没有哪个分布式锁能做到100%可靠,最终防线永远是数据库的行锁和唯一约束。在这个项目里,我判断最需要增加唯一约束的地方是“学员同一天不允许重复预约同一个教练同一个时段”这类规则,可以在预约表上建唯一索引(student_id, schedule_id)。一旦约束建好,即使代码里忘了做并发判断,数据库也会拒绝重复订单。
第二点是状态字段只用数字枚举。源码里时段状态用的就是int,订单状态也是int,没直接用字符串。这种设计乍看不直观,但配合枚举类在代码里做映射,可维护性比字符串强很多。字符串虽然人眼可读,但拼写错误、多义词、大小写不一致都是隐形炸弹。数字枚举配合统一的状态转换工具类,是我比较推荐的模式。
第三点是业务日志和操作审计不能省。预约系统是个强流程系统,中间环节特别多。谁在某天把某个教练的可约时段锁了,谁取消了学员的预约,这类操作如果不记录,出了问题就只能翻数据库binlog,费时费力。建议在关键操作的service方法上增加操作日志注解,把操作人、操作对象、操作前后的状态都记录下来,日志表和业务表用一个事务,这样业务成功提交的同时日志一定也落库了。
扩展方向上,我建议优先补两块:一是消息通知模块,对接短信服务,预约成功、课程提醒、取消通知自动触发,这块能明显提升系统的实用感;二是支付对账,系统里订单和支付结果的一致性靠定时任务核对,可以做一个对账页面,每天手动或自动核对一次订单和资金流水,不怕一万就怕万一。
跑通这套源码最大的好处是你能在较短时间内看到一整套前后端分离的业务系统长什么样。懂了一个真实的业务闭环之后,再去看那些单点教程,比如SpringBoot的某个注解、Vue3的某个API,都会有“原来是这么用的”这种感觉。我个人习惯是拿到别人的代码先做“破坏性实验”:改一个字段类型看哪里报错,把一条SQL去掉索引看慢多少,模拟两个同时预约的请求看数据库怎么兜底。这套系统数据量不大,跑实验成本低,太适合用来验证和深化理解了。