news 2026/9/9 11:06:19

SpringBoot+Vue3+MyBatis+MySQL汽车维修预约系统实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringBoot+Vue3+MyBatis+MySQL汽车维修预约系统实战

前阵子把这套汽车维修预约服务系统完整撸了一遍,从需求梳理、数据库设计到前后端联调,坑没少踩。项目本身不算复杂,但该有的模块都有:SpringBoot 做后端接口,Vue3 做管理端页面,MyBatis 负责数据库交互,MySQL 存业务数据,前后端分离部署,整个流程是闭环的。

这套系统的定位很直接——给汽修门店、4S 店售后车间用的预约管理工具。车主线上提交预约,门店后台确认派工,技师接单修车,前台结算离店,业务状态一目了然。适合正在做毕设、想练全栈、或者接私活做外包的 Java 开发去参考。市面上很多“系统源码”标题的仓库,跑起来容易,但要让业务逻辑真正自洽,还是得把设计思路吃透。

这篇文章我就把整套系统的设计思路、核心实现和踩过的坑拆开来讲,按标题里的技术栈展开:SpringBoot + Vue3 + MyBatis + MySQL,前后端分离。标题里连着写了两个“系统”,典型的源码发布风格,咱们不纠结这个,直接把项目讲明白。

1. 需求梳理与系统设计:先想清楚业务,再动代码

1.1 汽车维修预约的核心场景

很多刚接触这套系统的人上来就建表写接口,结果做到一半发现状态对不上,流程串不起来。我建议先画业务流转图,把角色和状态理清再动手。

一个标准的汽车维修预约系统,至少有三类角色:

  • 车主:注册登录、添加车辆、选择维修项目、预约到店时间、查看维修进度、支付结算。
  • 门店管理员/前台:确认预约、分配技师、查看工位占用、处理取消订单、录入结算信息。
  • 维修技师:查看分给自己的工单、更新维修状态、填写维修记录、领用配件。

核心业务流程是这样的:车主注册登录后,先在“我的车辆”里添加车辆信息,再选择需要维修或者保养的项目,按门店的可预约时段提交预约单。管理员在后台确认预约,等待车主到店。到店后管理员确认签到,分配技师和工位,生成维修工单。技师维修过程中需要更新状态,维修完成后发起结算,车主支付后整个预约流程结束。

这个流程对应到数据库里,就是一条预约记录从创建到完成的状态变化。如果一开始不把这些理清楚,后面写状态更新逻辑的时候必然改来改去。

1.2 为什么选 SpringBoot + Vue3 + MyBatis + MySQL

这套技术栈不是随便凑的,每层都有它的理由。

后端用SpringBoot,本质上是图它“开箱即用”的生态。内置 Tomcat、自动配置、起步依赖,加上现在 Spring Initializr 一键生成工程,十秒就能把项目骨架拉起来。对于预约系统这类 CRUD 密集型业务,SpringBoot 的开发效率确实高,而且招人好招、资料好查,遇到问题基本都能搜到答案。

前端用Vue3,是因为中后台管理页面的核心诉求是“组件化 + 响应式 + 路由管理”。Vue3 的组合式 API 写业务逻辑比 Options API 更集中,一个预约管理页面里,筛选条件、表格数据、状态标签、分页逻辑可以聚在一起维护。配合 Vite 开发体验也很好,热更新快。

MyBatis在这套系统里的角色是 SQL 控制。预约查询往往涉及多表关联和动态条件,MyBatis 的 XML 动态 SQL 写起来直白,SQL 优化空间完全掌握在手里,不像 JPA 那样容易生成一堆让人摸不着头脑的查询。这也是很多传统业务系统的选择。

MySQL不用多说,中小型业务系统的标配。预约数据的量级,在单门店场景下一天几百条顶天了,MySQL 性能完全够用,而且部署运维成本低。

1.3 数据库表结构设计:一张预约单串起所有业务

我设计表结构的时候,核心思路是一张appointment预约单作为主链路,串联起用户、车辆、工单、结算这些子业务。核心表如下:

表名用途关键字段
sys_user系统用户username、password、role(owner/admin/tech)、phone
vehicle车辆信息user_id、plate_no、brand、model、vin、mileage
appointment预约单appt_no、user_id、vehicle_id、appoint_time、service_type、status、tech_id
work_order维修工单appt_id、tech_id、status、start_time、end_time、labor_fee
repair_item维修项目字典item_name、standard_fee、duration_minutes
order_item工单明细work_order_id、item_id、quantity、fee
part配件库存part_no、name、spec、stock
part_usage配件出库记录work_order_id、part_id、quantity
settlement结算单work_order_id、total_amount、pay_status、pay_time

几个设计细节要注意:

一是预约编号 appt_no,不要用自增 id 直接给用户看。业务上一般用时间戳加随机数生成,比如20250407103000123,方便前台查单和对账。

二是状态字段的数据类型,我建议用 tinyint 加枚举映射,而不是直接存字符串。这样数据库层面索引效率更高,Java 代码里用枚举统一管理,不会出现“零散字符串状态”导致的数据污染。

三是并发约束。同一个时间段同一个技师或者同一个工位,不应该出现两个预约。这个光靠代码判断不够,数据库层面建议加唯一索引,比如uk_tech_time(tech_id, appoint_time),从底层兜底。

2. 后端核心实现:状态机、动态 SQL 和事务控制

2.1 后端分层与统一返回体

后端我采用标准的四层结构:controller接收请求,service处理业务,mapper操作数据库,entity对应表结构。此外加了一层dtovo,请求参数和响应数据不直接拿实体类往外传。

这里有个很容易犯的错:直接把entity返回给前端。这样会多暴露很多不该给的字段,比如密码、内部备注等。正确做法是定义一个统一的返回体,比如:

@Data public class Result<T> { private Integer code; private String msg; private T data; public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.code = 200; r.msg = "success"; r.data = data; return r; } public static <T> Result<T> error(String msg) { Result<T> r = new Result<>(); r.code = 500; r.msg = msg; return r; } }

再加上配合@RestControllerAdvice的全局异常处理器,业务里抛自定义异常后统一转成 JSON 返回,前端拦截器只需要判断 code 即可,省去一堆 try-catch。

2.2 预约单状态机设计

预约单是整个系统的核心对象,它的状态流转一定要收敛,不能到处随便改 status。我定义了一个枚举:

public enum ApptStatus { WAIT_CONFIRM(0, "待确认"), CONFIRMED(1, "已确认"), ARRIVED(2, "已到店"), REPAIRING(3, "维修中"), FINISHED(4, "已完成"), CANCELLED(5, "已取消"); private final int code; private final String desc; }

状态流转规则我写在 service 层的一个方法里,所有更新状态的操作都必须走这个方法:

  • 待确认 -> 已确认(管理员确认预约)
  • 待确认 -> 已取消(管理员驳回或车主取消)
  • 已确认 -> 已到店(车主到店签到)
  • 已确认 -> 已取消(超时未到店,管理员取消)
  • 已到店 -> 维修中(生成工单)
  • 维修中 -> 已完成(技师完工)

为什么要把状态机单独抽出来?因为状态流转不只是改一个数字,往往伴随其他业务动作。比如“已到店”那一步,要同时把 appointment 状态更新,并且创建 work_order;比如“已完成”那一步,要计算维修费用,生成结算单。如果状态更新散落在各种 controller 里,后面查问题会非常痛苦。

2.3 MyBatis 动态 SQL:多条件分页查询

预约管理列表是后台最常用的页面,筛选条件可能包括:预约状态、车牌号、预约日期、技师 id,还需要联表查出车主姓名和车辆信息。这类查询我用 MyBatis 的 XML 实现,配合 PageHelper 分页。

<select id="selectAppointmentPage" resultType="com.example.vo.AppointmentVO"> SELECT a.id, a.appt_no, a.appoint_time, a.status, u.real_name AS ownerName, u.phone AS ownerPhone, v.plate_no, v.brand, v.model FROM appointment a LEFT JOIN sys_user u ON a.user_id = u.id LEFT JOIN vehicle v ON a.vehicle_id = v.id <where> <if test="status != null"> AND a.status = #{status} </if> <if test="plateNo != null and plateNo != ''"> AND v.plate_no LIKE CONCAT('%', #{plateNo}, '%') </if> <if test="appointDate != null and appointDate != ''"> AND DATE(a.appoint_time) = #{appointDate} </if> <if test="techId != null"> AND a.tech_id = #{techId} </if> </where> ORDER BY a.appoint_time DESC, a.id DESC </select>

有几个地方容易踩坑:

第一,<where>标签不要省略。它能自动去掉第一个条件前面的 AND,比手动加WHERE 1=1干净得多。

第二,时间字段比较要用DATE(a.appoint_time) = #{appointDate},如果前端传的是YYYY-MM-DD格式,直接用=比较 datetime 字段会查不到数据,因为数据库里存的还带时分秒。

第三,联表查询返回的字段如果和实体类不一致,建议单独建一个 VO 类,配上 @Data 注解,别硬塞进 entity 里。

2.4 事务与并发控制

预约场景典型的并发问题是两个车主同时抢同一个技师的同一时段。光靠前端按钮置灰没用,高并发下接口可能会被连点两次。

处理方式我在项目里上了三层保险:

第一层,@Transactional注解。创建预约的方法加上事务,一旦后续校验失败就直接回滚,避免产生脏数据。

第二层,数据库唯一索引。appointment表设置uk_tech_time(tech_id, appoint_time),同一个技师在同一个时间只能有一条预约。就算代码逻辑有漏洞,数据库层面也会直接抛 DuplicateKeyException。

第三层,查询预约可用时段的 SQL 加上FOR UPDATE悲观锁。示例:

SELECT COUNT(*) FROM appointment WHERE tech_id = #{techId} AND appoint_time = #{appointTime} AND status IN (0, 1, 2, 3) FOR UPDATE

这样在创建预约前先锁住这一行统计,事务提交后再释放锁,基本能挡住并发问题。注意FOR UPDATE必须在事务里才能生效,而且如果索引没走对,会锁全表,所以字段索引要建好。

3. 前端核心实现:Vue3 预约看板与权限布局

3.1 前端工程目录设计

前端我用 Vite + Vue3 + Element Plus,目录结构大致是这样:

  • src/api:按业务模块拆分的接口请求文件
  • src/router:路由配置与登录守卫
  • src/store:Pinia,存用户信息和登录状态
  • src/views:页面组件
  • src/components:业务通用组件,比如状态标签、预约时间线

src/api/appointment.js这种文件建议一个模块一个文件。接口路径统一以/api开头,方便 dev 环境做代理,生产环境做 Nginx 转发。

import request from '@/utils/request' export function getAppointmentPage(params) { return request({ url: '/api/appointment/page', method: 'get', params }) } export function updateAppointmentStatus(id, status) { return request({ url: `/api/appointment/${id}/status`, method: 'put', data: { status } }) }

3.2 路由守卫与 JWT 登录鉴权

登录逻辑我用 JWT 做鉴权,后端登录成功后返回 token,前端存到 localStorage,每次请求通过拦截器带上。

service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) service.interceptors.response.use( response => response.data, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(error) } )

路由守卫写在router/index.js里,核心逻辑就是判断有没有 token,没有就跳登录页。这里有个细节:不要把角色权限判断也写在路由守卫里。角色判断应该结合菜单渲染来做,因为不同角色的菜单项不一样,直接用前端写死的路由守卫维护成本太高。

菜单权限的做法是:登录接口返回用户信息和角色,前端根据角色动态组装菜单数组,再动态注册路由。这样管理员能看到“预约管理”“用户管理”“配件库存”,技师只看到“我的工单”。

3.3 预约看板组件:用时间线展示预约流转

预约详情页是整个系统最有“业务感”的地方。我先实现了预约状态的 Timeline,直观展示当前走到哪一步。Element Plus 的el-steps组件就能做。

<template> <el-steps :active="activeStep" align-center> <el-step title="提交预约" /> <el-step title="门店确认" /> <el-step title="到店签到" /> <el-step title="维修中" /> <el-step title="完成结算" /> </el-steps> </template> <script setup> import { computed } from 'vue' const props = defineProps({ status: Number }) const activeStep = computed(() => { // 待确认 0 -> 0 // 已确认 1 -> 1 // 已到店 2 -> 2 // 维修中 3 -> 3 // 已完成 4 -> 4 // 已取消 5 -> -1 return props.status === 5 ? -1 : props.status }) </script>

状态展示的时候注意统一做映射,不要每个页面都写一遍if (status === 0)。我封装了一个useApptStatus组合式函数,里面返回状态对应的文字、颜色、操作按钮列表,各页面复用。

3.4 表单校验与状态联动

预约表单里有一个核心交互:选维修项目后,系统根据项目和车辆自动估算预约时长,并计算出可选的时间段。

表单校验我用 Element Plus 的el-formrules,车牌号做正则校验,手机号做 11 位校验。预约时间用el-date-picker,设置disabled-date禁用掉过去的时间。

另一个关键点是操作按钮和状态的联动。比如预约状态是“待确认”时,管理员能看到“确认”“取消”两个按钮;“已确认”时,“确认”按钮隐藏,显示“到店签到”;“维修中”以后,只能看到详情和结算入口。这个联动逻辑适合抽成一个计算属性,根据当前状态返回可用操作列表,避免在模板里写一堆v-if判断。

4. 前后端联调、数据库初始化与部署

4.1 接口设计规范

前后端分离项目最怕接口各写各的,联调阶段一片混乱。我在这套系统里定了几条规矩:

  • 路径用名词复数,比如/api/appointments/api/vehicles
  • 状态更新用 PUT 或 PATCH,不用 GET 传状态。
  • 分页参数统一为pageNumpageSize,返回结构统一为{ code, msg, data: { list, total } }
  • 时间字段统一传字符串,格式yyyy-MM-dd HH:mm:ss,避免前后端时区不一致。

这几个约定虽然简单,但能省掉大量沟通成本。后端写接口文档我用的是 knife4j(Swagger 增强版),自动生成在线接口文档,前端团队照着调就行。

4.2 跨域处理的两种落地方式

开发环境我推荐用 Vite 代理,不折腾后端 CORS。vite.config.js里配置:

server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }

生产环境用 Nginx 反向代理,同时解决跨域和静态资源托管问题:

server { listen 80; server_name your-domain.com; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }

注意try_files那行必须写,否则前端路由用 history 模式刷新会 404。

如果非要在后端用 CORS,SpringBoot 里加一个配置类就行,生产上我一般不用,交给 Nginx 更省心。

4.3 MySQL 初始化与连接配置

标题里提到 MySQL,这块很关键。我从官网下载 MySQL 8.0 安装包,安装时选开发环境默认配置,端口 3306,字符集选 utf8mb4。初始化数据库执行项目的sql/init.sql

连接配置在application.yml里:

spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/car_repair?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: your_password jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

这里几个参数都是血泪教训。serverTimezone必须显式设置为Asia/Shanghai,否则 MySQL 8.0 会报时区错误或者差 8 小时。allowPublicKeyRetrieval=true是 MySQL 8.0 连接时常见的认证插件问题,不加会报 “Public Key Retrieval is not allowed”。map-underscore-to-camel-case开启后,数据库appoint_time就能自动映射到 Java 的appointTime,否则查出来全是 null。

4.4 打包部署流程

部署我分三步走:

第一步,前端构建。在项目根目录执行npm run build,产物在dist/目录。放到 Nginx 的 html 目录或者用 Docker 挂载。

第二步,后端打包。执行mvn clean package -DskipTests,生成car-repair.jar。用java -jar car-repair.jar --spring.profiles.active=prod启动。

第三步,数据库导入。生产环境执行sql/init.sql,如果是已有数据,建议用 Flyway 管理增量 SQL,避免手动改表。

热词里有人问“SpringBoot + MyBatis 当表不存在自动建表”,这里提一下:MyBatis 本身不做建表,SpringBoot 的spring.sql.init.mode=always可以启动时自动执行schema.sql,但只适合开发环境。生产环境更推荐 Flyway 或者直接人工维护 SQL 脚本,因为自动建表容易覆盖不了索引和字段变更,反而埋雷。

5. 常见问题与排查实录

5.1 高频问题速查表

很多同学照着代码敲完,跑不起来,问题大多集中在以下几类:

问题现象可能原因解决办法
前端请求报 CORS 错误跨域未处理开发用 Vite proxy,生产用 Nginx 代理
后端返回日期是时间戳数字Jackson 时间格式没配yml 配置 jackson.date-format 和 time-zone
MyBatis 查出的对象部分字段为 null驼峰映射未开启mybatis.configuration.map-underscore-to-camel-case=true
数据库连接报 Public Key Retrieval 错误MySQL 8.0 认证方式问题JDBC URL 加 allowPublicKeyRetrieval=true
同一技师同一时段被重复预约并发控制缺失加唯一索引 + 事务 + FOR UPDATE
前端 history 路由刷新 404Nginx 没有回退到 index.htmltry_files $uri $uri/ /index.html;
SQL 查询很慢联表字段没索引外键字段建索引,状态字段也建索引
页面请求 401 循环跳登录拦截器没放行登录接口在拦截器白名单加 /api/auth/login

5.2 三个真实排查案例

案例一:预约时间差 8 小时。前端选的下午三点,后端存进去变成早上七点。排查后是 JDBC URL 没有指定serverTimezone=Asia/Shanghai,MySQL 用了系统默认时区,和 JVM 时区对不上。这个最容易忽略,一定要在 URL 里写死。

案例二:MyBatis 联表查询,VO 里有个字段一直返回 null。查了半天,发现 XML 里 SQL 别名写的是owner_name,但 VO 属性是ownerName,而项目里map-underscore-to-camel-case只对 mapping 的 resultMap 生效。我用的是resultType映射,别名必须和属性完全一致。解决方案要么改成AS ownerName,要么建 resultMap 显式映射。

案例三:管理员在后台“确认预约”时,前端报“系统繁忙”,后端日志显示死锁。原因是FOR UPDATE的锁顺序不一致,一个事务先锁 appointment 再锁 work_order,另一个事务先锁 work_order 再锁 appointment,互相等待造成死锁。解决方法是统一锁顺序,先操作 appointment 再操作 work_order,并且把事务尽量缩短,不把无关的网络请求包进事务里。

5.3 开发中好用的辅助工具

排 SQL 问题的时候,IDEA 插件MyBatis Log Free简直是神器。它可以把 MyBatis 打印出来的预编译 SQL 和参数自动拼成可执行的完整 SQL,直接复制到 Navicat 或者 MySQL Workbench 里跑,排查效率翻倍。

日常调试我还习惯在 yml 里显式开启 SQL 打印:

mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

注意这个配置只放开发环境,生产环境一定关掉,否则日志量太大,而且会把数据参数打到日志里,有泄露风险。

写在最后

我个人在实际操作中的体会是,这类预约业务系统,技术难点真的不在框架本身,而在于状态管理和并发控制。把预约单的状态机设计得清清楚楚,把数据库唯一索引和事务用好,这套系统基本就稳了。Vue3 前端方面,最值得花时间的是权限菜单和预约看板这类通用组件,做一次沉淀下来,后面接类似的系统能省不少时间。

最后再分享一个扩展思路:这套系统目前是单门店版本,后续可以加一个门店维度字段,做成多门店预约中心;预约确认前能接短信通知;维修完成后引导车主评价回访。数据库表结构在设计时已经预留了store_idsource_type这类字段,扩展起来不用大改,直接把业务宽度拉上去。做毕设或者接私活,这些都是很好的加分项。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 11:05:54

超导磁能储存系统SMES的Simulink建模与仿真实践

提到储能&#xff0c;很多人第一反应是锂电池、抽水蓄能或者飞轮&#xff0c;但在电力电子和电力系统领域&#xff0c;超导磁能储存系统&#xff08;SMES&#xff09;一直是个独特存在。它不是通过化学能或势能存储能量&#xff0c;而是直接把电能以磁场形式“锁”在超导线圈里…

作者头像 李华
网站建设 2026/9/9 11:05:41

Android利用Onvif实现局域网摄像头发现与RTSP取流的完整实践

简介&#xff1a;在Android平台上使用Onvif协议完成局域网摄像头自动发现&#xff0c;并获取可播放的视频流地址&#xff0c;是安防与物联网项目开发中常见的需求。资源面向具备Android基础、需要对接不同品牌Onvif兼容设备的开发者&#xff0c;围绕设备发现、身份认证、媒体服…

作者头像 李华
网站建设 2026/9/9 11:03:48

Scrapy分布式爬虫调试与性能优化实战指南

分布式爬虫跑到第三周&#xff0c;你大概率会遇到一个特别无语的场面&#xff1a;任务还在跑&#xff0c;数据量却在悄悄往下掉。Redis队列里的待抓取URL越积越多&#xff0c;日志里没有任何报错&#xff0c;每台节点的CPU看起来也不高&#xff0c;但整个集群就是越来越慢。更折…

作者头像 李华
网站建设 2026/9/9 11:03:15

outskirts和suburb区别:从边缘位置到功能社区,一文讲透

先说结论&#xff1a;outskirts 和 suburb 虽然中文里都能翻译成“郊区”&#xff0c;但它们在英文里的使用场景、语义边界和语感完全不是一回事。你拿“suburbs”去描述一个荒凉的公路边缘地带&#xff0c;或者在正式文书里用“outskirts”指代一个成熟的居住社区&#xff0c;…

作者头像 李华
网站建设 2026/9/9 11:01:50

强化学习模型部署到RK3566足式机器人:从GPU到ARM的完整踩坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/9 11:01:43

2026年9月广州亨得利腕表官方售后门店服务体验,从到店接待、现场沟通到消费反馈

2026年9月广州亨得利腕表官方售后门店服务体验&#xff0c;从到店接待、现场沟通到消费反馈前言 广州亨得利腕表官方售后正规直营门店地址为广州市天河区天河路208号粤海天河城大厦12 F03-04&#xff0c;是广州本地完成备案的腕表售后服务门店。本文更新时间为2026年9月。写这篇…

作者头像 李华