☰
基于SpringBoot的汽车租赁APP后端实战:从数据库设计到订单状态机
2026/10/6 4:33:35 网站建设 项目流程

简介:这是基于SpringBoot的汽车租赁APP设计与实现文档,面向Java后端与全栈学习者、毕业设计或课程项目参考人群,围绕汽车租赁业务场景,系统梳理了从需求分析、技术选型到功能落地的完整思路。文档为单个docx文件,大小1.48MB,包含中英文摘要、目录及正文章节,结构清晰,便于按模块阅读。已有140人学习下载。内容重点覆盖Spring Boot与MySQL、Redis缓存、Vue及UNI-APP的整合方案,并详细说明身份认证、汽车查看、租赁流程、订单管理和汽车管理等核心模块,同时引入XSS拦截、SQL注入拦截、登录拦截等安全设计,对希望快速搭建同类型管理系统的开发者具有直接参考价值。

1. 汽车租赁APP的SpringBoot后端:这个项目到底解决了什么问题

“基于SpringBoot的汽车租赁APP”这个题目,每年毕业设计和练手项目里都会出现,看着不新鲜,但它卡的位置很舒服:业务足够具体,技术栈完整覆盖接口开发、数据库建模、订单状态流转和前后端联调,一个人几周就能做完。你打开APP,看到车辆列表,筛选车型、看日租金、下单、支付、到门店取车、还车结算——这一串动作落到后端,核心就是管好车、管好订单、管好用户。SpringBoot就是把这套业务暴露成APP能调的接口,数据落进MySQL,顺便把配置、事务、鉴权这些脏活揽下来。这类项目最磨人的其实不是CRUD,而是“同一辆车被两个人同时下单”这类并发场景,以及订单状态机在各个节点的边界判断。新手常把状态字段随手填字符串,最后算价格、查历史、对账全乱。

2. SpringBoot选型与项目骨架:搭一个能跑十天的工程

2.1 为什么租赁业务选SpringBoot而不是老一套Servlet/Node

汽车租赁这个场景是标准的事务型业务:用户注册、车辆查询、订单下单、支付回调,每一步都要保证数据一致。SpringBoot在这类业务的生态成熟度是碾压级的,内嵌Tomcat、自动装配、Maven依赖管理,相比当年SSH时代写一堆XML配置,现在一个starter就把Web能力带进来。网约车App开发的大多数开源参考方案也是SpringBoot这套技术栈,说明在“用车”这个领域,这套选型已经被反复验证过。

选型时也对比过其它方案:Node的Express写RESTful接口确实快,但后面你想加并发控制、分布式锁、消息队列,参考案例少一大截,遇到诡异问题连报错都搜不到几条。老Servlet那套配置繁琐,纯手写又容易在小细节上翻车,时间成本全耗在搭环境上。对一个人一个月内要交付的项目来说,SpringBoot是后悔药最少的选择。

2.2 初始化工程:pom依赖与目录分包一次做对

我一般会用一个干净的初始化工程,依赖控制到够用为止,不为凑技术栈加东西。下面是pom.xml里最核心的依赖块:

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.5</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> </dependencies>

依赖里最关键的是parent用了SpringBoot 2.7.x而不是3.x。原因后文避坑章节会展开:3.x把javax换成了jakarta,大量网上教程和MyBatis-Plus的示例还是2.x写法,抄着抄着就翻车。毕设和练手项目求稳,就用2.7.x收官版本。MyBatis-Plus提供BaseMapper的通用CRUD,省掉一大半手写SQL的体力活。lombok标注optional,让它只参与编译不打包进jar,避免运行期类冲突。

目录结构建议按下图分包,职责尽量单一:

src/main/java/com/example/rental/ common/ # 统一返回体、全局异常、工具类 config/ # MyBatis-Plus分页、跨域、拦截器 controller/ # REST接口层,只做参数接收和返回 service/ # 业务逻辑层,状态机、事务都在这里 mapper/ # 数据访问层,继承BaseMapper entity/ # 数据库实体 dto/ # 接口入参和出参对象

controller层要薄,service层要厚。很多新手把业务判断全部堆在controller里,一个方法上百行,后期改一个状态流转要翻半天。把service拆出来,controller只负责拿参数、调service、返回Result,后面接JUnit测试也方便。

2.3 application.yml里的三个必调参数

SpringBoot的配置看着简单,但有三个参数不调好,后面联调全是眼泪:

server: port: 8080 servlet: context-path: /rental spring: datasource: url: jdbc:mysql://localhost:3306/rental_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

server.servlet.context-path加了个/rental前缀,好处是接口路径整体带业务标识,后面如果前端要打包进同一个jar,静态资源和接口路径不会互相干扰。datasource的URL里serverTimezone=Asia/Shanghai必加,否则MySQL连接会有时区相关的报错;useSSL=false是为了本地开发环境不强制加密。jackson的date-format和time-zone是修“APP传时间过来差8小时”这个经典问题的关键,后面避坑章节会再提。mybatis-plus的map-underscore-to-camel-case让数据库的daily_price自动映射成dailyPrice,不用写一堆@TableField。log-impl配了StdOutImpl,开发阶段每次SQL都会打印到控制台,排查问题非常直观。

控制台那个启动banner也可以用在线生成器换掉,纯属个人趣味,别在这上面花超过五分钟。

3. 汽车租赁数据库设计:从业务流程倒推出三张核心表

3.1 从使用流程倒推表结构:核心表怎么来的

别一上来就设计表。先把业务流程走一遍:用户在APP注册登录,浏览车辆列表,按品牌和租金筛选,选一辆车填写取车和还车日期,系统算出总价,用户支付,订单变成已支付,到门店取车,门店把车改成已取车,归还后订单变成已还车。整个过程里出现的实体有用户、车辆、订单、门店。

用户和车之间是通过订单关联的,门店是车辆的归属地。毕设阶段做四张表就够了:user、car、store、rental_order。如果再想加一点管理端味道,可以加一张car_type做车型分类,但不加也不影响闭环。

表名作用关键字段
user用户账号与基础信息id、username、phone、password、balance、create_time
store门店信息id、name、address、phone
car车辆信息与状态id、plate_no、brand、model、daily_price、status、store_id
rental_order租赁订单流水id、order_no、user_id、car_id、start_date、end_date、total_price、status、create_time

这张表清单对应APP底部的三个主入口:首页看车、订单列表、我的页。每张表都能找到对应的接口和页面,答辩时讲“按业务流程倒推表结构”比讲“我设计了四张表”高级得多。

3.2 建表SQL:用户、门店、车辆、订单一次建完

CREATE TABLE `user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL, `phone` varchar(20) DEFAULT NULL, `password` varchar(100) NOT NULL COMMENT 'BCrypt加密后的密文', `balance` decimal(10,2) NOT NULL DEFAULT '0.00', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `store` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(100) NOT NULL, `address` varchar(255) DEFAULT NULL, `phone` varchar(20) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `car` ( `id` bigint NOT NULL AUTO_INCREMENT, `plate_no` varchar(20) NOT NULL COMMENT '车牌号,唯一', `brand` varchar(50) DEFAULT NULL, `model` varchar(50) DEFAULT NULL, `daily_price` decimal(10,2) NOT NULL COMMENT '日租金', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0可租 1已租 2维修', `store_id` bigint DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_plate_no` (`plate_no`), KEY `idx_status` (`status`), KEY `idx_store_id` (`store_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `rental_order` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL, `user_id` bigint NOT NULL, `car_id` bigint NOT NULL, `store_id` bigint DEFAULT NULL, `start_date` date NOT NULL COMMENT '取车日期', `end_date` date NOT NULL COMMENT '还车日期', `daily_price` decimal(10,2) NOT NULL COMMENT '下单时快照的日租金', `total_price` decimal(10,2) NOT NULL, `status` tinyint NOT NULL DEFAULT '0' COMMENT '0待支付 1已支付 2已取车 3已还车 4已取消', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`), KEY `idx_car_id` (`car_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

几个细节值得说。rental_order里冗余了daily_price字段,这是故意设计的:车辆租金后续可能调整,但历史订单必须保留下单那一刻的价格快照,不冗余的话查旧订单就得重新关联当前车价,对不上账。order_no用唯一索引而不是直接用自增id当订单号,因为id会暴露业务量,而且订单号需要可读性,我习惯用“日期+随机数”拼一个32位以内的字符串。start_date和end_date用date类型而不是datetime,因为租赁按天计价,精确到日期就够了,用datetime反而会带来跨天、时区这些没必要的麻烦。

金额字段全部用decimal(10,2),绝对不用float和double。这个坑在支付对账时才会暴露,等你发现0.1加0.2不等于0.3的时候,数据已经错了。

3.3 状态字段用数字+枚举管理,不要散落字符串

car.status和rental_order.status都用tinyint数字。好处是存储空间小、索引效率高,更重要的是能在Java代码里用枚举把状态集中管理。看下面这个简单的状态枚举:

public enum CarStatus { AVAILABLE(0, "可租"), RENTED(1, "已租"), MAINTENANCE(2, "维修"); private final int code; private final String desc; CarStatus(int code, String desc) { this.code = code; this.desc = desc; } public int getCode() { return code; } public String getDesc() { return desc; } }

订单状态机我一般维护成:0待支付 → 1已支付 → 2已取车 → 3已还车,其中0和1状态下用户都可以取消,取消变成4。状态流转的校验放在service层,不放在数据库触发器里。数据库触发器看着能约束状态,但报错信息不友好,而且多个系统共用数据库时触发器容易互相干扰。用Java枚举约束的另一个好处是:你可以写一个StatusTransferUtil,专门定义“从哪个状态可以跳到哪个状态”,非法流转直接抛业务异常,不至于在业务代码里到处写if判断。

有一点要提前想清楚:还车时如果超时,要不要补差价?一旦要补差价,订单状态机就要多设计一个“待结算”状态,而不是直接从已取车跳到已还车。答辩时被问到这个,你有准备就不会慌。

4. 核心接口实现:从车辆列表到下单还车的完整链路

4.1 车辆分页查询:controller到mapper的一条链路

车辆列表是APP首页的数据源。直接用MyBatis-Plus的BaseMapper可以省掉大量重复CRUD,但条件查询还是要写XML。先看Mapper接口:

@Mapper public interface CarMapper extends BaseMapper<Car> { IPage<Car> selectCarPage(Page<Car> page, @Param("kw") String keyword, @Param("status") Integer status, @Param("storeId") Long storeId); }

对应的XML:

<select id="selectCarPage" resultType="com.example.rental.entity.Car"> SELECT * FROM car <where> <if test="kw != null and kw != ''"> AND (brand LIKE CONCAT('%', #{kw}, '%') OR model LIKE CONCAT('%', #{kw}, '%')) </if> <if test="status != null"> AND status = #{status} </if> <if test="storeId != null"> AND store_id = #{storeId} </if> </where> ORDER BY id DESC </select>

where标签的用法值得说一下:它会自动处理条件拼接,第一个条件前面的AND会被去掉,后面条件自动补AND,节省你手写1=1这种脏技巧。LIKE用的是CONCAT拼接参数而不是直接在SQL里写%${kw}%,后者有明显的SQL注入风险,这是我在代码审查里必查的一条。分页参数通过Page对象传入,MyBatis-Plus拦截器会自动生成COUNT和LIMIT语句,controller里只要把page和size透传进来。

Controller层写得越薄越好,这个接口只做三件事:接收参数、调service、返回统一结果体。

@RestController @RequestMapping("/api/car") public class CarController { private final CarService carService; public CarController(CarService carService) { this.carService = carService; } @GetMapping("/page") public Result<IPage<Car>> page(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size, String keyword, Integer status, Long storeId) { return Result.ok(carService.pageCars(page, size, keyword, status, storeId)); } }

这里用构造器注入而不是@Autowired字段注入,Spring官方也推荐前者,测试时可以放心把mock对象传进来。分页参数都给了默认值,防止APP端传参不全时直接报错。APP端下拉刷新对应翻页逻辑,每次请求把当前页码传上来即可,不用一次拉全量数据。

4.2 createOrder下单逻辑:事务与状态机是关键

下单是整个项目里最值得讲的部分,也是并发问题最容易暴露的地方。下面是核心逻辑:

@Transactional(rollbackFor = Exception.class) public RentalOrder createOrder(Long userId, Long carId, LocalDate startDate, LocalDate endDate) { if (!startDate.isBefore(endDate)) { throw new BusinessException("取车日期必须早于还车日期"); } Car car = carMapper.selectById(carId); if (car == null || car.getStatus() != CarStatus.AVAILABLE.getCode()) { throw new BusinessException("车辆不存在或不可租"); } long days = ChronoUnit.DAYS.between(startDate, endDate); RentalOrder order = new RentalOrder(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setCarId(carId); order.setStartDate(startDate); order.setEndDate(endDate); order.setDailyPrice(car.getDailyPrice()); order.setTotalPrice(car.getDailyPrice().multiply(BigDecimal.valueOf(days))); order.setStatus(OrderStatus.WAIT_PAY.getCode()); rentalOrderMapper.insert(order); car.setStatus(CarStatus.RENTED.getCode()); carMapper.updateById(car); return order; }

@Transactional让订单插入和车辆状态变更在同一个事务里,任何一步失败都会回滚,不会出现“订单建了但车还是可租”这种脏数据。日期校验放在service而不是controller,这样如果有人绕过controller直接调service,校验逻辑依然生效。天数计算用ChronoUnit.DAYS,它能正确处理跨月、跨年的日期差。价格计算用BigDecimal.multiply,避免float的精度问题,这正是前面建表时坚持用decimal的原因。

先说清楚这个实现的边界:两个用户同时下单同一辆车,两个请求同时读到车辆状态AVAILABLE,都通过校验,最后都可能插入订单。SpringBoot默认的事务隔离级别挡不住这种并发。要彻底解决,常见做法是对car表加乐观锁版本号,或者在查询时用SELECT ... FOR UPDATE做行锁。毕设阶段能把事务和状态校验讲清楚就够用了,但答辩时被追问“并发怎么办”,你得能说出这两个方案的名字。另外SpringBoot 2.x默认使用CGLIB代理来处理事务和AOP,这也是老生常谈的面试点。

4.3 与APP对接的接口约定:统一返回体与鉴权

APP端和后端是两拨不同的人(或者说你自己扮演两拨人),接口约定不定清楚,联调就会变成灾难现场。我需要一个统一的返回结构,让APP端拿到响应之后先判断code再决定解析逻辑:

public class Result<T> { private int 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 = "ok"; r.data = data; return r; } public static <T> Result<T> error(int code, String msg) { Result<T> r = new Result<>(); r.code = code; r.msg = msg; return r; } // getter/setter 省略 }

整体约定是:code等于200才算业务成功,非200时APP端直接弹msg内容,不解析data。配合@RestControllerAdvice这个全局异常处理器,把业务异常统一转成Result.error返回,APP端就不用面对一堆乱七八糟的异常响应格式。分页返回结构固定为{records, total, size, current},APP端写列表页时可以写死这套结构。

用户鉴权建议用JWT方案,登录接口返回token,后续请求在Header里带Authorization字段。拦截器放行登录、注册、车辆列表这几个白名单接口,其余接口校验token。APP底部的三个导航入口,对应关系正好是:首页车辆列表靠/api/car/page,订单列表靠/api/order/list,我的页靠/api/user/info。联调时按这条线索逐个对接口,漏一个都能迅速定位。

5. 避坑:做这个项目最容易踩的5个翻车现场

5.1 JDK版本和Lombok打架,编译直接红

现象:代码里用了@Data、@Builder,编译时报cannot find symbol: method builder(),clean项目也没用。

原因:很多人用JDK 17甚至21,但依赖里的Lombok版本还是老教程里的,Lombok的注解处理器对高版本JDK的字节码支持不到位,生成的方法缺失。

解决:最稳的组合是JDK 8或11搭配SpringBoot 2.7.x,Lombok选1.18.30左右的版本。如果非要JDK 17,就得同步检查Lombok版本是否对应升级。这个坑的恶心之处在于报错位置和真实原因离得很远,看半天代码以为是自己写错了。

5.2 SpringBoot版本太高,照着教程抄都会翻车

现象:新建SpringBoot 3.2项目,抄2.x教程写配置,发现javax.servlet包导入失败,MyBatis-Plus的分页插件类名也对不上。

原因:SpringBoot 3.x做了两个大变更,包名从javax换成jakarta,基底Java版本要求17以上。网上大量博客和毕业设计参考代码都是2.x时代的,直接抄必然断层。

解决:毕设和练手求稳就用2.7.x收官版。如果因为课程要求必须用3.x,那么所有javax开头的import都要改成jakarta,MyBatis-Plus也要用spring-boot3-starter后缀的依赖。SpringBoot版本太高这个事,本身不是坏事,但生态配套没跟上时,它就是个定时炸弹。

5.3 MyBatis-Plus分页失效,total等于0但数据有

现象:调用分页接口,返回的total是0,records里却有数据,或者什么都没查限制直接返回全表。

原因:MyBatis-Plus的分页功能不是默认开启的,需要注册MybatisPlusInterceptor和PaginationInnerInterceptor这两个Bean,很多人漏了这一步。

解决:加一个配置类:

@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(100L); interceptor.addInnerInterceptor(pagination); return interceptor; } }

setMaxLimit(100L)这个参数是我多年习惯,防止APP端手滑传一个超大size把数据库内存拉爆。分页失效属于典型“代码没报错但结果不对”的玄学问题,排查时先往配置类方向看,比改SQL高效得多。

5.4 APP传时间过来差8小时,或者直接解析失败

现象:APP传2025-06-01,后端收到2025-05-31T16:00:00;或者传带毫秒的时间戳时直接报JSON格式错误。

原因:前后端时区不一致,Jackson默认不识别yyyy-MM-dd HH:mm:ss之外的格式,MySQL连接URL里也没指定时区。

解决:application.yml里把jackson的date-format写死成yyyy-MM-dd HH:mm:ss,time-zone设成GMT+8,JDBC URL里加serverTimezone=Asia/Shanghai。如果APP端传的是时间戳数字,就在DTO字段上加@JsonFormat和@DateTimeFormat双注解。这个坑的隐蔽之处在于本机测试可能没问题,真机跨时区才暴露,而且报错信息各种形态都有。

5.5 手机连不上后端,抓包抓不到请求

现象:模拟器里APP请求全部超时,真机调试时后端一个包都收不到,想抓包看请求结果却什么都抓不到。

原因:APP的baseUrl配置了localhost,真机和模拟器里的localhost指向设备自身,不是电脑;后端没配CORS,浏览器H5调试时跨域请求直接被拦;抓包场景里走了HTTPS但证书没处理,客户端校验不通过直接把请求挡掉。

解决:baseUrl改用电脑的局域网IP,Android模拟器里也可以用10.0.2.2指向宿主机;后端配置CorsFilter统一放行;联调阶段全部用http明文协议,不要上HTTPS,证书问题能让抓包变成折磨。手机和电脑必须在同一网段,这是最容易被忽略的前提。还不通,先用curl在电脑上打接口确认后端本身是通的,再查APP端配置,避免两边同时排查越搞越乱。

6. 验证与进阶:让项目从“能跑”到“能答辩”

6.1 一条命令串起整个业务流程验证

项目写完后,我习惯按下面这条顺序验证,而不是打开APP乱点。先在MySQL里执行建表SQL,再打包启动:

mysql -uroot -p < sql/init.sql mvn clean package -DskipTests java -jar target/rental-1.0.0.jar

启动无误后,用curl模拟APP端走一遍核心链路:登录拿token,带token查车辆列表,再下单。每一步的响应码和关键字段都要确认:

curl -X POST http://localhost:8080/rental/api/user/login \ -H "Content-Type: application/json" \ -d '{"username":"test","password":"123456"}'

拿到token后,在后续请求头里加Authorization。如果项目里集成了Knife4j,浏览器打开文档地址可以免掉curl直接可视化调试,但我依然会在部署前跑一遍curl脚本,因为它才是线上环境最真实的验证手段。验证顺序固定为:注册→登录→查车→下单→订单状态流转。任何一步失败,按前面避坑章节的方向排查。

这里多说一句成本问题:这个项目如果只是为了毕设演示和个人练手,根本不需要上架应用商店,一台电脑加一个手机模拟器就够。真要考虑上架,才需要开发者账号、服务器和备案这些额外投入。

6.2 答辩加分项:四个值得深入的进阶方向

第一个是订单超时自动取消。用@Scheduled定时任务每分钟扫一次待支付订单,超时就把状态改成已取消并释放车辆;如果消息队列玩得熟,用RabbitMQ延迟队列更优雅。第二个是vue打包放进SpringBoot的static目录,前后端打成同一个jar直接部署,演示时只需要跑一个进程,这个组合在面试场景里很能加分。第三个是并发下单的库存问题,把前面提过的乐观锁或行锁真正实现出来,并写一个模拟并发测试展示效果,这是答辩时最能拉开差距的地方。第四个是给项目补上单元测试和接口文档,不用多,覆盖下单核心流程就好。

我自己的教训是:当初做类似项目时,急着写代码,没先画订单状态机,结果中途改状态判断改到怀疑人生。现在再做,第一件事永远是在白板上画出状态流转图,把每个迁移条件写清楚再动手。这些收尾动作做完,整个项目从“能跑”变成“能讲”,面试官追问时你也有话可说。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询