ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于Spring Boot的汽车维修预约服务系统设计与实现

基于Spring Boot的汽车维修预约服务系统设计与实现 做汽车维修预约服务系统这个项目之前我其实在门店端踩过不少坑。很多车主打电话预约保养前台拿纸质本子记记完还得扯着嗓子喊技师确认时间赶上高峰期同一个工位能排两队。后来我接手这套基于Spring Boot的汽车维修预约服务系统才真正把预约、工单、车辆档案、结算这一条链跑通。今天就把这个项目的设计思路、核心表结构、预约逻辑和部署遇到的那些坑一次性讲透。这篇内容适合谁看如果你是在校生拿Spring Boot做毕业设计或者小维修厂想上一套预约系统再或者你纯粹是想知道一个完整的预约业务怎么从零落地这篇文章都能给你一个能直接抄作业的底子。我不打算只列代码更想讲清楚每一步为什么这么设计。1. 项目定位与整体设计思路先想清楚再动手1.1 汽车维修预约到底在预约什么很多人刚拿到这个题目时第一反应是做一个在线选时间的日历类似预约理发那种。但汽车维修预约和理发预约有一个本质区别维修预约不只是占一个时间段它还涉及技师技能匹配、工位资源、配件库存和预计工时。车主想预约的是“明天上午把车开过去做个大保养”而系统要回答的不只是“有没有空位”还要回答“明天上午哪个技师会做这个车型”“对应工位空不空”“保养套餐里的机油有没有库存”。所以我做需求分析的时候把系统拆成了四类使用角色每类角色的诉求完全不同车主端绑定车辆、选择服务项目、查预约记录、看维修进度、收结算单。接待前台代客下单、确认预约、到店核销、引导车辆入场。维修技师查看今日预约列表、接单、登记检查结果、填报工时和配件。老板/管理者看门店工位利用率、技师工作量、预约取消率、营业收入。这四类角色在同一个Spring Boot项目里其实不需要拆成四个服务单应用按角色区分接口权限就行前期不引入微服务那套复杂的东西。关键是把业务闭环打通车主预约到店前台确认技师开工完工结算记录沉淀到车辆档案里下一次保养提醒从档案里计算出来。1.2 技术栈为什么选 Spring Boot这套系统我用了 Spring Boot 3 MyBatis-Plus MySQL Redis Vue 3 的组合选型不是拍脑袋是实际对比过的。Spring Boot 相比传统 SSM 的最大优势是自动装配和起步依赖。以前做SSM项目配置数据源、配置事务管理器、配置Spring MVC每个都要写一大堆XML现在一个spring-boot-starter-web就把Web容器、JSON序列化、默认异常处理全部带进来了。内嵌Tomcat的特性也让部署变得非常轻量一个jar包放到服务器就能跑不要求运维懂太多Java中间件。持久层我用 MyBatis-Plus 而不是纯 MyBatis原因是预约列表的查询条件太碎了按门店查、按状态查、按车辆查、按日期范围查手写SQL虽然也可以但工作量明显变大。MyBatis-Plus 的条件构造器让我可以很快拼接查询条件分页插件也是现成的。Kepp in mindMyBatis-Plus 在 Spring Boot 3 时代要用新版本老版本会因为包路径变化直接启动失败这个坑后面细说。Redis 在这个项目里不是摆设主要做了三件事缓存当天预约时间槽、做分布式锁防止同一个时间段被重复下单、存储预约待支付的临时单。前两件事是预约系统的核心稳定性保障后面我会展开讲。前端选了 Vue 3 Element Plus和后端通过 JSON 交互属于典型的前后端分离方案。实际部署时默认推荐 Nginx 反向代理到后端接口但为了让学生项目或者小门店能简单部署我也会讲怎么把 Vue 打包好的静态文件放进 Spring Boot 的静态目录里打成单 jar 运行。1.3 核心业务闭环这个系统的核心业务闭环是我反复给团队讲的一张流程注册登录 → 绑定车辆VIN码/车牌 → 选择服务项目 → 选择门店和时间槽 → 提交预约 → 前台确认/系统自动确认 → 到店核销 → 技师接车检查 → 生成维修工单 → 报价确认 → 维修执行 → 完工质检 → 结算 → 回访记录 → 车辆档案更新保养里程、维修历史每一个环节在数据库里都对应明确的状态字段和操作记录这也是项目能上线运营而不是停留在演示Demo的关键。下面重点拆解数据库设计和核心逻辑。2. 数据库设计别把预约做成“假日历”2.1 用户与车辆档案表用户表我保持了相对简单的设计单表存用户用role字段区分角色。这里不推荐做多张用户表然后 join 来 join 去那是在给自己找麻烦。角色就四类车主、前台、技师、管理员一个数字字段就能表达。车辆档案表是整个系统的“资产表”这辆车做过什么保养、什么时候换过刹车片、保险公司是哪家、VIN码对应什么车型配置全部沉淀在这里。核心字段有car_id、user_id、plate_no、vin_code、brand、model、mileage、last_maintain_mileage、insurance_expire_date。有一个很容易忽略的点VIN码一定要做格式校验17位排除字母I、O、Q。前挡风玻璃上那一串码如果录错一次后面所有保养记录都会对不上。我的做法是在前端做正则校验后端再加一道Pattern校验双保险。2.2 预约与工单核心表预约主表service_appointment我设计的字段大概是这个思路字段名类型说明appointment_idbigint主键appointment_novarchar预约单号如 YY20250613001store_idbigint门店IDcar_idbigint车辆IDtechnician_idbigint技师ID预约时可以指定也可以由系统分配service_typeint服务类型1 小保养 2 大保养 3 维修 4 轮胎/底盘等expect_start_timedatetime期望开始时间expect_end_timedatetime预计结束时间statusint0待确认 1已确认 2已到店 3维修中 4待结算 5已完成 6已取消appointment_sourceint1用户自助 2前台代录remarkvarchar备注时间槽表appointment_slot是预约系统的核心资源表我会在第3章详细讲。工单表repair_order关联预约单记录技师实际执行的维修项目、配件明细、工时费用和结算状态。这三张表的关系是预约单是“客户意愿”工单是“实际干活”时间槽是“资源占用”。三者通过预约单号关联起来但状态各自独立因为预约被取消不代表工单已经被删掉工单一旦生成就要归档。2.3 状态流转设计预约状态和工单状态是两套状态机别混在一起。预约状态我控制在7个以内状态只允许按固定方向流转待确认 → 已确认 → 已到店 → 维修中 → 待结算 → 已完成。取消这个动作从待确认和已确认状态都可以直接跳到已取消但不能从维修中直接取消除非这个预约对应的工单已经废弃。这个限制在代码里用枚举校验实现不能仅仅靠前端按钮隐藏不然用Postman直接调接口就能把状态改乱。工单状态我简化成更轻量的流程检查中 → 待报价 → 维修中 → 待质检 → 已完工 → 已结算。注意工单是“干活的事”它不关心预约是否被放鸽子只要车开进来了工单就开始独立流转。我用整数枚举而不是字符串存状态一个是为了查数据库时索引效率更好一点另一个是避免字符串拼写错误导致状态判断失灵。比如写status confirmed和status Confirm你肉眼很难发现但Integer.valueOf(1).equals(status)这种判断就永远不会错。3. 预约时间槽与冲突处理核心逻辑要稳3.1 时间槽怎么生成和扣减预约模块最容易踩坑的地方就是时间槽。一开始我图省事只存了预约的expect_start_time然后在查询空闲技师/工位时直接数据库里查找时间段有重叠的预约记录。功能看起来能跑但当预约数量多起来之后每次选时间都要实时算一遍数据库压力大而且容易出现间隙。后来我改成“预生成时间槽”方案。门店提前一天按配置生成第二天的全部时间槽比如营业时间是 09:00 到 18:00每个时段30分钟那么一台工位一天生成18个时段一个是“空闲/已占”的二进制判断另外再记录起始时间和结束时间。生成时间槽的定时任务我用 Spring 自带的Scheduled实现每天凌晨2点生成所有门店、所有工位未来7天的时间槽。如果发现某个时间槽已经生成了就跳过保证幂等。这里还活跃了一个变量每个工位在同一时间最多只能被一个预约占用所以扣减时间槽时不能简单地把整个时间槽标记为已占用而是要判断新预约的时间范围是否和已有的预约重叠。简化后的判断逻辑是这样的一个新的预约[start, end)要成功必须满足它覆盖的所有最小时间槽里没有一个已经被其他预约占用。比如预约9:00到10:30那必须9:00、9:30、10:00三个槽全部可用才能下单。3.2 三种冲突场景一个都不能漏预约冲突我归纳成三类同技师冲突同一个技师在同一个时间区间里不能接两辆车。这个判断要按人维度做不能只看工位。因为有的门店技师少工位多工位闲着但技师满了。同工位冲突同一个举升机上不能同时放两辆车。这个按工位维度判断。同车辆冲突同一辆车不能在同一个时间段有两笔有效预约。这个比较容易忽略车主有可能手滑重复提交。对应到数据库设计里建议建一个唯一性约束虽然表面看起来是业务级校验但数据库约束是最后的兜底。MySQL 的 InnoDB 支持索引所以在预约表上建立一个(car_id, status, expect_start_time)的联合索引能明显提升冲突查询速度同时对高并发下单也有一定的锁保护作用。3.3 分钟级锁定与超时释放在线预约经常出现的情况是车主选好了时间、填完了车辆信息但是卡在支付押金或者犹豫要不要提交这一步。如果不做任何机制这个时间槽会被他白白占住别的车主想选却选不了。我的方案是“预占 超时释放”。车主提交预约请求时系统先不真正写预约单而是在 Redis 里以slot:{storeId}:{date}:{time}为 key 做一个15分钟的占位value 存用户ID。15分钟内如果用户确认支付这个占位转成真正的预约记录并释放锁超时的话用一个延迟任务把 Redis key 删掉时间槽恢复空闲。实现超时释放有两种做法一种是用 Redis 的 Key 过期事件配合 Spring 的监听类来感知另一种是启动一个定时任务每1分钟扫一次 Redis 中过期时间小于当前时间的占位 key。我用的是第二种更可控因为 Redis 过期事件默认不是精确触发的在低版本上有延时。4. 核心模块实现与代码实操4.1 创建 Spring Boot 项目与基础配置用 IDEA 创建 Spring Boot 3 项目时记住几个重点Java 选择 17 及以上Spring Boot 3 不支持 Java 8依赖里把 Spring Web、MySQL Driver、MyBatis-Plus、Lombok、Validation 都选上。如果你是手动往pom.xml加依赖建议用下面这个基线版本组合实测稳定parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.5/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.7/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency /dependencies这里要强调一个坑MyBatis-Plus 在 Spring Boot 3 下要用mybatis-plus-spring-boot3-starter这个独立坐标而不是旧的mybatis-plus-boot-starter。旧坐标在 Spring Boot 3 里会报ClassNotFoundException或者自动配置不生效并且通常不会告诉你是版本问题排查起来很费时间。然后在application.yml里配置数据源、端口和 MyBatis-Plus 的日志打印。开发环境我习惯打开 SQL 日志方便追踪自动生成的 SQL 是否符合预期。加一句mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl生产环境记得关掉或者切到Slf4jImpl按日志级别输出不然控制台会被 SQL 刷爆。4.2 预约下单接口事务加锁缺一不可预约下单是整个项目里最考验代码功底的一个接口因为涉及到多步操作校验时间槽、生成预约单、扣减时间槽、记录操作日志每一步都可能抛异常所以必须加事务。如果哪一步失败前面已扣减的时间槽必须回滚否则会出现“预约失败但时间被占”的情况。我提供了两种实现方式。单机部署就选同步锁加事务直接简单。核心代码如下Transactional(rollbackFor Exception.class) public AppointmentResult createAppointment(AppointmentCreateDTO dto) { // 1. 校验车辆属于当前用户 Car car carMapper.selectById(dto.getCarId()); if (car null || !car.getUserId().equals(dto.getUserId())) { throw new BusinessException(车辆信息不存在); } // 2. 锁定时间槽防止并发冲突 boolean locked slotService.tryLockSlot(dto.getStoreId(), dto.getExpectStartTime(), dto.getExpectEndTime()); if (!locked) { throw new BusinessException(该时段已被预约请选择其他时间); } // 3. 计算预计结束时间 LocalDateTime endTime calcEndTime(dto.getServiceType(), dto.getExpectStartTime()); // 4. 插入预约记录 Appointment appointment buildAppointment(dto, endTime); appointmentMapper.insert(appointment); // 5. 记录时间槽占用明细 slotService.occupySlot(appointment); return AppointmentResult.success(appointment); }tryLockSlot底层在单机环境我用的是基于数据库的行锁加上一个 Redis 预占的逻辑。核心就是不要让两个请求同时通过“该时段空闲”的检查否则后面的插入会互相覆盖。参数校验我用Validated注解请求 DTO 里加上NotNull、Future这些约束。有个细节expectStartTime必须要求在提交时是未来10分钟之后的时间不然客户提交秒级预约技师刚看到消息车就到了完全没有准备时间。4.3 报价计算工时费加配件费还要考虑套餐维修报价是门店老板最看重的模块。简单做一个“项目单价乘数量”不难但实际业务里报价涉及工时定额、门店工时费率、配件成本、套餐折扣、会员折扣。我采用的是策略模式加明细表避免把一堆判断逻辑写在一个calcPrice方法里。工时费的计算逻辑是每个服务项目在service_item表里有标准工时比如换机油是0.5小时大保养是2.5小时。门店可以设置自己的工时单价比如120元/小时。所以一单的工时费 工时定额 × 工时单价。配件费则来自part_item表维修工单创建时逐个录入配件最后累计。报价明细单独存一张repair_order_item表每条记录都带item_type1工时 2配件 3套餐然后主表repair_order里冗余一个total_amount方便列表页直接显示不用每次汇总。这里要特别注意报价保存后技师后面又加了维修项目这一单的金额会变化所以在状态流转到“待结算”之后要禁止修改工单明细。除非走“价格调整单”流程并记录操作人和调整原因这既是业务要求也是事后审计的依据。4.4 保养提醒与消息通知车辆档案里记录了上次保养的里程和到期日期系统要能自动算出“这辆车是不是该保养了”。计算规则不复杂当前里程减去上次保养里程大于等于5000公里或者距离上次保养时间超过6个月满足任一条件就触发提醒。这个提醒不是单纯的系统日志要真正推到车主那里才有价值。我的做法是维护一张remind_record表定时任务每天扫描车辆档案生成待提醒列表然后通过微信服务号模板消息或者短信 API 推送给车主。因为是 Spring Boot 项目消息推送的客户端只需要封装一个 HTTP 请求的 Service 类即可。定时任务建议单独开一个配置类用EnableScheduling启动。不要写在 Web 层的 Controller 里一个是不安全另一个是启动时会执行两遍导致重复提醒。定时任务的执行时间选在早上10点和下午4点避开晚上推送否则容易被用户投诉骚扰。5. 前端联调与部署流程走通才算数5.1 前后端联调的三个高频问题前后端分离开发的模式确定下来之后联调阶段我总结了三个高频问题第一个是跨域。前端在 localhost:5173 起的 Vite 开发服务后端跑在 localhost:8080浏览器会拦截非同源请求。解决方式是在 Spring Boot 里写一个跨域配置类实现WebMvcConfigurer的addCorsMappings允许的前端源明确写出来不要图省事直接allowedOriginPatterns(*)那样会把后端暴露给所有网站不安全。第二个是 Token 传递。后端的登录接口返回 token 后前端每次请求要在 Header 里带上Authorization: Bearer xxx。我在 Spring Boot 里加了一个拦截器统一从 Header 取 token解析出用户信息后放到ThreadLocal里后面的 Controller 方法直接用。好处是业务代码不用每处都传 userId缺点是要小心线程池场景下的ThreadLocal内存泄漏所以过滤器最后必须在 finally 里 remove。第三个是日期格式。前端传递的日期字符串通常是2025-06-13 09:00:00后端如果用LocalDateTime接收要提前在配置里指定 Jackson 的反序列化格式否则会报DateTimeParseException。统一在application.yml里写spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8这个 time-zone 一定要写不然服务器在 UTC 时区、数据库存的是本地时间返回给前端会少8小时。时区问题我排查过一次查了半小时最后发现是默认UTC。5.2 Vue 打包放进 Spring Boot 的单 jar 方案Nginx 反向代理是标准部署方案但如果目标环境只有一台小服务器、还不想装 Nginx那就可以把前端构建产物放进 Spring Boot 的静态资源目录打成单 jar 直接跑。方法并不复杂。前端项目在vite.config.js里把base设置为相对路径或你希望访问的上下文路径然后执行npm run build生成的dist目录里的静态文件复制到后端src/main/resources/static下。后端里加一段路由转发把前端路由例如/和/appointment等页面路径转发到index.html否则直接访问这些路径是404。Controller public class PageForwardController { GetMapping(value {/, /appointment, /order, /car, /admin/**}) public String forward() { return forward:/index.html; } }需要特别注意的是后端接口路径必须和前端页面路由避免冲突。比如后端GetMapping(/order)和前端路由/order就会撞上。我的做法是后端所有接口统一加/api前缀前端页面路由不加这个前缀从根上断开冲突。5.3 服务器部署与日常维护单 jar 部署的方式在 Linux 服务器上非常简单把打包好的appointment-system.jar上传后执行nohup java -jar appointment-system.jar --spring.profiles.activeprod appointment.log 21 第一次启动时建议先不用nohup直接在终端前台跑方便看到启动日志里有没有报错。启动成功后再用 nohup 放到后台。我用的另外一条习惯是把启动参数放到外面nohup java -Xms256m -Xmx512m -jar appointment-system.jar \ --spring.config.additional-location/opt/app/config/application-prod.yml \ /opt/app/logs/appointment.log 21 配置文件外置的好处是哪天改数据库密码或者端口不用重新打包 jar直接编辑外置的 yml 然后重启即可。日志文件一定要按月切割可以用 logback 配置不切割的话一个月下来单个文件能到几个 GB查日志变得非常痛苦。6. 常见问题与排查技巧实录6.1 Spring Boot 版本太高带来的连锁反应市面上不少教程还在用 Spring Boot 2.x有些同学图新鲜直接创建 Spring Boot 3.3 甚至 3.4结果碰到一连串问题JDK 版本不够、MyBatis-Plus 不兼容、javax 包名找不到。Spring Boot 2.x 用的javax.servlet在 3.x 里变成了jakarta.servlet如果你看的是老教程import javax.servlet.http.HttpServletRequest会直接编译报错这不是你写错是包名整体迁移了。解决方案就是要么把 Spring Boot 版本降到 2.7.x要么把所有javax替换成jakarta二选一别纠结。还有 Lombok 版本也可能有问题Spring Boot 3 要求 Lombok 1.18.30 以上。遇到Lombok Data生成的 getter/setter 找不到方法先查 Lombok 版本是不是太老。6.2 预约时间槽并发下单导致超卖预约系统最怕的问题就是“超卖”两个车主同时抢同一个时间槽最后两个人都付了押金但门店只能接一辆车。我在秒杀类场景里常用的方法是 MySQL 乐观锁。时间槽表加一个version字段更新时先查 version然后执行更新时带WHERE version 旧值如果更新影响行数为0说明这一秒被其他事务抢先修改了当前请求直接返回“该时段已被预约”。UPDATE appointment_slot SET status 2, version version 1 WHERE slot_id #{slotId} AND status 1 AND version #{oldVersion}配合 Redis 预占位之后这个方案在几十个用户同时抢两个热门时段的情况下没出过问题。如果哪天门店规模大到上千人同时抢那就得引入消息队列削峰但中小门店完全没必要。6.3 事务不生效的经典场景我帮别人看代码时经常发现一个错误在同一个类里一个方法调用另一个带Transactional的方法结果事务死活不生效。原因是 Spring 的事务是基于 AOP 代理的类内部方法调用走的不是代理对象而是原始对象所以注解被忽略了。解决办法是让它们互相分离把事务方法放到另一个 Service Bean 里或者像下面的代码一样通过代理对象调用((AppointmentService) AopContext.currentProxy()).createAppointment(dto);不过AopContext.currentProxy()需要额外开启EnableAspectJAutoProxy(exposeProxy true)我Preferred的做法还是拆类代码更清晰也避免测试时搞不清代理状态。另外要注意事务方法如果被 try-catch 吞掉了异常事务也会失效因为框架根本不知道发生了错误。推荐把业务异常包装成RuntimeException抛出统一由全局异常处理器转换返回给前端。6.4 查询列表越查越慢的隐患预约列表、车辆列表这类页面一开始数据量小速度还行等跑了大半年几万条预约记录之后列表查询开始变慢。主要原因是没有用好索引或者查询条件没有走索引。我的做法是每个页面查询都通过 MyBatis-Plus 的QueryWrapper构造确保where条件里最常用的“门店日期范围”“车辆状态”都有对应复合索引。另外在列表页永远只返回当前页的数据不要一次性把所有预约记录查出来。分页插件用MybatisPlusInterceptor配PaginationInnerInterceptor每页默认20条最多50条防止有人手工改参数调出一个超大分页。还有一个小技巧appointment_no这种业务编号字段如果频繁做精确查询也要单独建索引。索引不是越多越好当写入频繁时每个索引都增加写放大所以我会在系统上线前根据实际运行日志里的慢SQL有针对性地补索引而不是一开始就把所有字段都建上。7. 写在最后的实操体会这个项目从需求梳理到上线我最大的体会是不要一上来就动手写代码先把预约的“资源模型”想明白。你订的是时间、技师、工位这三样资源任何设计如果漏掉了其中一个维度后面再做都是打补丁。另外状态机一定要控制住。我接触过的失败项目中有一半以上是状态字段失控这个接口把已完成的单子改成维修中那个接口把已取消的单子重新参与对账。解决思路就是严格用枚举定义合法流转非法流转直接抛异常宁可前端友好提示也不要让脏数据进数据库。最后分享一个实际运营中的小技巧预约系统上线后我会在后台加一个“预约完成率”的统计指标。看起来有点虚但这个数据非常能反映问题——如果预约完成率低于70%说明线上预约只是“看起来方便”实际到店率不高一定是确认流程或者提醒机制出了问题。做技术的人不能只盯着接口耗时和并发量多关注这些业务指标系统的价值才能真正体现出来。
返回列表