SpringBoot+Vue3汽车租赁管理系统:时间冲突检测与状态机设计实战
我去年完整做了一套汽车租赁管理系统技术栈就是标题里那套SpringBoot Vue3 MyBatis MySQL前后端分离。做之前以为就是一个普通的业务管理系统做顺了才发现租赁类业务和常规CRUD完全是两码事——它有一个绕不开的核心约束同一辆车在同一时间段只能被一个订单占用。这个时间窗口的冲突检测加上订单从下单到还车的全生命周期状态流转才是这套系统真正有含金量的地方。这篇文章把我从头到尾的设计思路、表结构设计、核心接口逻辑、前端交互方案以及实测中踩过的坑完整复盘一遍。不管你是正在准备Java全栈项目、想充实简历的应届生还是公司要快速落地一套租赁类业务后台这套设计都能直接参考。1. 技术栈选型为什么是SpringBootVue3MyBatis而不是别家组合1.1 四个组件各自解决什么问题这套技术栈放到今天已经不算新潮但它依旧是Java企业级项目里最稳的组合。SpringBoot负责把后端的集成工作简化到极致嵌入式Tomcat、自动配置、starter机制省去大量XML配置MyBatis负责SQL的可控性租赁系统里查询条件特别多车型、价格区间、状态、租期组合查询一多动态SQL的价值就体现出来了Vue3配合Vite和Element Plus做后台管理界面和用户端页面都很顺手MySQL负责数据持久化轻量、稳定、资料多作为中小型业务系统的数据库完全够用。有人会问现在MyBatis-Plus这么流行为什么不用两个原因。第一这个项目体量不大原生MyBatis完全能把SQL控制住不会出现几百行动态SQL的极端场景第二MyBatis的动态SQL和XML映射是Java面试常考点用原生方式把这块吃透对面试帮助更大。实际开发中团队用MP还是原生那是团队习惯问题但对于个人项目我建议至少要能独立写出XML里的动态SQL。1.2 项目边界这个系统到底做什么做一个系统之前先把边界划清楚否则容易越做越失控。我的设计是两类角色、两条主线。普通用户能做的事注册登录、浏览车辆、选择租期下单、在线支付押金和租金、查看个人订单、还车结算。管理员能做的事车辆增删改查、车辆上架下架、订单审核、确认取车、确认还车、订单结算。这个边界划好后前后端的功能范围就定了接口数量也能数得出来。全系统核心接口大概二十来个后端分用户模块、车辆模块、订单模块前端分用户端和管理端两个独立视图。边界清晰的好处是开发过程中不会出现“要不要加个站内信”、“要不要做积分系统”这种没完没了的需求蔓延。1.3 前后端分离的关键收益前后端分离不是赶时髦对这个项目来说有几个实际收益。第一开发过程中后端不需要关心页面长什么样只返回JSON前端也不需要关心SQL怎么查只负责渲染和交互我可以并行推进第二部署上后端是一个jar包前端是一堆静态文件可以单独发布前端改样式不用重新打包后端第三将来如果要做小程序端或者App端后端接口可以直接复用不用重写。代价也有就是联调成本变高了。前端需要知道后端接口返回的数据结构后端需要知道前端需要什么字段。我的办法是后端统一返回结构所有数据都包在Result对象里前端拿到后按约定解析。这个统一约定后面专门有一节说。2. 数据库模型车辆、订单、用户三张核心表的设计逻辑2.1 核心表结构与建表SQL数据库设计是这套系统的地基。我用的核心表就三张用户表、车辆表、订单表。不要加太多冗余表更不要用物理外键业务关系靠代码逻辑维护索引来保证查询性能。用户表id、用户名、密码、手机号、身份证号、角色、状态、创建时间。密码我用了BCrypt加密存储明文存密码在真实项目里是绝对红线。数据库字段类型金额用decimal时间用datetime状态用tinyint。车辆表这里信息量比较大我贴一下实际的建表SQLCREATE TABLE car ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 车辆ID, brand VARCHAR(50) NOT NULL COMMENT 品牌, model VARCHAR(50) NOT NULL COMMENT 车型, plate_number VARCHAR(20) NOT NULL UNIQUE COMMENT 车牌号, category TINYINT NOT NULL DEFAULT 0 COMMENT 车型分类 0-经济型 1-SUV 2-商务型 3-豪华型, daily_price DECIMAL(10,2) NOT NULL COMMENT 日租金, deposit DECIMAL(10,2) NOT NULL COMMENT 押金, status TINYINT NOT NULL DEFAULT 0 COMMENT 状态 0-可租 1-已租出 2-维修中 3-已下架, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT车辆表; CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 订单ID, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT 订单编号, user_id BIGINT NOT NULL COMMENT 用户ID, car_id BIGINT NOT NULL COMMENT 车辆ID, start_time DATETIME NOT NULL COMMENT 取车时间, end_time DATETIME NOT NULL COMMENT 还车时间, daily_price DECIMAL(10,2) NOT NULL COMMENT 下单时日租金快照, total_days INT NOT NULL COMMENT 租期天数, amount DECIMAL(10,2) NOT NULL COMMENT 租金金额, deposit DECIMAL(10,2) NOT NULL COMMENT 押金金额, status TINYINT NOT NULL DEFAULT 0 COMMENT 状态 0-待支付 1-已支付待取车 2-租赁中 3-已完成 4-已取消, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 下单时间, pay_time DATETIME NULL COMMENT 支付时间, finish_time DATETIME NULL COMMENT 完成时间, INDEX idx_user_id (user_id), INDEX idx_car_id (car_id), INDEX idx_start_time (start_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表;有几个字段容易被忽略我单独说。orders表里的daily_price字段是“下单时日租金快照”这个很重要。车辆日常价格会变比如节假日调价订单一旦创建租金就得按下单那一刻的价格算不能在结算时又去读车辆表的最新价格否则用户会投诉。这是一个很典型的订单设计原则金额类信息必须做快照不做实时关联。订单编号我用的是当前时间加随机数生成格式类似yyyyMMddHHmmss 4位随机数保证同一秒内并发也不会重复。生产环境可以用雪花ID或者Redis自增个人项目这个简化方案完全够。2.2 车辆和订单的状态机设计状态字段我全部用tinyint从0开始递增。为什么不用字符串三个原因省空间、查询快、代码里定义常量类后可读性也不差。真正要注意的是别把魔法数字写死在代码里我在后端建了一个枚举类把所有状态做成枚举前端再用映射字典把数字翻译成中文标签。订单状态流转是最核心的业务规则。从用户角度看是这个链路待支付 - 已支付待取车 - 租赁中 - 已完成任意状态下取消则进入已取消。从管理员角度看这里面有两次人工确认动作确认取车和确认还车。为什么要人工确认因为线下要验车车况没问题才交接这个动作必须由管理员触发不能全自动。状态流转用表格表示当前状态触发动作下一状态谁触发0 待支付支付成功1 已支付待取车用户0 待支付取消订单4 已取消用户1 已支付待取车管理员确认取车2 租赁中管理员2 租赁中管理员确认还车3 已完成管理员1 已支付待取车超时未取车4 已取消系统/管理员这套状态机定义清晰后代码里每个状态下的按钮、接口逻辑、车辆状态配合都变得非常明确不需要在每个接口里临时判断一堆if。2.3 时间冲突检测租赁系统最核心的业务校验这是整套系统最有含金量的地方。车辆表里有status字段标记是否在租但只有这个还不够——一个订单还没创建时车是“可租”的可一旦有人在某个时间段已经预定了又没到取车时间车已经处于被占用的窗口期。这个时候需要靠订单表来判断。判断逻辑可以归结为一句话新订单的租期 [newStart, newEnd] 与某个已占用订单的租期 [oldStart, oldEnd] 是否存在重叠区间。SQL写法是这样的SELECT COUNT(*) FROM orders WHERE car_id #{carId} AND status IN (1, 2) AND start_time #{newEndTime} AND end_time #{newStartTime}这里status IN (1, 2)排除了已取消和已完成的订单。这个区间重叠判断覆盖了四种情况新订单完全包含旧订单、新订单被旧订单包含、新订单左端与旧订单右端重叠、新订单右端与旧订单左端重叠。凡是这四种情况里任意一种count都大于0就不能下单。边界情况要注意处理有人会问前一个订单还车时间是9:00新订单取车时间也是9:00算不算冲突严格说这取决于业务规则。我的规则是不允许边界紧贴所以SQL里用的是和不是和。如果业务上允许9点整还车9点整租出那要用和处理。这个细节最好和业务方确认清楚再定。3. 后端实现里容易翻车的三个点金额、状态、动态SQL3.1 金额计算与BigDecimal的精度陷阱租金计算看起来简单日租金 × 天数 押金但实操里有两个坑。第一个坑是数据类型金额一律用BigDecimal计算数据库里用decimal(10,2)绝不能用float或double。0.1 0.2 在double里的结果是0.30000000000000004这个精度错误在金额上会造成分账差错测试时还不容易发现。第二个坑是租期天数的计算逻辑两个datetime之间做差得到毫秒数再除以一天的毫秒数这里牵扯时区问题取整规则也要定好。我这里的使用者是按整天租赁的所以天数计算采用向上取整当天取当晚还也算一天。代码很简单long diffMs endTime.getTime() - startTime.getTime(); int totalDays (int) Math.ceil(diffMs / (1000.0 * 3600 * 24)); BigDecimal amount dailyPrice.multiply(BigDecimal.valueOf(totalDays));这个计算逻辑后端写好后前端也必须要写一模一样的预估价逻辑。我的做法是在前端展示预估费用但最终费用以后端订单详情里的金额为准。如果两边口径不一致用户看到预估100元下单后变成120元体验非常差。3.2 防止“抢车”条件更新比先查再改可靠租赁系统并发场景下容易出现一个bug两个用户同时看中同一辆车同时提交订单结果两个订单都创建成功。解决办法是两层防护。第一层创建订单前执行上面那个冲突检测SQLcount为0才允许创建第二层创建订单时会同步把车辆状态改为已租出这个更新操作必须加条件UPDATE car SET status 1 WHERE id #{carId} AND status 0注意这里的关键点update语句里带了AND status 0这个条件。如果车辆已经被其他订单改成了已租出这条update受影响的行数就是0通过Java代码判断affectedRows如果为0就抛出异常让当前事务回滚这样即使两个用户同时提交也只有一个能成功。这个设计比“先SELECT再UPDATE”的常规写法更可靠因为在高并发环境下select之后、update之前的那段时间里车辆状态可能已经被别人改了。条件更新本质上是一种乐观锁思路用状态本身做版本校验。3.3 MyBatis动态SQL和事务边界MyBatis的动态SQL是查询条件不确定时的核心武器。比如车辆列表页用户可以按品牌模糊查、按车型分类查、按价格区段查、按状态查这些条件任意组合。用XML的 标签加 标签就能轻松搞定select idlistCars resultTypecom.example.entity.Car SELECT * FROM car where if testbrand ! null and brand ! AND brand LIKE CONCAT(%, #{brand}, %) /if if testcategory ! null AND category #{category} /if if teststatus ! null AND status #{status} /if /where ORDER BY create_time DESC /select标签的好处是如果所有条件都不满足它不会生成WHERE关键字如果第一个条件成立它会自动去掉多出来的AND。这里要注意 里的判空不仅要判null还要判空字符串尤其前端传空字符串的时候brand ! null拦不住。事务边界是另一个容易翻车的地方。创建订单这个操作涉及三件事插入订单记录、更新车辆状态为已租出、记录支付流水。这三个操作必须在一个事务里任何一个失败都要全部回滚否则会出现订单存在但车辆状态没改的脏数据。实现方式就是在service方法上添加Transactional注解。但要注意一个Spring事务自调用失效的问题同一个类里方法A调用方法BB上面有Transactional是无效的事务不会开启。事务方法是代理对象调用才生效所以事务逻辑要设计在独立的service类里别在controller里写业务事务也别在一个类内部调来调去。4. Vue3前端用户端和管理端分开设计4.1 前端工程结构和路由设计前端我用Vite初始化Vue3项目用TypeScript还是JavaScript这个项目我用的是JavaScript团队项目用TS另说个人项目里JS改起来更快。UI库用Element Plus状态管理用PiniaHTTP请求用Axios。目录结构比较常规src/ api/ 接口请求定义 store/ Pinia状态管理存token和用户信息 router/ 路由配置 views/user/ 用户端页面 views/admin/ 管理端页面 utils/ axios实例和工具函数路由这块用前端路由守卫配合后端鉴权双层控制。前端路由分为用户端和管理端两组管理员登录后才显示管理端菜单普通用户访问管理端路由直接跳首页。后端的拦截器还要再校验一次两层的权限拦截不能互相替代。4.2 用户端选车、下单、支付的交互链路用户端的核心交互是四个步骤浏览车辆 - 查看详情 - 选时间下单 - 支付。车辆列表页用卡片展示每辆车显示名称、分类标签、日租金、押金、当前状态。状态为“可租”的车辆才可以点击查看详情其他状态置灰。这个细节很重要用户看到一辆在租的车点进去发现下不了单体验很差。车辆详情页是交互最关键的地方。页面里放了两个日期选择器分别是取车时间和还车时间默认取车时间是明天还车时间是后天。用户一选完时间前端立刻计算预估费用并展示出来。计算公式和后端完全一致const diffMs new Date(endTime).getTime() - new Date(startTime).getTime() const days Math.ceil(diffMs / (1000 * 3600 * 24)) const estimate dailyPrice * days deposit用户点击下单前端把carId、startTime、endTime提交到后端。后端返回订单ID和应付金额前端弹一个支付确认框上面显示明细租金多少、押金多少、合计多少。本项目用模拟支付点确认就调用支付接口把订单状态从待支付改成已支付待取车。真实项目中这一步要对接微信或支付宝逻辑上只是把模拟支付的接口换成真正的支付回调。4.3 管理端用状态驱动按钮显隐管理端订单管理页是最能体现状态机设计价值的地方。页面用表格展示所有订单每一行根据订单状态显示不同的操作按钮订单状态显示的操作按钮0 待支付查看详情、取消1 已支付待取车确认取车2 租赁中确认还车3 已完成查看详情4 已取消查看详情Element Plus的el-table里可以通过条件渲染动态控制按钮是否显示操作列根据状态对象里的属性来判断。这样做的好处是代码逻辑非常简单不会出现一堆乱七八糟的v-if每个状态对应什么操作是固定的数据驱动的思路。车辆管理页相对简单表格展示车辆列表新增和编辑用弹窗表单。这里容易被忽略的是车辆分类和日租金的表单校验分类用一个下拉框取值范围要和后端枚举对齐。还有“上架/下架”操作下架的车不允许再下单这个操作本质就是更新车辆status字段为3。4.4 前端状态管理和权限控制用户登录后的token、用户基本信息、角色信息我都放在Pinia里。Axios的请求拦截器统一从store里取token加到Authorization请求头响应拦截器判断后端返回code如果不是200就弹出错误提示如果是401表示token过期清除本地登录状态跳转登录页。这个模式几乎是Vue3前后端分离项目里固定套路但值得注意的一点是响应拦截器的错误提示不能太生硬要区分是网络错误还是业务错误业务错误比如库存不足要用后端的message内容提示网络错误则统一提示“网络异常请稍后重试”。5. 前后端联调的关键细节统一返回结构、JWT鉴权、跨域5.1 统一接口返回结构前后端分离的项目最忌讳各写各的后端返回原生对象字段名一个叫createTime前端需要的却是createdAt联调时改来改去。我从一开始就定了统一的返回结构{ code: 200, message: success, data: {} }code为200表示成功非200表示业务异常message里放给用户看的提示信息data里放业务数据。后端所有接口都返回这个Result对象泛型保证data的类型安全。这么做的好处是前端的响应拦截器可以全局统一处理code为200时直接返回data非200时统一弹message。前端每个请求都不用重复写错误处理代码。5.2 JWT登录态设计前后端分离后服务端不再维护Session登录态用什么方案我用的是JWT。流程是登录接口校验用户名密码成功后后端生成一个token返回给前端前端存到localStorage里之后每次请求都在Authorization头带上这个token后端拦截器解析token获取用户信息。生成token我用的是jjwt库token里可以放userId和role过期时间设24小时。后端写一个拦截器在HandlerInterceptor的preHandle里解析token解析成功就放行失败就返回401。要注意的是拦截器放行的白名单要放好登录、注册、车辆列表查询这些接口是不需要登录就能访问的不能拦截但下单、支付、管理端所有接口必须登录后才能访问。管理员的接口校验要加角色判断普通用户拿自己的token调管理员接口也必须拒绝。5.3 开发环境和生产环境的跨域处理开发阶段前后端分离前端跑在5173端口后端跑在8080端口跨域问题避免不了。我的方案是开发环境用Vite的proxy代理把前端的/api请求转发到后端// vite.config.js server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }生产环境则用Nginx反向代理让前端静态页面和后端接口在同一个域名下彻底避免跨域问题。Nginx配置里把/api路径转发到后端服务其他路径返回前端dist目录的静态文件。我在部署时会加上try_files配置后面踩坑记录里会详细说。6. 打包部署与实测踩坑记录6.1 前后端打包流程后端打包用Maven命令很简单mvn clean package -DskipTests生成的jar在target目录直接java -jar跑起来。前端打包用Vitenpm run build生成dist目录。生产部署我是把后端jar和前端dist都放到同一台服务器上后端跑SpringBoot服务监听8080端口前端dist目录由Nginx托管。6.2 我在实测中遇到的真坑第一个坑是MySQL时区导致的日期错乱。系统上线后发现订单的创建时间比本地时间晚了8个小时原因是JDBC连接串里没配时区。解决方法是连接串加上参数jdbc:mysql://localhost:3306/car_rental?serverTimezoneAsia/ShanghaicharacterEncodingutf8useSSLfalse这个坑在本地开发时可能不出现因为本地MySQL时区恰好和系统一致部署到云服务器或者用Docker跑MySQL时就会暴露。遇到时间相关的问题第一反应就是查时区配置。第二个坑是Linux下MySQL表名大小写敏感。本地Windows开发时一切正常部署到Linux服务器后程序报“Table car_rental.CAR doesnt exist”因为我的SQL里写的是大写表名Windows下MySQL默认不区分大小写Linux下区分。解决方法两种要么所有SQL统一小写表名要么在MySQL配置里加lower_case_table_names1。个人项目改SQL统一比较稳妥。第三个坑是Vue打包部署后刷新页面出现404。原因很简单前端路由用的是history模式Nginx默认配置下刷新 /orders/123 这样的路径时Nginx去磁盘上找这个路径对应的文件找不到就404。要在Nginx配置里加上location / { try_files $uri $uri/ /index.html; }这个配置的意思是如果请求的路径不存在就回退到index.html让前端路由自己解析。第四个坑是业务层面的订单取消或还车后车辆状态要记得恢复。这个看起来理所当然但我在开发时漏过用户下单后取消订单订单状态改成已取消但车辆状态还是已租出这辆车就永远租不出去了。后来在service方法里严格按状态机流转订单取消的同时把车辆状态改回可租并且放在同一个事务里才算彻底解决。6.3 这个项目的后续扩展空间系统做完了功能上是完整的但还有几个方向可以继续扩展。第一个是把前端预估费用的逻辑抽出来独立成一个费用计算工具后续如果支持半天租、按时租前后端只改这个工具就行。第二个是给车辆加价格日历节假日和旺季可以动态调整日租金这个功能在真正的租车公司里几乎必备。第三个是增加一些统计报表比如车辆出租率、订单收入趋势、热门车型排行管理端用ECharts画几个图表项目看起来也更完整。第四个是把管理端的角色再细分比如操作员和超级管理员操作员只能处理订单超级管理员才能改车辆配置。说回到这套系统本身我个人实测下来最大的体会是租赁类业务系统的难度不在增删改查而在业务约束的完整性。一辆车的状态从可租到被租再到还车背后牵扯的是时间段冲突检测、事务边界、状态流转的异常恢复这些逻辑捋清楚了代码写起来会非常顺。这套设计不止适用于汽车租赁共享单车、酒店房间预约、机械设备租借核心模型都可以直接平移过去。如果你正在找项目练手我建议在跑通基础功能后真实地给自己设计几个并发场景去压一压比如同时抢同一辆车、订单取消的瞬间又有新用户下单把这些场景都处理干净这个项目就算真正吃透了。