门店租车最怕的不是车不够,而是明明有车却不知道车在哪,订单要靠Excel手工登记,计费靠口算,还车时超时费扯皮。这套基于SpringBoot+Vue的租车服务管理系统,就是把“车辆、订单、用户、计费”整套流程线上化,从租车预约、取车、还车到费用结算,全部在系统里闭环跑完。不管是做毕设还是给中小型租车门店做信息化改造,这套东西都很值得参考。
我会按实际开发顺序来拆,先把业务想清楚,再聊架构设计、数据库建模,然后重点讲后端订单状态机和计费引擎怎么写,前端页面怎么和接口配合,最后把我开发过程中踩过的坑和排查过程梳理一遍。
1. 项目定位与核心需求拆解
1.1 租车业务到底在管什么
租车服务管理系统本质上是一个“资产流转”系统,核心管理对象是车,核心业务动作是“把车交出去”和“把车收回来”。在中间这一段,车处于什么状态、被谁使用、该收多少钱,这三个问题必须实时回答清楚。
我接手这个项目时,门店上午登记租车,下午还车,全靠前台手写一张租车单,车型、车牌、里程、油量全靠人眼核对。遇到两辆车同时看中同一台车,谁先谁后也凭前台印象。这种模式的问题不是效率低,而是数据一旦丢就全乱了,计费和库存根本无法追溯。
所以在设计系统时,我没有先急着写代码,而是把业务拆成三条主线:车辆生命周期管理、订单全流程跟踪、费用自动计算。用户注册登录后可以浏览车辆、提交租车订单;管理员负责审核订单、办理取车还车、维护车辆信息;财务和店长需要看到每日营收和车辆利用率。
1.2 核心功能模块与角色权限划分
系统的角色我划分成了三种:普通用户、门店管理员、系统管理员。普通用户就是客户,主要用C端功能;门店管理员操作车辆和订单;系统管理员管用户和权限。
我整理了一张功能清单:
| 模块 | 功能点 | 使用角色 |
|---|---|---|
| 用户模块 | 注册、登录、个人信息、押金账户 | 用户、管理员 |
| 车辆模块 | 车辆新增/编辑、上下架、状态跟踪 | 管理员 |
| 订单模块 | 用户下单、管理员审核、取车还车登记 | 用户、管理员 |
| 计费模块 | 按小时/按天计费、超时费、优惠计算 | 系统自动 |
| 统计模块 | 订单量、营收、车辆利用率报表 | 管理员、老板 |
| 系统管理 | 角色权限、操作日志、公告设置 | 系统管理员 |
订单模块是核心中的核心,它串起了用户、车辆、金额三个实体。用户提交订单时,车辆必须是“可租”状态;管理员确认取车后,车辆状态变成“租用中”;还车时系统根据实际时长和里程计算总费用。
1.3 非功能性需求的现实约束
除了业务功能,还要考虑并发和异常场景。节假日是租车高峰期,多个用户同时抢同一辆车的情况非常常见。如果系统不做并发控制,库存或车辆状态就可能被超卖。
另外,租车系统的计费涉及金额,不能有半点马虎。数据库事务一定要用好,特别是还车结算时,需要同时完成订单状态更新、车辆状态恢复、费用记录写入,任何一个环节失败都不能留下半截数据。这些约束在需求阶段不明确,到了后期就是大坑,我后面专门有一节讲这个。
2. 架构设计与数据库建模
2.1 为什么选择SpringBoot+Vue的前后端分离方案
选择SpringBoot+Vue,最重要的原因是生态成熟、招聘和参考资料多。SpringBoot让后端开发不需要再折腾复杂的XML配置,内嵌Tomcat,打好包就能跑;Vue的前端组件化开发非常适合管理后台这类交互密集型的项目。
前后端分离另一个好处是可以分别独立开发和部署。后端只提供RESTful接口,前端通过HTTP请求拿数据。我这边前后端是两个端口在跑,前端开发服务器走代理转发到后端,生产环境再通过Nginx把接口代理过去,整个链路非常清晰。
有人会问为什么不直接用JSP或Thymeleaf模板渲染。我只能说,如果你只需要做一两个简单页面,模板渲染确实够用;但租车管理涉及车辆列表筛选、订单表单、状态流转提示、统计图表,页面交互复杂度高,Vue的响应式数据绑定和组件复用会让开发体验好太多。
2.2 技术栈选型清单
后端我选的是SpringBoot 3.x,搭配MyBatis-Plus做数据持久层。很多人纠结用JPA还是MyBatis,我的习惯是,业务查询相对复杂、需要自己控制SQL的项目用MyBatis-Plus,它提供了单表CRUD封装,复杂查询又能写XML,两边都不耽误。
前端是Vue 3 + Vite + Element Plus + Pinia。Vite的冷启动比Webpack快太多,Element Plus的表格、表单、弹窗组件对后台系统来说完全是量身定做。状态管理用Pinia,简单直接,没有Vuex那么多样板代码。
数据库用的MySQL 8.0,Redis作为缓存和分布式锁的候选方案,但真正常用的还是MySQL,Redis主要存验证码这类临时数据。鉴权方案是JWT + Spring Security,密码加密用BCrypt。
2.3 核心表设计与关系说明
数据库设计是项目的地基,我一开始就把表结构敲定了,后面改动就少了。核心表有六张:用户表、车辆表、订单表、费用表、角色表、操作日志表。
| 表名 | 关键字段 | 说明 |
|---|---|---|
| t_user | id, username, password, phone, role_id | 用户基础信息 |
| t_car | id, car_no, brand, model, daily_price, status | 车牌唯一,状态区分可租/出租中/维修 |
| t_order | id, order_no, user_id, car_id, start_time, end_time, status | 订单核心表 |
| t_fee | id, order_id, amount, type, create_time | 费用明细,区分租金/押金/违约金 |
| t_role | id, role_name, permissions | 角色和权限 |
| t_log | id, user_id, action, detail, create_time | 操作日志 |
订单表和车辆表之间是外键关联,但我在实际代码中没有单纯依赖外键约束,而是通过应用层逻辑保证关系。比如订单存在时不允许删除车辆,这样控制更灵活,也避免数据库约束引起不必要的麻烦。车辆表的st字段我用了tinyint,0代表可租,1代表出租中,2代表维修,3代表下架,方便SQL查询和索引优化。
关键查询表都要加索引。订单表我建了(user_id, status)联合索引,车辆表给status单列索引,统计报表查询再按月创建时间走索引。数据量不大的时候索引效果不明显,但等系统跑了一两年,订单数据积累到几万条之后,没有索引的查询会直接拖垮页面响应。
3. 后端:从骨架到业务闭环
3.1 统一响应体、异常处理与日志
后端开发的第一步不是写业务代码,而是搭好公共模块。我定义了一个统一的返回结构Result,包含code、message、data三个字段。所有Controller接口都返回这个结构,前端拿到之后统一判断code是否为200,再决定是渲染数据还是弹错误提示。
配合统一返回体,我还加了全局异常处理器。业务异常、参数校验异常、未知异常分别处理。业务异常通过自定义的BusinessException抛出,前端可以看到明确的错误信息,比方说“该车辆已被预约,请选择其他车辆”;未知异常则返回“系统繁忙,请稍后再试”,避免把堆栈信息暴露给用户。
日志这块我用了SLF4J,关键操作都打点跟踪。创建订单、审核通过、还车结算都记录操作人和操作内容。前期也许觉得日志无所谓,真出了线上问题,日志就是唯一的线索。租车项目涉及钱,审计追踪是必须的。
3.2 登录鉴权与角色控制
登录这块我直接用Spring Security做过滤链,JWT做无状态认证。用户登录成功后,后端生成一个包含用户ID和角色信息的Token返回给前端,前端每次请求在Header里带上这个Token,后端通过过滤器解析Token并放入SecurityContext。
密码加密必须用BCrypt,不要自己写MD5加盐。MD5虽然也能加密,但暴力破解成本低,BCrypt内部自动加盐,同样的密码每次生成的哈希都不同,安全性高很多。
角色控制上,接口上用@PreAuthorize注解做方法级鉴权。比如车主增删改接口只允许ADMIN角色访问,普通用户角色调用就直接返回403。Spring Security的注解方式非常方便,不需要在每个Controller里手动判断角色。
3.3 车辆管理与库存扣减
车辆管理本身不复杂,难点在于“防止同一辆车被重复下单”。用户查询车辆列表时看到的是可租状态,但在他浏览的这几分钟里,其他人可能已经提交了订单。
我最初的做法是:用户提交订单时,后端先检查车辆状态是否为可租,然后更新车辆状态为“锁定”。但这里存在并发问题,两个请求同时查到状态为可租,同时执行更新,就可能都成功。解决办法是使用乐观锁或Redis分布式锁。
我最后选了数据库层面的悲观锁,对车辆记录执行SELECT ... FOR UPDATE,锁住这条记录后再判断状态并更新。这个方案在单库环境下最直接可靠,Redis分布式锁虽然也行,但引入额外组件,还要考虑锁超时和重入问题,对于项目规模来说反而复杂。核心代码思路如下:
// 在事务方法中 CarEntity car = carMapper.selectByIdForUpdate(carId); if (car.getStatus() != CarStatus.AVAILABLE) { throw new BusinessException("该车辆已被预约"); } car.setStatus(CarStatus.LOCKED); carMapper.updateById(car);这里要注意,FOR UPDATE必须放在事务里才生效,而且查询条件必须走主键或索引,避免锁住整个表。我在排查一个问题时发现,如果条件字段没有索引,MySQL会锁全表,那性能就废了。
3.4 订单状态机与计费引擎
订单状态我用一个数字字段表示,0代表待取车,1代表租用中,2代表已完成,3代表已取消。取车时管理员操作,让订单从0变成1,同时车辆状态变成出租中;还车时订单从1变成2,车辆恢复为可租。
这里最容易出的问题是状态跳转校验不严。我见过有系统直接UPDATE订单SET状态=2,对于已取消的订单也能还车成功,这就是状态机没有做约束导致的。我在Service层写了状态流转校验方法,只有当前状态等于允许的前置状态时才能推进,否则抛异常。
计费引擎是租车系统里最有意思的部分。我设计了一套规则:取车时间和还车时间,系统自动计算小时数,不足半天按小时计费,超过半天按天计费。租金单价根据车辆类型和租期档位各有不同。超时费单独计算,超过预定还车时间2小时内不额外收费,超过2小时按一小时的租金单价收。
计算过程全部放在Service层,金额用BigDecimal,绝对不用double或float,否则一分钱误差都解释不清楚。订单金额、实收金额、优惠金额分开存,要能追溯到每笔费用是怎么算出来的。
4. 前端:页面、状态与接口联动
4.1 Vue工程初始化与路由设计
前端这块我用Vue 3的Composition API来写。项目的目录结构按模块划分,views下面有user、order、car、stats等子目录,公共组件放在components里,API请求统一放在api目录下。
路由分为两部分,一部分是用户端页面,包括首页车辆展示、车辆详情、下单页面;另一部分是管理后台,车辆管理、订单管理、统计报表。登录后的用户访问后台页面时,通过路由守卫检查角色权限,没有权限直接跳转首页。
路由守卫是前端权限控制的一个重要环节。我在全局前置守卫里面判断目标路由的meta信息,如果meta要求登录但本地没有Token,就跳去登录页;如果meta要求管理员角色但用户角色不是,就提示无权访问。后端接口也做了同样的权限校验,前端守卫只是提升体验,不让用户看到闪一下的无权限页面。
4.2 登录态管理与Axios封装
前端和所有后端接口通信都要经过Axios,我封装了一个统一的request对象。它做的事情很多:从Pinia里取出Token并设置到请求头,响应拦截器判断HTTP状态码和业务状态码,401时自动清理登录信息并跳转登录页,其他错误统一弹出消息提示。
这里有一个细节,Token过期往往会遇到拦截器里面跳转登录页和路由守卫同时触发,处理不好就会死循环。我的处理方式是,拦截器里只执行清理动作,通过标记变量防止重复跳转,跳转逻辑统一交给路由守卫处理。
Axios封装好之后,前端写接口调用就非常清爽了。每个模块的API文件里只保留函数定义,页面组件只管调用并处理返回值。这样后端接口一旦有变化,只需要改动一个文件。
4.3 核心页面和组件实现
用户端比较核心的是车辆列表页和订单提交页。车辆列表用卡片形式展示品牌、型号、日租金、车辆状态标签,支持按车型和价格区间筛选。单个车辆的状态变化通过接口刷新,不需要手动刷新整个页面。
订单提交页是另一个重点。用户选择取车时间和还车时间后,系统自动计算预估费用并显示在页面上。这里前端展示的预估价和后端结算的实际价是有可能不同的,如果超时还车就会产生额外费用。我在界面上标注清楚“预估费用”,结算结果以后台实际计算为准,避免后期扯皮。
管理后台的订单管理用Element Plus的表格组件,支持分页、状态筛选。每一行订单旁边根据状态显示不同操作按钮,比如待取车显示“确认取车”,租用中显示“确认还车”,已完成显示“查看明细”。这种按钮操作本质是调用后端状态机接口,前端只控制界面层,不放业务逻辑。
4.4 统计报表可视化
统计报表给管理员的日常运营提供决策依据。我用ECharts做了三个图表:每日订单量折线图、近七天营收柱状图、车型热度饼图。这些数据通过后端统计接口返回,接口在MySQL里按时间分组聚合。
图表组件封装成了独立组件,父页面只需要传配置项和数据。ECharts的响应式容器要给它设置固定高度,否则初始化宽高会变成0,图表显示不出来,这个问题我当时排查了很久才发现是容器高度问题。
5. 开发中踩过的坑与排查过程
5.1 并发抢单导致车辆状态被覆盖
压力测试时发现,两台车同时被下单,最后都显示成功。原因是两个请求同时进入订单创建方法,虽然代码里判断了车辆状态,但判断和更新之间不是原子的,请求A没提交事务之前,请求B读到的是旧状态。
解决过程我前面提到了,使用SELECT FOR UPDATE把记录行锁住。但还有一点容易漏,就是加锁方法所在的整个链路必须在同一个事务里。我当时把锁查询和订单插入拆到两个方法,因为Spring的@Transactional默认只对同一个类内的方法调用生效,结果锁一样没用。后来把所有业务操作合并到一个事务方法里,问题才消失。排查这个问题时,要同时看方法调用链和事务管理器配置,缺一个都会出问题。
5.2 还车时间计算差一小时
计费模块测试时发现,选下午14:00取车,当天13:00还车,系统计算时长却是负数。问题出在日期解析逻辑上,前端传过来的是“2025-06-10 14:00:00”,后端把字符串转成LocalDateTime时格式用错了,导致解析出来的时间比实际晚了一小时。
排查时看日志里的时间数值,一眼就能发现不对劲。日期时间处理在租车系统里非常敏感,我已经形成习惯,所有时间字段统一用LocalDateTime,数据库字段用datetime类型,前后端传输用“yyyy-MM-dd HH:mm:ss”格式字符串。尽量不要用Date类型,它有很多时区相关的坑。
5.3 前端响应拦截器跳转死循环
Token过期后,Axios拦截器跳转到登录页,但登录页的登录和刷新接口本身也会走拦截器,结果就是在登录页和接口之间反复横跳。当时浏览器卡到没法操作,控制台刷满了401状态码。
解决办法是设置一个标记,当已经在跳转过程中时,后续请求直接丢弃;同时对白名单接口不做401跳转,比如获取验证码的接口。这个问题告诉我们,拦截器不是越强越好,要区分接口的场景。
5.4 MyBatis-Plus分页查询返回总数为0
用MyBatis-Plus的分页插件查询订单列表,数据能正常显示,但总条数始终是0,分页组件显示不出正确页数。排查发现是分页插件没有正确配置拦截器。
MyBatis-Plus的分页依赖PaginationInnerInterceptor,必须在配置类里显式注入。我一开始只引入了依赖,忘了配置拦截器,分页SQL只能查出当前页的数据,拿不到count。还有一个小细节,分页插件要写在多数据源或租户插件的后面,否则拦截顺序不对也可能出问题。
5.5 跨域和代理配置问题
前端开发时访问后端接口,直接请求后端地址会触发跨域。我在Vite的配置里设置了proxy代理,所有/api开头的请求转发到localhost:8080,并且在后端也配置了CORS允许跨域。
需要注意,如果同时开了Vite代理和后端CORS,有时候会出现加过一层跨域头又加一层的现象,虽然能跑但很不规范。我的建议是开发环境只用代理,后端CORS只针对生产环境不走同一网关时的场景。配置越简单越不容易出错。
6. 落地部署与后续扩展方向
6.1 打包部署的关键步骤
后端部署我用Maven打包成Jar包,放到服务器上用systemd服务管理。配置文件中数据库连接、JWT密钥、日志路径都放在外部配置里,不写死。前端打包后生成的静态文件放到Nginx的html目录,Nginx里配置location /api指向后端地址。
有一个必须要做的事就是JWT密钥不能暴露在代码仓库里。我在环境变量里注入JWT_SECRET,启动命令里引用,这样即使代码泄漏,密钥也拿不到。
6.2 按这个基础还可以继续扩展什么
系统目前版本已经满足日常运营了,但如果继续演进,有几个很自然的扩展点。
第一个是引入消息队列处理高峰期的订单通知,用户提交订单成功后通过异步消息发送短信或邮件。第二个是车辆GPS定位对接,在订单租用中状态实时显示车辆当前位置,一方面给用户安心感,另一方面便于企业风控。第三个是接入支付,押金和租金直接通过支付网关实现线上扣款,不用线下转账。
还有一点,现在的权限是基于角色的,角色-权限模型可以根据实际需要继续往细了拆,比如操作级权限和字段级权限,每个管理员只能看到和操作与自己相关的部分。
6.3 最后分享一点我的实操体会
整套系统的开发工作量,大头不在增删改查,而在业务规则的边界处理。租车系统里订单状态永远不是单一跳变,计费规则也不是固定公式,它们随着业务模式调整而调整。所以写代码的时候,把状态流转和计费逻辑单独抽出来做成引擎,是非常值得做的前期设计。
我自己做完这版之后最大的感受是,表结构定义清楚,后端业务和数据库事务对齐,前端再把接口和页面交互捋顺,整个项目就没有想象中那么难。希望这篇内容对准备做租车系统的朋友有帮助,能少踩一些我踩过的坑。