尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Spring Boot+Vue从零搭建汽车维修预约系统实践与踩坑

Spring Boot+Vue从零搭建汽车维修预约系统实践与踩坑 做汽车维修预约系统这个项目我一开始是有点犹豫的。市面上这类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 LambdaQueryWrapperAppointment() .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的查询参数里强制传入storeIdMyBatis-Plus的LambdaQueryWrapper如下LambdaQueryWrapperAppointment 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() { ListAppointment 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?date2025-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: GMT8同时数据库连接串上指定serverTimezoneAsia/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本身提供的能力已经非常成熟。如果你打算做一个类似的维修预约系统我的建议是先画清楚业务状态和角色边界再动手写代码数据库唯一约束能加就加不要把时间浪费在追逐新版本和复杂框架上稳定运行才是线下门店最需要的。后面我还会把工单结算和门店排班这两块继续迭代到时再专门写一篇补充分享。
返回列表