做汽车维修预约系统这个项目,我一开始是有点犹豫的。市面上这类SaaS产品不少,但真要贴合门店实际运营流程,还是得自己动手。Spring Boot那一套我已经用了好几年,但这次从零搭一个带多角色、预约档期、状态流转的完整系统,还是踩了不少很实在的坑。这篇就把整个开发过程拆开聊,从表结构到并发控制,从前端联调到上线部署,全部是这次项目里真实跑过的方案。
如果你的目标也是用Spring Boot快速搭一个维修预约类系统,这篇文章可以帮你绕过很多弯路。我会说明每一步为什么这么做,也会把那些文档里查不到的细节和常见故障点写出来,方便直接照抄。
1. 项目从哪来:维修预约系统的痛点与边界
1.1 传统维修门店的预约混乱问题
维修店的实际场景其实比想象中复杂。电话预约靠前台手记,微信聊天记录一多就容易漏,到店后工位安排全凭默契。客户约了上午十点,结果前一个车还没修完,后一个已经到了,排队体验极差。门店技师的时间没法精确管理,材料准备也摸不着头脑。
这个系统的核心目标就是解决三个问题:把预约入口从电话/微信搬到线上,让客户自己选时段;把技师和工位资源纳入排程,避免超卖;把接车、维修、完工、结算的流程状态统一管理起来。听起来不复杂,但真正落地设计时,业务状态和角色权限会牵扯出很多细节。
1.2 系统角色与功能边界
我最后确定了三类角色,没有继续细分更复杂的加盟商体系:
- 车主端(微信浏览器或App内网页):注册登录、绑定车辆、选择服务项目、预约时段、查看预约记录、取消预约、评价。
- 门店端(技师/接待员/店长):查看预约列表、接单/拒单、设置可预约时段和工位、维护车辆维修记录、录入工单状态。
- 管理端(老板/管理员):查看经营统计、管理员工账号、维护服务项目价格、配置系统参数。
功能边界划定得比较克制。没有做在线支付和配件商城,原因是预约环节的核心是时间与资源的匹配,支付放到到店环节反而更简单。押金模式虽然能减少爽约,但涉及退款流程,初期版本果断舍弃了。这个取舍在后来的开发中帮了大忙,让团队把精力集中在业务主链路上。
1.3 “11793”编号的意义
项目编号11793是内部提的一个需求单号,代表一期版本的功能范围。做这类系统我建议从一开始就建立需求版本管理,哪怕用Excel记录也好。后续每加一个功能点,对照编号就能知道是哪一期需求引入的,排查线上问题时会少很多心智负担。
2. 技术选型:为什么是Spring Boot + Vue,而不是别的组合
2.1 后端框架的取舍
这个项目后端直接选了Spring Boot,但我想说清楚为什么不是Spring Cloud,也不是SSH老框架。维修预约系统的核心是单机事务、业务状态管理和接口响应速度,并没有高并发和分布式事务的需求。Spring Boot自带的Tomcat、Spring MVC、Spring Data JPA/MyBatis一整套生态足够覆盖,开发效率也最高。
热点数据用Redis做缓存,定时任务用Spring自带的@Scheduled,权限用JWT,不需要引入重量级的微服务组件。如果你有消息推送、异步任务的需求,Spring Boot整合ActiveMQ或RocketMQ都有现成starter,但前期一个都用不上时就不要加。减少依赖是我这次最深的体会,每多一个中间件,运维和排障成本都会翻倍。
2.2 前端与服务端渲染的选择
前端用了Vue 2 + Element UI,构建后打成静态包丢进Spring Boot的resources/static目录。这个方案很适合中小型内部系统,不用单独部署Nginx,也省了跨域问题。因为主要使用场景是手机浏览器和后台管理页面,就没有上重型的前端工程化框架,Vue CLI脚手架配合vue-router、vuex足够支撑。
有一段时间我试过用Thymeleaf做服务端渲染,但预约系统的交互状态太多:选时间、切换车辆、动态计算金额,服务端渲染会导致页面刷新频繁,体验很差。后来又改回前后端分离,维护起来思路清楚很多。Vue打包后放进Spring Boot这个操作,也是很多新手问得比较多的问题,我在第7节专门写一下。
2.3 数据库与持久层方案
数据库选了MySQL 8,持久层用MyBatis-Plus。为什么不选JPA?我的理由是维修预约系统有不少复杂查询,比如按时间段统计工位使用率、多表关联查询预约详情、动态条件拼接筛选列表。MyBatis-Plus在控制SQL方面更直接,分页插件也很好用。
表结构设计上,主键统一用雪花ID。因为将来数据量上来可能要分库分表,雪花ID比自增主键更好迁移。当然,现在的数据量用自增也没问题,但既然能一开始就避免潜在问题,为什么不呢。
3. 数据模型设计:把维修业务拆成可落地的表结构
3.1 用户与角色模型
用户表我不建议直接写死角色字段,而是设计了user和role两张基础表,再加user_role关联表。原因很简单:一个门店的接待员以后可能同时是技师,或者一个店长也要处理维修工单。多对多关系才是现实。
这里有一个容易被忽略的字段:status。账号锁定、停用、注销都靠它区分,千万别用delete_flag硬删除。我接手过一些老项目,删除用户直接把记录删掉,结果历史工单关联全断了,后来改成逻辑删除后,追溯才恢复正常。
密码存储用的BCrypt加密,Spring Security自带PasswordEncoder。有个小细节:BCrypt每次加密同一密码生成的hash不同,这是正常的,不要感到奇怪。
3.2 车辆与维修记录
车辆信息需要单独建表,一个用户可以绑定多辆车。字段包括车牌号、品牌型号、车架号(VIN)、里程数、上次保养时间、备注。在预约时,用户选择车辆后,后端自动带出车辆信息,减少重复输入。
维修记录表与车辆表是主从关系,每条维修记录关联vehicle_id和work_order_id。这里我坚持保留一个冗余字段license_plate在维修记录表上,每次查询列表时能少关联一次车辆表。虽然不符合严格的范式,但性能上确实值得,尤其是商家端查询“某辆车的历史维修记录”时快很多。
3.3 预约单与工单的状态流转
预约单(appointment)是核心表,字段包括预约编号、用户ID、车辆ID、门店ID、服务项目ID、技师ID、预约开始时间、预约结束时间、状态、备注、创建时间。
状态字段用String还是Integer?我用的Integer枚举值,并在代码里定义了常量类:
public class AppointmentStatus { public static final int PENDING = 1; // 待确认 public static final int CONFIRMED = 2; // 已确认 public static final int IN_SERVICE = 3; // 服务中 public static final int FINISHED = 4; // 已完成 public static final int CANCELED = 5; // 已取消 }工单(work_order)用来记录实际到店后的维修过程和费用明细,与预约单是一对一关系。预约单解决“客户什么时候要来”,工单解决“来了之后干了什么活、收了多少钱”。两者分开后,取消预约不会污染工单数据,统计也方便。
4. 预约核心逻辑:并发控制、档期管理和状态机
4.1 同一时间段多人预约怎么处理
这是预约系统最核心的问题。如果两个客户同时预约同一个技师的同一个时段,系统必须保证只有一个人能成功。方案看起来简单:预约前查询该时段是否被占用,然后插入预约记录。但这两步不是原子操作,并发时会产生脏读。
我查了不少资料,最常见的方案有三种:
- 数据库唯一约束:比如给appointment表加上(technician_id, start_time)唯一索引。
- 悲观锁:select for update锁定技师档期记录。
- 乐观锁:更新时带上版本号。
我选了唯一索引作为兜底,同时在应用层用Redis分布式锁做预检。原因是unique index最可靠,不管代码出了什么bug,数据库层面都能挡住重复数据。而Redis锁用来降低业务冲突导致的异常频率,让用户体验不至于频繁报重试。
核心代码如下:
// 使用Redis锁做预检 String lockKey = "appointment:lock:" + technicianId + ":" + startTime; boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(5)); if (!locked) { throw new BizException("该时段刚刚被约走,请重新选择"); } try { // 再次查库确认 int count = appointmentMapper.selectCount( new LambdaQueryWrapper<Appointment>() .eq(Appointment::getTechnicianId, technicianId) .eq(Appointment::getStartTime, startTime) .ne(Appointment::getStatus, AppointmentStatus.CANCELED)); if (count > 0) { throw new BizException("该时段已被预约"); } // 插入预约记录 appointmentMapper.insert(appointment); } finally { redisTemplate.delete(lockKey); }这样做还有一个额外收益:锁过期时间设为5秒,足够完成一次数据库查询和插入。如果业务在5秒内没执行完,锁会自动释放,下一次请求可能放进来。所以数据库唯一索引兜底必不可少。
4.2 技师档期与门店工位
技师和工位究竟哪个维度用来判断冲突?我最终选择了按技师维度做排程,工位只是辅助标记。原因是不同技师的专长不同,维修项目不同,工时也不同。两个技师可以同时接两个工位的活,但同一个技师没法分身。
不过有些项目会把“工位”作为独立资源,一个工位一天排多个技师轮班,那就需要再抽出一张schedule表专门管理。初期版本没有做轮班复杂的班次,预约表直接记录技师ID和时间段就满足了需求。如果你们店有多个工位且技师不固定,建议把工位表加进来,否则后面改起来很痛。
4.3 状态机与取消策略
预约状态的变化不是随意跳转的,我给每个角色定义了允许的操作,并写了一个状态校验器:
- 用户取消:只能在 PENDING 或 CONFIRMED 状态。
- 门店拒单:只能在 PENDING 状态。
- 开始服务:只有 CONFIRMED 状态。
- 完工:只有 IN_SERVICE 状态。
状态机的好处是,接口不会因为调用方传了一个奇怪的状态组合而破坏业务数据。实现上用Strategy模式管理每个操作的校验逻辑,后面再加“改期”操作时不会把代码越改越乱。
取消预约的另一个细节:是否释放档期。用户取消后,appointment记录保留并标记CANCELED,这样能保留记录用于分析爽约率。释放档期则通过定时任务把该时间段的剩余可约数加回去,也就是在Redis里维护一个可用库存计数。
5. 权限体系与安全控制:用户、技师、店长三种角色的边界
5.1 JWT认证流程
登录接口先校验用户名密码,成功生成一个JWT token,包含userId、roleId、storeId,然后返回给前端。之后所有需要认证的接口都在Header里带Authorization: Bearer token。
Spring Boot端我写了一个OncePerRequestFilter,解析token并注入到ThreadLocal中的UserContext。这里有个非常容易被忽视的问题:token里不要放过多信息,尤其是手机号、车牌号这种隐私数据。JWT的Payload只是Base64编码,并没有加密,虽然防篡改但防不了明文读取。
5.2 基于注解的权限校验
权限控制我用了自定义注解@RequireRole,配合Spring AOP实现。用起来大概这样:
@RequireRole(RoleType.STORE) @PostMapping("/appointment/start") public R startService(@RequestBody StartServiceRequest req) { ... }这样做比在方法里手写if判断清晰很多,尤其当系统角色超过三个之后。AOP切面里先判断用户角色是否匹配,不匹配直接抛403异常,由全局异常处理器转成统一返回结构。
5.3 数据越权防护
角色验证只是第一层,数据归属校验同样重要。比如店长只能看自己门店的预约单,技师只能操作指派给自己的工单。这个不能只靠前端隐藏按钮,后端查询时一定要带上storeId作为条件。
我在所有Mapper的查询参数里强制传入storeId,MyBatis-Plus的LambdaQueryWrapper如下:
LambdaQueryWrapper<Appointment> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Appointment::getStoreId, currentUser.getStoreId());有同学觉得这样写太啰嗦,直接用MyBatis拦截器统一拼接。我不建议,因为隐式SQL会让人排查问题时困惑,不如每个查询都显式写清楚。实测下来,显式传入条件并不会比自动拼接慢多少,代码可读性和安全性才是第一位。
6. 服务端周边能力:定时任务、消息通知与缓存优化
6.1 使用@Scheduled做预约提醒
预约前一天的提醒,最直接的方式是Spring Boot的@Scheduled注解。我在项目里定义了一个提醒任务,每10分钟扫描一次预约表,匹配开始时间在24小时内的预约记录,然后发送提醒。
@Scheduled(fixedDelay = 600000) public void sendRemindMessage() { List<Appointment> appointments = appointmentMapper.findPendingRemind(); for (Appointment item : appointments) { messageSender.sendAppointmentRemind(item.getPhone(), item.getStartTime()); appointmentMapper.markReminded(item.getId()); } }这里有一个坑:定时任务默认是单线程串行执行的,如果任务里有耗时的网络调用,会阻塞其他定时任务。所以提醒任务里只负责扫描和发送,发送动作尽量异步化,比如发送到消息队列或使用@Async。
6.2 短信与站内消息接入
前期没有接入真正的短信服务商,因为需要企业资质和模板审核。我用的是站内消息 + 邮件通知的组合,后续要接入阿里云短信、腾讯云短信都只是替换Sender实现类的问题。
设计一个MessageSender接口,然后在实现类里调用第三方SDK。这样做的核心收益是:如果短信服务商临时出问题,可以快速切换到备选通道。我见过太多项目把短信API直接写在业务代码里,换服务商时改得想离职。
6.3 Redis缓存与防重复提交
预约页面的“提交”按钮,用户手滑点了三次,就可能产生三张预约单。除了前面说的唯一索引,在接口入口处加一个防重复提交的AOP拦截器:
@AvoidDuplicateSubmit(interval = 3) @PostMapping("/appointment/submit") public R submit(@RequestBody AppointmentSubmitDTO dto) { ... }拦截器逻辑简单:以userId + methodName + 请求参数的hash作为key,存入Redis并设置过期时间。如果key已存在,直接拒绝。这种方案成本低,效果也足够好。
热点数据缓存主要面向门店端首页的今日预约列表和技师工作台。查询频率高但数据变更快,我设置了5秒的缓存过期时间,既减轻数据库压力,又不会让数据太滞后。
7. 前端集成与联调:Vue页面如何对接后端接口
7.1 接口文档与axios封装
前后端联调最怕接口对不上。我用的方案是Swagger生成OpenAPI文档,后端启动后访问/swagger-ui.html,前端照着文档写。同时封装了一个axios实例,统一处理token注入、响应拦截和401跳转。
service.interceptors.request.use(config => { config.headers['Authorization'] = 'Bearer ' + getToken(); return config; }); service.interceptors.response.use( response => { const res = response.data; if (res.code !== 0) { Message.error(res.message); return Promise.reject(new Error(res.message)); } return res.data; }, error => { if (error.response && error.response.status === 401) { router.push('/login'); } return Promise.reject(error); } );前端所有请求走这一个实例,后端返回统一格式{code: 0, message: "success", data: ...}。联调时少了很多扯皮的场景,换成非0 code就直接提示,不用前端再单独判断。
7.2 预约表单的日期与时段选择
日期选择是预约系统里体验最敏感的部分。我用了Element UI的DatePicker + 自定义时段面板。时段不是固定的,而是由后端接口动态返回:比如查询某个技师在某个日期下,已经被占用的时段、剩余可约时段。
接口设计成:
GET /api/technician/{id}/availableSlots?date=2025-03-01返回一个列表,每个slot包括startTime和endTime,以及剩余可约数。前端拿到后渲染成网格按钮,不可用的时段灰掉。这里有几个优化点:
- 接口在用户选择日期变化时动态请求,不要让用户一次性拉取30天的档期。
- 时段粒度设为30分钟,太细会增加资源碎片,太粗又不够灵活。
- 需要处理后端返回的日期格式时区问题,否则会差8小时,我在第8节会单独说。
7.3 Vue打包后放进Spring Boot
当Vue项目开发调试完成后,执行npm run build生成dist目录。此时将dist目录内的所有文件复制到Spring Boot的src/main/resources/static目录下,重启后端即可通过http://localhost:8080访问页面。
需要注意的两点:
- Vue的路由如果是history模式,直接访问二级路径会404,需要后端统一转发到index.html。Spring Boot里写一个简单的Controller或Filter,处理非接口路径的转发比较方便。
- 如果Spring Boot配置了server.servlet.context-path,打包后的静态资源路径也要对应调整,否则页面找不到JS和CSS。
我建议前期直接用hash模式,省去后端路由转发的麻烦。如果一定要history模式,记得在后端做好路径匹配。
8. 上线遇到的坑与性能优化
8.1 Spring Boot版本过高带来的连锁问题
这个项目初始化时我用了当时最新的Spring Boot 3.x,结果遇到不少兼容性问题。首先,javax.servlet包变成了jakarta.servlet,很多老工具类和教程示例都不能直接用。其次,Spring Security 6的配置写法与5差别很大,网上搜到的旧方案大多不适用。
如果你不属于“追新”型开发者,我建议用Spring Boot 2.7.x版本,资料多、生态成熟、坑基本都被踩平了。新版本确实有性能和功能提升,但对一个预约系统来说,稳定和团队熟悉度更重要。
8.2 LocalDateTime序列化时区问题
这是预约系统最容易踩的坑。后端MySQL使用DATETIME存储时间,Java实体用LocalDateTime,前端拿到的JSON却是字符串。如果不做统一配置,前后端的时间格式会对不上,显示出来多半就差8个小时。
我的解决方案是在application.yml里统一配置:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8同时数据库连接串上指定serverTimezone=Asia/Shanghai。另外,前端在解析和展示时间时,也统一按字符串处理,不调用本地时区转换函数。这样三层时区一致后,再没出现过年久时差问题。
8.3 数据库连接没释放
项目上线后跑了一段时间,发现偶尔有接口卡死,查看日志发现数据库连接在等待获取。排查后定位到是因为长事务里有外部HTTP调用,占着事务一直不提交,导致连接池耗尽。
后来做了两个调整:一是MXBean监控连接池使用率,达到阈值发告警;二是把事务边界缩到最小,外部调用移到事务外,或者使用@Transactional(propagation = Propagation.REQUIRES_NEW)。这个经验教训很直接:写代码时养成先想事务边界的好习惯,别把不需要包在事务里的操作全塞进去。
8.4 自定义自动配置的简单实践
开发过程中我发现一些公共组件(比如统一异常处理器、ThreadLocal用户上下文)在很多模块里重复出现,于是把它们抽取到单独模块,利用Spring Boot的SpringFactories机制写了一个自定义自动配置。启动日志里能看到自定义配置生效,整个项目清爽不少。
这也是Spring Boot比较核心的自动装配原理的一个简单应用:通过@ConditionalOnClass、@ConditionalOnMissingBean等注解,控制配置在什么条件下生效。不必非得自己去写框架,理解了这个机制后,排查Maven依赖冲突时会快很多。
9. 开发中一些容易忽略的细节
9.1 Banner生成器
有同事在启动日志里加一个定制Banner,用banner生成器搞了ASCII艺术字,每次启动都能看到项目名。这个对项目本身没什么用,但在团队协作时能提升一点仪式感。而且Spring Boot支持直接启用的Banner,只需把生成的文件命名为banner.txt放入resources目录即可。
9.2 日志链路追踪
线上排查问题最大的痛点是多个请求的日志混在一起。我在拦截器里给每个请求生成traceId,放入ThreadLocal和MDC,然后在logback配置里输出traceId。每次查看日志时,用traceId就能把一次请求的完整链路拉出来。这个改动虽小,但值得每个Spring Boot项目都配上。
9.3 事务失效的三个常见场景
- 同类内部方法调用导致@Transactional失效。解决方法是注入自身代理对象,或者拆到另一个Service里。
- 异常被try/catch吞掉,事务感知不到异常,自然不会回滚。
- @Transactional加在private方法上,Spring AOP无法代理private方法。
预约取消防御里,我曾经因为捕获了异常没有抛出,导致已经插入的预约记录无法回滚,排查了几个小时才意识到是事务失效。所以异常处理一定要规范:业务异常抛BizException,系统异常抛RuntimeException,统一由全局处理器转换。
最后
每次开发完一个从零到一的系统,回头看最值钱的其实不是代码能跑起来,而是那些摸出来的边界条件。这个预约系统在档期并发、状态流转、时区问题上花的时间最多,而Spring Boot本身提供的能力已经非常成熟。如果你打算做一个类似的维修预约系统,我的建议是:先画清楚业务状态和角色边界,再动手写代码;数据库唯一约束能加就加;不要把时间浪费在追逐新版本和复杂框架上,稳定运行才是线下门店最需要的。
后面我还会把工单结算和门店排班这两块继续迭代,到时再专门写一篇补充分享。