1. 为什么我选择用Web技术栈重写一套汽车租赁系统
1.1 从一次真实的踩坑经历说起
去年帮一个做线下租车生意的朋友整理业务,发现他们还在用Excel登记车辆、用纸质合同签字、用微信群调度车辆。旺季的时候,前台三个人同时接电话,经常出现同一辆车被重复预订的情况,客户到店才发现车已经开走了,投诉电话一个接一个。朋友问我能不能帮忙搞一套系统,把车辆、订单、客户、合同这些事管起来。
我一开始想的是直接买个SaaS账号算了,但算了一笔账:按车辆数收费,他们车队有四十多台车,一年下来费用不低,而且数据不在自己手里,后续想对接自己的财务流程也麻烦。于是决定自己搭一套。技术选型上没怎么纠结,直接上了Web技术栈——浏览器打开就能用,前台、调度、财务、老板都能各自登录看自己该看的东西,不用装客户端,维护成本最低。
这套系统后来迭代了几个版本,从最初只能登记车辆和订单,到后来加上合同生成、押金管理、车辆状态流转、简单的经营报表,基本覆盖了一家中小型租车公司的日常运营。我把整套源码和开题文档整理了出来,这篇文章就把我在这个项目里踩过的坑、做过的取舍、以及可以直接抄作业的实现细节,完整地讲一遍。
1.2 这套系统到底解决什么问题
先说清楚边界。这套系统面向的是中小型汽车租赁企业,车队规模大概在十台到一百台之间,业务模式以短租、日租为主,可能带一点长租和企业客户。它不涉及复杂的金融风控,也不做跨区域的连锁调度,核心就是把“车、人、单、钱”四件事管明白。
具体来说,它要解决几个痛点。第一是车辆状态不透明,一辆车现在是在库、已租出、维修中还是被调走了,必须实时可见,否则调度就是瞎猜。第二是订单冲突,同一时间段同一辆车只能有一个有效订单,这个约束必须在系统层面卡死,不能靠人脑记。第三是合同与押金管理混乱,纸质合同容易丢,押金退还没记录,出了纠纷说不清。第四是经营数据靠拍脑袋,这个月租出率多少、哪些车闲置时间长、哪个渠道来的客户多,得有数。
适合谁来参考这份内容?如果你是计算机相关专业的学生,拿它当毕业设计或者课程项目,这套系统的功能完整度和技术栈都比较合适;如果你是刚入行的开发者,想找一个业务逻辑不算太复杂但五脏俱全的练手项目,它也够用;如果你是小团队的技术负责人,想给传统行业客户交付一套轻量级的管理系统,里面的表结构设计和状态机思路可以直接借鉴。
1.3 技术选型的取舍逻辑
技术栈这块我选的是比较主流的组合:后端用Java + Spring Boot,数据库用MySQL,前端用Vue配合Element UI组件库,整体前后端分离,通过 RESTful 接口通信。为什么这么选,我说几个实在的理由。
Spring Boot 的生态成熟,遇到问题搜一下基本都有答案,对于一个需要快速交付、后续还要维护的项目来说,这比追求新技术重要得多。MySQL 不用多说,租车业务的数据关系清晰、事务要求明确,关系型数据库是最自然的选择。前端选 Vue 是因为它的上手曲线平缓,组件化写起来舒服,Element UI 提供了大量现成的表格、表单、弹窗组件,后台管理类的界面用它能省掉大量样式工作。
有朋友问过为什么不用更轻的方案,比如直接用服务端渲染或者低代码平台。我的考虑是,租车系统里有不少交互比较复杂的地方,比如车辆日历视图、订单状态流转、多条件筛选,这些用低代码平台做出来体验会很别扭,后期改起来也受限。前后端分离虽然多了一层接口联调的工作量,但前端可以独立迭代,接口也能被未来的移动端复用,长期看是划算的。
提示:如果你的项目周期特别紧,或者团队里没有专门的前端,可以考虑用 Thymeleaf 这类模板引擎做服务端渲染,能省掉前后端联调的时间,但交互复杂度和可复用性会打折扣。选型没有绝对的对错,关键看约束条件。
2. 系统整体设计与核心模块拆解
2.1 功能模块的划分思路
一套租车系统看起来功能很多,但拆开来看,核心就是围绕几个实体做增删改查加状态流转。我把它划分成六个模块:车辆管理、客户管理、订单管理、合同与押金管理、调度看板、统计报表。这个划分不是拍脑袋来的,而是按照“谁在用、用来干什么”来切的。
车辆管理是基础,维护车辆档案、状态、保险和年检信息。客户管理维护个人和企业客户资料,包括证件信息、历史租车记录。订单管理是整个系统的核心,负责下单、改单、取消、还车结算。合同与押金管理处理合同生成、押金收取和退还。调度看板是给调度员用的,用日历视图看每辆车的时间占用情况。统计报表给管理者看经营数据。
模块之间不是孤立的。订单创建时要校验车辆在所选时间段是否可用,这就要读车辆状态和已有订单;还车结算时要更新车辆状态、生成结算单、处理押金退还,涉及订单、车辆、财务三块。所以设计的时候,我把订单作为中心,其他模块围绕它提供支撑。
2.2 数据库表结构的关键设计
表结构设计是这套系统里我最花心思的部分,因为改表结构的成本远高于改代码。核心表有七张:vehicle(车辆)、customer(客户)、rental_order(租赁订单)、contract(合同)、deposit_record(押金记录)、vehicle_status_log(车辆状态变更日志)、user(系统用户)。
先说vehicle表,除了常规的品牌、型号、车牌、座位数、日租金,我特意加了current_status字段,取值是一个枚举:AVAILABLE(可租)、RENTED(已租出)、MAINTENANCE(维修中)、RESERVED(已预订)、DISABLED(停用)。这个字段是调度看板的数据来源,也是订单校验的第一道关卡。
rental_order表是核心,关键字段包括vehicle_id、customer_id、start_time、end_time、status、total_amount、deposit_amount。这里有个设计要点:时间段用两个 DATETIME 字段存储,而不是存天数。因为租车业务里取车和还车时间精确到小时很常见,按天算容易扯皮。订单状态用枚举:PENDING(待确认)、CONFIRMED(已确认)、ONGOING(租用中)、COMPLETED(已完成)、CANCELLED(已取消)。
vehicle_status_log这张日志表很多人会忽略,但我强烈建议加上。每次车辆状态变更都记一条,记录变更前后的状态、操作人、时间、关联订单。出了问题回溯的时候,这张表能救命。比如客户投诉说车明明还了系统却显示租出中,查日志就能定位是哪个环节没更新。
| 表名 | 核心作用 | 关键字段 |
|---|---|---|
| vehicle | 车辆档案与状态 | 车牌、日租金、current_status |
| customer | 客户资料 | 证件号、联系方式、客户类型 |
| rental_order | 租赁订单主表 | 车辆、客户、起止时间、状态、金额 |
| contract | 合同信息 | 订单关联、合同编号、签署状态 |
| deposit_record | 押金流水 | 订单关联、金额、收退方向、状态 |
| vehicle_status_log | 状态变更审计 | 车辆、前后状态、操作人、时间 |
2.3 订单冲突校验的实现原理
这是整个系统里最不能出错的地方。同一辆车在同一时间段内不能有两个有效订单,这个约束必须在数据库层面和业务层面双重保证。
业务层面的做法是:创建订单时,查询该车辆在[start_time, end_time]区间内是否存在状态为CONFIRMED或ONGOING的订单。SQL 的写法有个细节,判断两个时间段是否重叠,条件是existing.start_time < new.end_time AND existing.end_time > new.start_time。注意这里用的是严格小于和严格大于,因为如果一辆车上午十点还车、下午两点再租出,中间留了缓冲时间,这两个订单不算冲突。如果业务上要求还车和取车之间至少间隔一定时间(比如清洗整备),那就在比较时加上这个间隔。
数据库层面,我给rental_order表加了一个联合索引(vehicle_id, start_time, end_time, status),加快冲突查询的速度。但索引本身不能防止并发写入导致的冲突,所以下单接口里用了悲观锁:在事务开始时对车辆记录加SELECT ... FOR UPDATE,把同一辆车的下单请求串行化。这样即使两个前台同时给同一辆车下单,也只有一个能成功,另一个会拿到冲突提示。
注意:悲观锁会带来性能开销,如果并发量不大(中小租车公司完全够用),这是最简单可靠的方案。如果未来并发上来了,可以考虑用乐观锁加版本号,或者引入分布式锁,但那是另一个量级的问题了。
2.4 车辆状态机的设计
车辆状态不能随便改,必须按规则流转。我设计了一个状态机,明确哪些状态可以转到哪些状态。AVAILABLE可以转到RESERVED(被预订)或MAINTENANCE(送修);RESERVED可以转到RENTED(客户取车)或回到AVAILABLE(订单取消);RENTED只能转到AVAILABLE(还车)或MAINTENANCE(还车时发现故障直接送修);MAINTENANCE修好后回到AVAILABLE。
这个状态机写在服务层的一个方法里,所有状态变更都必须走这个方法,不允许直接 update 数据库。方法里先校验当前状态是否允许转到目标状态,不允许就抛异常。这样做的好处是,状态流转的规则集中在一处,后续要加新状态或者改规则,只改一个地方,不会出现某个接口漏改导致状态错乱。
3. 核心功能的实操实现与关键代码
3.1 开发环境搭建与项目初始化
先把环境跑起来。需要准备的东西:JDK 8 或以上、Maven、MySQL 5.7 或 8.0、Node.js 14 以上。后端用 Spring Initializr 生成骨架,依赖勾选 Spring Web、MyBatis-Plus、MySQL Driver、Lombok。MyBatis-Plus 比原生 MyBatis 省事,单表 CRUD 基本不用写 SQL,复杂查询再手写。
数据库建一个库叫car_rental,字符集用utf8mb4,排序规则utf8mb4_general_ci。建表脚本我建议用 Flyway 或者 Liquibase 管理,这样表结构变更可追溯,团队协作时不会出现“你本地表结构和我这边不一样”的问题。如果嫌重,至少把建表 SQL 放在版本库里,按版本号命名。
前端用 Vue CLI 创建项目,选 Vue 2 加 Element UI(Vue 3 配 Element Plus 也行,看团队熟悉度)。项目结构上,src/api放接口封装,src/views放页面,src/components放公共组件,src/router配路由,src/store管全局状态。接口请求统一用 axios 封装一层,加上 token 拦截和统一错误提示。
配置文件里几个关键项:数据库连接池用 HikariCP(Spring Boot 默认就是),最大连接数设 20 左右够用;MyBatis-Plus 开启逻辑删除,给表加deleted字段,删数据时只改标记不真删,租车业务里历史订单不能真删;时间字段统一用LocalDateTime,序列化格式配成yyyy-MM-dd HH:mm:ss。
3.2 车辆管理模块的增删改查实现
车辆管理看起来简单,但有几个细节值得说。新增车辆时,车牌号要做唯一性校验,这个在数据库加唯一索引,同时在接口层做友好提示。车辆图片上传我用了本地存储加静态资源映射的方式,中小项目没必要上对象存储,本地存到upload/vehicle/目录下,数据库存相对路径,前端通过配置的静态资源前缀访问。
车辆列表查询要支持多条件组合筛选:车牌模糊查、状态筛选、品牌筛选、价格区间。MyBatis-Plus 的LambdaQueryWrapper写这种动态条件很顺手,每个条件判断非空再拼接。分页用它的Page对象,前端传current和size两个参数。
这里有个实操心得:车辆列表默认只查deleted = 0且状态不是DISABLED的车,停用的车单独有个入口查。因为日常调度时没人关心停用的车,混在一起反而干扰。另外列表接口返回的字段要控制,别把车辆描述这种大字段带出来,列表页用不上,白白增加传输量。
车辆状态变更的接口要单独设计,不能和编辑车辆信息混在一起。编辑信息是改品牌型号这些,状态变更是业务操作,要记日志、要校验流转规则。我把它做成PUT /api/vehicle/{id}/status,请求体里带目标状态和变更原因,服务层走状态机校验,通过后更新状态并写日志。
3.3 订单创建与冲突校验的完整流程
订单创建是核心链路,我把流程拆成几步。第一步,前端提交订单草稿,包含车辆、客户、起止时间。第二步,后端校验基础数据:车辆是否存在且状态允许租出、客户是否存在且证件有效、起止时间是否合法(结束时间必须晚于开始时间,且不能是过去的时间)。第三步,做冲突校验,就是前面说的区间重叠查询加悲观锁。第四步,计算费用,日租金乘以天数,不足一天按一天算,如果有长租折扣或者会员折扣在这里应用。第五步,生成订单记录,状态置为PENDING,同时把车辆状态改为RESERVED。
费用计算这块有个容易踩的坑:天数怎么算。如果客户上午十点取车、第二天上午十点还车,算一天还是两天?行业惯例是 24 小时为一天,超过部分按小时计费或者按半天计。我在实现里做了个可配置的规则:基础按 24 小时一天算,超出部分不足 4 小时按 4 小时算,超过 4 小时不足 12 小时按半天算,超过 12 小时按一天算。这个规则写在配置里,不同公司可以调。
// 冲突校验核心逻辑示意 public boolean hasConflict(Long vehicleId, LocalDateTime start, LocalDateTime end, Long excludeOrderId) { LambdaQueryWrapper<RentalOrder> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(RentalOrder::getVehicleId, vehicleId) .in(RentalOrder::getStatus, Arrays.asList("CONFIRMED", "ONGOING")) .lt(RentalOrder::getStartTime, end) .gt(RentalOrder::getEndTime, start); if (excludeOrderId != null) { wrapper.ne(RentalOrder::getId, excludeOrderId); } return rentalOrderMapper.selectCount(wrapper) > 0; }改单的逻辑比下单复杂,因为要排除当前订单自身。比如客户把还车时间延后,校验冲突时要把自己排除掉,否则永远和自己冲突。上面代码里的excludeOrderId就是干这个的。
3.4 合同生成与押金流水管理
合同这块,我的做法是用模板引擎生成 PDF。模板用 Word 写好,另存为 docx,然后用 poi-tl 这个库做占位符替换,把订单信息、客户信息、车辆信息填进去,导出 PDF 给客户签署。为什么不搞电子签章?中小租车公司大多还是线下签字,系统生成合同打印出来签,签完扫描上传存档,这个流程最贴合实际。
押金管理要单独建流水表,因为一笔订单可能对应多条押金记录:收取一条、部分退还一条、再退一条。每条记录有方向(收/退)、金额、状态(待处理/已完成)、关联订单。还车结算时,系统根据是否有违章、是否有车损,计算应退金额,生成退还记录。这里的关键是押金和订单是一对多关系,不能把押金金额直接存在订单表里,否则退押金的历史记录就丢了。
提示:押金退还建议加一个“审核”环节,前台发起退还申请,财务审核后才真正打款。系统里记录审核人和审核时间,避免资金操作没有留痕。
3.5 调度看板的日历视图实现
调度看板是调度员最常用的界面,核心是一个按车辆分行的日历视图,横轴是日期,每个格子显示该车当天的占用情况。前端我用了一个日历组件库,把订单数据按车辆和日期聚合后渲染上去。后端提供一个接口,传入日期范围,返回该范围内所有车辆的订单占用情况,按车辆分组。
数据量大的时候要注意性能。如果一次查一个月的所有车辆订单,四十台车乘以三十天,数据量不大,没问题。但如果车队上百台、查三个月,就要考虑分页或者按需加载。我的做法是默认查当前周,支持前后翻周,翻页时重新请求。这样单次请求的数据量可控。
日历格子的颜色区分状态:绿色可租、蓝色已预订、橙色租用中、灰色维修中。调度员一眼就能看出哪辆车哪天有空。点击格子弹出该订单的简要信息和快捷操作,比如确认订单、办理取车、办理还车。这个交互设计省掉了调度员在多个页面之间跳转的麻烦。
4. 实操中踩过的坑与排查实录
4.1 时间区间重叠判断的边界问题
这个坑我踩过两次。第一次是判断条件写成了<=和>=,结果上午十点还车、下午两点取车的两个订单被判成冲突,因为还车时间等于取车时间的情况被算进去了。改成严格小于大于后正常。第二次是时区问题,服务器部署在 UTC 时区,数据库存的时间没做转换,导致前端显示的时间和实际差了八小时,订单冲突判断也跟着错。解决办法是统一用Asia/Shanghai时区,数据库连接串里加serverTimezone=Asia/Shanghai,JVM 启动参数也指定时区。
还有一个隐蔽的坑:数据库 DATETIME 的精度。MySQL 的 DATETIME 默认精确到秒,如果业务上取车还车时间精确到分钟,问题不大。但如果两个订单的边界时间在同一秒内,判断可能出意外。我的建议是时间字段统一用DATETIME(0),业务上不允许秒级以下的精度,前端时间选择器也限制到分钟,从源头避免精度问题。
4.2 并发下单导致的状态错乱
测试阶段模拟过两个前台同时给同一辆车下单的场景,结果两个订单都创建成功了,车辆状态被改了两次。原因是冲突校验和状态更新之间没有锁,两个请求都查到了“无冲突”,然后都往下走。加上SELECT ... FOR UPDATE悲观锁后解决,但要注意锁的范围:锁的是车辆记录,不是订单表,因为冲突的本质是争抢同一辆车。
这里还有个事务传播的细节。下单方法上加了@Transactional,锁在方法内获取,方法结束事务提交锁释放。如果方法里调用了其他带事务的服务方法,要注意传播级别,避免锁提前释放。我一开始把冲突校验抽成了一个独立服务方法,也加了事务注解,结果内层方法的事务和外层不是同一个,锁的行为不符合预期。后来把冲突校验改成普通方法,不单独开事务,问题解决。
| 问题现象 | 排查方向 | 解决办法 |
|---|---|---|
| 两订单同时创建成功 | 检查是否有并发锁 | 车辆记录加悲观锁 |
| 锁未生效 | 检查事务传播级别 | 冲突校验不单独开事务 |
| 时间显示差八小时 | 检查时区和连接串 | 统一 Asia/Shanghai |
| 边界时间误判冲突 | 检查比较运算符 | 用严格小于大于 |
4.3 车辆状态与实际不符的排查思路
运营一段时间后,出现过几次系统显示车辆可租、实际车已经开走的情况。排查下来原因有几类。一类是还车时前台只改了订单状态,忘了触发车辆状态更新,这种靠vehicle_status_log日志能快速定位——日志里没有还车对应的状态变更记录,说明流程漏了。解决办法是把状态更新和订单状态变更绑在同一个事务里,订单完成必须同时更新车辆状态,代码层面用同一个服务方法保证。
另一类是订单取消后车辆状态没回滚。客户预订了车,车辆变RESERVED,后来订单取消,但取消逻辑只改了订单状态,没把车辆改回AVAILABLE。这个在取消订单的服务方法里补上回滚逻辑,并且加了一条规则:任何导致订单失效的操作,都必须检查并回滚关联车辆的状态。
第三类比较隐蔽,是手工在数据库里改数据导致的。有人直接 update 了订单表把状态改成完成,但没走服务层,车辆状态自然没变。这种只能靠规范约束,禁止直接操作生产库,所有变更走接口。我在系统里加了个定时对账任务,每天凌晨跑一次,检查所有RENTED状态的车辆是否有对应的ONGOING订单,不一致的生成告警清单,人工核对。
4.4 合同 PDF 中文乱码与字体嵌入
用 poi-tl 生成 PDF 时遇到过中文乱码,导出的合同里中文全是方块。原因是 PDF 渲染时没有嵌入中文字体。解决办法是在模板配置里指定字体,并且确保服务器上有对应字体文件。我用的方案是把字体文件(比如思源黑体)放在项目资源目录下,生成时显式加载。这样不依赖服务器环境,换台机器部署也不会出问题。
还有个细节:合同里的金额要大写。比如“壹仟贰佰元整”,这个转换逻辑要自己写,注意零的处理和角分的表达。我写了个工具方法,测试用例覆盖了整数、带角、带分、中间有零的各种情况。这种小功能看着不起眼,但合同金额写错是大事,必须测到位。
4.5 常见问题速查表
把运营和开发过程中高频出现的问题整理成一张表,方便快速对照排查。
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 下单提示冲突但实际无冲突 | 历史订单状态未正确关闭 | 检查该车历史订单状态 |
| 车辆列表查不到某辆车 | 被逻辑删除或状态为停用 | 查 deleted 字段和状态 |
| 押金退还金额不对 | 车损或违章扣款未计入 | 核对结算单明细 |
| 合同编号重复 | 编号生成规则有并发问题 | 用数据库序列或加锁生成 |
| 报表数据与订单对不上 | 统计口径含已取消订单 | 明确统计只算有效订单 |
| 前端时间选择器可选过去时间 | 组件配置未限制 | 设置 disabledDate |
提示:合同编号、订单编号这类业务单号,千万别用时间戳加随机数凑,并发下容易重复。用数据库自增 ID 加前缀,或者用号段模式,简单可靠。
5. 这套系统还能怎么扩展
5.1 从单店到多门店的改造思路
现在的设计是单门店模型,车辆、订单都默认属于一个门店。如果要支持多门店,改动主要集中在几处:加store表,车辆和订单加store_id字段,用户加门店归属,调度看板按门店过滤。跨门店调车是个复杂点,需要一张调车单表记录车辆在门店之间的流转,调车期间车辆状态设为TRANSFERRING,不参与租赁。
这个改造不难,但要想清楚业务规则:客户在 A 店租的车能不能在 B 店还?异地还车怎么收费?这些规则不明确,系统改了也是白改。我的建议是先把单店跑顺,业务确实发展到多店了再改,别一上来就过度设计。
5.2 对接支付与电子签约的可能性
现在押金和租金的收取是线下完成的,系统只做记录。如果要对接线上支付,需要引入支付网关的 SDK,做支付回调处理、对账、退款。这块涉及资金安全,测试要充分,尤其是回调的幂等处理——同一个支付回调可能重复到达,不能重复记账。
电子签约也是类似,需要对接第三方签约服务,把合同 PDF 推过去,客户在线签署,签完回传存证。这个对中小租车公司来说不是刚需,线下签字加扫描存档已经够用,但如果是企业客户或者长租业务,电子签约能提升体验。
5.3 数据报表的深化方向
现在的报表比较基础,就是租出率、营收、订单量几个指标。后续可以往几个方向深化:车辆维度的收益分析,算出每台车的投资回报周期,指导车队增减;客户维度的复购分析,识别高价值客户;渠道维度的转化分析,看不同获客渠道的订单质量和利润。这些分析需要更细的数据埋点,比如记录每个订单的来源渠道、每个客户的获取成本。
我在实际使用中发现,老板最关心的其实就两个数:这个月赚了多少、哪些车在闲置。所以报表不用做太花哨,把这两个数做准、做及时,价值就出来了。其他的分析等业务量上来了再说。
5.4 源码与开题文档的使用建议
这份源码和开题文档,我建议这样用:先跑通环境,把系统跑起来,用测试数据走一遍完整流程——建车、建客户、下单、取车、还车、结算。走通了再去看代码,从订单模块入手,因为它是核心,其他模块都是围绕它转的。看代码时重点关注服务层的事务边界和状态流转,这两块是业务系统的灵魂。
如果是拿来做毕业设计,开题文档里的需求分析和系统设计部分可以直接参考,但建议结合自己的理解重写,别照抄。答辩时老师问得最多的就是“为什么这么设计”,你得能说出取舍的理由,而不是背文档。源码里的注释我写得比较克制,关键逻辑都有说明,但不会每行都注释,留给你自己理解的空间。
最后分享一个小技巧:调试订单冲突这类问题时,别在页面上反复点,直接在数据库里造数据,用接口测试工具发请求,能快速复现和验证。我调试并发冲突时,写了个简单的脚本同时发十个下单请求,看哪些成功哪些失败,比手工点效率高得多。这套系统后来交付给朋友用了大半年,中间修修补补,整体还算稳。租车这个行业业务不复杂,但细节多,把状态和金额管住,系统就立住了。