又到了毕设产出集中的节点,论坛和社群里隔三差五就有人问:汽车租赁系统的 SpringBoot 毕设源码,有没有现成的能跑起来直接交?我的回答一直是:能跑起来的源码到处都是,能扛住评阅老师追问的源码才是真的值钱。这几年帮人评审过的毕设项目里,做得扎实的往往不是功能最多的,而是业务逻辑最清楚的。你从网上拉下来的每个项目,最怕的不是有bug,而是根本讲不明白它为什么这么设计。
这篇文章,我不打算给你贴一套完整代码充字数,而是想跟你聊清楚“基于SpringBoot的汽车租赁系统”这类毕设从业务梳理、技术选型、数据库设计到核心代码实现的完整链路。你拿到源码之后,对照这篇文章拆一遍,能动的部分动一动,能讲的部分讲明白,比单纯改个包名就交上去要稳妥得多。
1. 先拆业务骨架:汽车租赁系统到底在解决什么问题
1.1 一条订单的完整生命周期
很多同学上手就写代码,写到订单表就卡住了,因为没想清楚汽车租赁的本质业务流程。说到底,这是一个“资源—预约—履约—结算”的闭环业务。
我习惯把一个完整的租车流程画成下面这条线,这样无论代码怎么组织,心里都有一个地图:
用户注册登录 → 管理员在后台发布车辆 → 用户浏览/搜索车辆 → 用户选定租期、提交租车订单 → 管理员审核订单 → 审核通过后用户按期取车 → 用户用车 → 用户还车 → 系统根据租期和超时情况计算费用 → 订单完成
这中间有一条容易被忽视的并行线:管理员对车辆状态的管控。车辆不是永远可租的,它在订单审核通过的那一刻起就应该变为“已租”,否则就会出现同一辆车被两个人同时下单的尴尬局面。这条车与订单之间的状态联动,是整个系统最核心的部分。
把流程理解成一条线之后,你会发现所谓的“设计与实现”,其实就是把这条线上每一站的输入、输出、异常情况搞清楚,再用代码表达出来。
1.2 角色与功能清单:哪些必须有,哪些只是凑数
汽车租赁系统通常是双角色的,管理员和普通用户。功能上我建议分两类:一类是“没有就说不过去”的核心功能,一类是“有则锦上添花”的辅助功能。核心功能必须在五天以内能做完且做稳,辅助功能看剩余时间再决定。
先看用户端的核心功能:
- 注册与登录(含密码加密存储)
- 车辆列表浏览与关键字/品牌筛选
- 车辆详情页
- 下单租车(选择取车时间、还车时间)
- 我的订单列表(全部、待审核、租赁中、已完成等状态筛选)
- 取消订单(只在待审核状态下允许)
用户端的辅助功能:个人资料修改、押金充值记录、通知公告查看、租车评价。这些有时间就做,没时间不做也能交差。
再看管理端核心功能:
- 管理员登录
- 车辆管理(新增车辆、编辑信息、下架、上架)
- 车辆列表(含条件分页查询)
- 订单审核(通过/拒绝,拒绝要填原因)
- 订单管理(查看所有订单,标记还车并结算)
- 用户管理(查看用户列表,禁用/启用用户)
管理端的辅助功能:数据统计(每日订单量、收入汇总)、图片上传、公告管理。数据统计这块如果第四五章的技术点吃透了,做起来并不难,但如果时间紧张,留着不做也不影响主线逻辑。
这套功能清单列出来之后,你会发现它和酒店管理、图书管理、设备管理系统高度相似。本质上都是“资源 + 预订 + 状态管理”,只是资源的字段不同而已。你把这个共性想通了,后面看代码的时候就不会被各种业务细节带跑。
2. 技术选型定生死:为什么我推荐SpringBoot 2.7.x这一套组合
2.1 版本怎么选,别追新
毕设项目的第一原则是求稳,第二原则是资料多。如果你是用搜到的问题去排错,资料多的版本能让你少掉一半头发。
我建议你选这一套组合,这是现阶段最稳的:
| 组件 | 推荐版本 | 原因 |
|---|---|---|
| JDK | 1.8 或 11 | 大多数学校和教材还在用,兼容性最好 |
| SpringBoot | 2.7.x | 资料极其丰富,能覆盖你遇到的绝大多数报错 |
| MyBatis-Plus | 3.5.x | 单表CRUD几乎不用写SQL,省时省力 |
| MySQL | 5.7 或 8.0 | 两者皆可,8.0记得用8.0的驱动和URL参数 |
| Maven | 3.8+ | 稳定即可 |
| 前端方案 | Thymeleaf / Bootstrap+jQuery / Vue | 按你的前端水平三选一,后面第四章细说 |
SpringBoot 3.x虽然已经是很成熟的版本了,但要求JDK17起步,很多配套的依赖、博客教程和异常解决方案还停留在2.x时代。你花一个下午在版本适配上面,不如把这时间留给业务代码。记住:毕设的核心是业务逻辑完整、设计思路清晰,而不是用了最新版本。
2.2 创建项目失败的头号大坑:start.spring.io 拉不动
每次听到有人说“IDEA创建SpringBoot项目超时”,我第一反应就是网络问题。Spring官方的start.spring.io在国内的访问速度很不稳定,尤其下午高峰时段,很容易卡在加载页面。
解决方案有三个,按推荐顺序排:
- 改用阿里云镜像:在 IDEA 的 HTTP Proxy 或 Server URL 里填
https://start.aliyun.com。这个镜像和官方结构几乎一样,速度稳定。 - 手动建了一个普通Maven项目:如果在IDE里始终创建失败,干脆不要用脚手架。新建一个Maven空项目,在
pom.xml里手动写入spring-boot-starter-parent、spring-boot-starter-web、mybatis-plus-boot-starter、mysql-connector-java等依赖,再写一个启动类。这招最笨,但绝对不会被卡住。 - 换网络环境:如果公司或学校网络有代理限制,创建超时也可能是代理设置的问题。在IDEA的
Settings → Appearance & Behavior → System Settings → HTTP Proxy里改成Auto-detect,或者关掉代理重试。
依赖下载慢是另一个问题,在settings.xml里加阿里云镜像是最常规的操作。Maven的仓库地址配置好了,后面所有依赖都是一次性拉完的。
2.3 包结构怎么摆,决定你后面改代码的心情
包结构是个容易被忽视但后期影响巨大的事情。我见过不少源码,所有类堆在一个包下面,controller、service、mapper混在一起,光找文件就要半天。
我推荐一个比较干净的划分,也是我最常用的方案:
com.example.carrental ├── CarRentalApplication.java // 启动类 ├── common // 通用工具与类 │ ├── Result.java // 统一返回体 │ ├── ResultCode.java // 状态码枚举 │ └── BizException.java // 自定义业务异常 ├── config // 配置类 │ ├── MybatisPlusConfig.java // 分页插件配置 │ ├── WebMvcConfig.java // 拦截器/跨域/静态资源映射 │ └── PasswordConfig.java // BCrypt编码器Bean ├── controller // 接口层 │ ├── UserController.java │ ├── CarController.java │ └── OrderController.java ├── service // 业务层 │ ├── UserService.java │ └── impl │ └── UserServiceImpl.java ├── mapper // MyBatis-Plus的Mapper接口 ├── entity // 数据库实体类 ├── dto // 接收前端参数的类 ├── vo // 返回给前端的视图对象 └── interceptor // 登录拦截器这个结构的关键点在于:entity和vo分开。很多项目图省事,直接拿实体类返回给前端,结果密码字段全暴露了,还不方便加一些额外字段(比如车辆图片的完整地址)。分开写虽然多几个类,但后期改接口的时候就知道有多爽了。
3. 数据库设计:订单表是整个系统的定海神针
3.1 三张核心表,把主体数据落稳
数据库设计的原则是先定核心实体,再扩展辅助表。汽车租赁系统的核心实体就是三个:用户、车辆、订单。辅助表可以有公告、车型分类等,但都是可选项。
用户表user,重点字段如下:
id:主键,自增username:用户名,唯一索引password:加密后的密码,不要存明文phone:手机号id_card:身份证号(做毕设可存可不存,留着体现“租赁需要实名”的业务合理性)status:状态,1正常,0禁用deleted:逻辑删除标记,0未删,1已删
车辆表car,重点字段如下:
id、car_no(车牌号,唯一)、brand(品牌)、model(车型)price_per_day:日租金,用decimal(10,2)deposit:押金,用decimal(10,2)status:车辆状态,1可租,2已租,0下架image:车辆图片地址description:车辆描述,TEXT类型
订单表order,这张表是核心中的核心:
id、order_no(订单编号,可以用时间戳加随机数生成)user_id:用户外键car_id:车辆外键plan_begin_time:计划取车时间plan_end_time:计划还车时间actual_end_time:实际还车时间(还车时更新)total_price:总费用,decimal(10,2)deposit:订单当时的押金额度(从车辆表冗余拷贝过来,因为车以后可能调价)status:订单状态,0待审核,1已通过,2已拒绝,3租赁中(已取车),4已完成(已还车结算),5已取消audit_remark:审核备注,管理员拒绝时填create_time、update_time:创建和更新时间
这里有一个容易被忽略的细节:订单里的deposit和price_per_day为什么要冗余一份?因为车辆表里的价格是可以变动的,而订单一旦生成,费用就应该锁定。如果不冗余,过几天车辆调价了,这笔订单的费用就算不清楚了。这是很典型的业务设计考虑。
3.2 状态流转图:订单的6个状态怎么走
我用文字把状态流转理一遍:
- 用户提交订单 →
待审核(0) - 管理员审核通过 →
已通过(1),同时车辆状态改为已租(2) - 管理员审核拒绝 →
已拒绝(2),订单流程结束 - 用户取消 →
已取消(5),只能在待审核(0)状态下操作 - 用户取车(取车动作可由管理员确认)→
租赁中(3) - 用户还车,管理员确认 →
已完成(4),同时车辆状态改回可租(1)
这个状态机的核心约束有两条:
第一,待审核状态是取消订单的唯一起点。已经通过的订单不能随意取消,只能走完租赁流程。这个约束要在Service层做校验。
第二,订单状态和车辆状态必须联动变更。审核通过时改车为已租,还车完成时改车为可租。这一步需要加事务,否则会出现订单是已通过、车却是可租的不一致状态。后面第6章我还会专门讲事务的坑。
3.3 状态字段用数字还是字符串
照着上面写就好。数据库里存整数,代码里定义一个OrderStatusEnum枚举类,把每个数字对应的含义写清楚。这样数据库查询高效,代码可读性也好,前后端约定的字段值就是整数。用字符串pending、approved也不是不行,但会白白增加存储成本和比较开销,没必要。
建表时给order表的user_id和car_id加上普通索引,给car表的brand加上普通索引。这样分页查询和筛选时能走索引,虽然毕设数据量小可能感觉不到差异,但评阅老师问到索引设计时你能答出来,这是加分项。
4. 核心模块实现:从车辆发布到还车结算的完整闭环
4.1 前端方案怎么选,这是很多人的盲区
先解决一个前置问题:这个系统要不要做前后端分离?
我观察到的现象是,很多同学被“前后端分离”这四个字绑架了,明明没有Vue基础,硬要搭一个Vue3 + Element Plus的前端,结果光是npm依赖和跨域问题就耗了三四天。
我的建议很直接:
- 如果你前端基础弱,用Thymeleaf 服务端渲染,这是最稳的方案。SpringBoot自带模板引擎,能直接在HTML里写Java语法一样的东西,不用跨域、不用考虑token传参,代码量最少。
- 如果你想界面好看一点,用Bootstrap + jQuery + Ajax,后端只提供JSON接口。前端页面还是放在
static目录下,用Ajax请求后端接口。这种方式前后端也算分离,但没有工程构建的复杂度。 - 如果你Vue有一定基础,或者想挑战一下,再考虑真正的前后端分离:Vue单独工程,通过反向代理或CORS解决跨域,登录用JWT。做成了是加分项,做不成就会拖累进度。
对于大多数希望顺利毕业的同学,我推荐第二种或第三种之间的平衡:后端统一返回JSON,前端用Bootstrap写页面。这样后端接口可以单独用Postman测,前端页面也不会太丑。
4.2 车辆管理:管理员的核心战场
管理员的车辆管理,本质是对car表做增删改查。但有几个细节需要注意。
新增车辆时,车辆图片是一个处理难点(我一开始是后端只接收图片地址,让管理员填一个外链URL,这样最简单。如果想让体验更好,就用MultipartFile上传到本地目录,再把访问地址存数据库。Upload File 保存路径要用配置项,不能写死)。
车辆列表分页查询,用MyBatis-Plus的分页插件:
@GetMapping("/admin/car/list") public Result carPage(@RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "10") Integer pageSize, @RequestParam(required = false) String keyword) { LambdaQueryWrapper<Car> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.hasText(keyword), Car::getBrand, keyword) .or(StringUtils.hasText(keyword)) .like(StringUtils.hasText(keyword), Car::getModel, keyword); wrapper.orderByDesc(Car::getCreateTime); Page<Car> page = new Page<>(pageNum, pageSize); return Result.success(carMapper.selectPage(page, wrapper)); }这里用LambdaQueryWrapper而不是QueryWrapper,好处是类型安全,字段名写错了会在编译期就报错,而不是运行期才炸出来。车辆上下架,直接改status字段就行,核心是把状态值约定好。
4.3 用户下单:并发校验这一行代码是灵魂
用户下单的核心逻辑分三步:校验、写订单、改车辆状态。对于毕设来说,最容易写出问题的是校验这一步。
给大家看一个我把并发校验做对的版本:
@Transactional(rollbackFor = Exception.class) public Result createOrder(OrderCreateDTO dto) { // 1. 校验车辆存在且可租 Car car = carMapper.selectById(dto.getCarId()); if (car == null || car.getStatus() != 1) { throw new BizException("车辆不存在或已被租出"); } // 2. 生成订单 Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(dto.getUserId()); order.setCarId(car.getId()); order.setPlanBeginTime(dto.getPlanBeginTime()); order.setPlanEndTime(dto.getPlanEndTime()); order.setDeposit(car.getDeposit()); order.setStatus(0); orderMapper.insert(order); // 3. 将车辆状态从可租(1)改为已租(2) int rows = carMapper.update(null, new LambdaUpdateWrapper<Car>() .eq(Car::getId, car.getId()) .eq(Car::getStatus, 1) .set(Car::getStatus, 2)); if (rows == 0) { throw new BizException("车辆已被抢租,请换一辆"); } return Result.success(order.getId()); }请特别注意第3步这一段。如果按很多人的习惯写法,先selectById查到车辆,判断是可租,然后updateById改状态,这在单线程下没有问题,但两个用户同时请求时,两个请求都能查到同一个“可租”状态,然后都执行更新,就会出现超租(超卖)。解法是把这个状态判断放进update的 where 条件里,用数据库的行锁保证原子性——更新影响行数为0,说明条件不满足了,直接抛业务异常。
这个点如果能在答辩时讲出来,是很扎实的一个亮点。
4.4 审核与还车结算:费用计算必须在后端做
管理员审核订单时,通过就把状态改成已通过,并确保车辆状态已经是已租;拒绝则填备注并改成已拒绝。这里同样要有事务和状态校验。
还车结算是整个系统业务味最浓的部分。我的结算规则是这样设计的:
- 租用天数为计划还车时间减去计划取车时间,按天向上取整
- 基础费用 = 日租金 × 租用天数
- 若实际还车时间晚于计划还车时间,超时部分按小时计费,每小时费用 = 日租金 ÷ 24
- 总费用 = 基础费用 + 超时费用,押金在还车后自动退回(毕设里标记一下即可)
计算金额时必须用BigDecimal,绝不能用double。浮点数运算在金额场景下会出精度问题,比如0.1 + 0.2在二进制浮点里并不等于0.3。用上BigDecimal并用ROUND_HALF_UP保留两位小数,才是规范做法。
public BigDecimal calculateTotalPrice(BigDecimal pricePerDay, LocalDateTime planBegin, LocalDateTime planEnd, LocalDateTime actualEnd) { // 租用天数:向上取整 long days = ChronoUnit.DAYS.between(planBegin, planEnd); BigDecimal basePrice = pricePerDay.multiply(BigDecimal.valueOf(days)); // 超时费用:晚还部分按小时计算 BigDecimal totalPrice = basePrice; if (actualEnd != null && actualEnd.isAfter(planEnd)) { long hours = ChronoUnit.HOURS.between(planEnd, actualEnd); if (actualEnd.plusHours(-hours).isBefore(planEnd)) { hours = hours + 1; // 不足一小时按一小时算 } BigDecimal hourPrice = pricePerDay.divide(BigDecimal.valueOf(24), 2, RoundingMode.HALF_UP); totalPrice = basePrice.add(hourPrice.multiply(BigDecimal.valueOf(hours))); } return totalPrice.setScale(2, RoundingMode.HALF_UP); }这段代码里,超时部分我把“不足一小时按一小时算”的规则也写进去了。这种细节平时测试不容易发现,但评阅老师问起来,你能说出自己的计费规则,就已经说明你懂业务了。
5. 技术亮点集中落实:登录鉴权、统一返回体和自动化特性
5.1 统一返回体和全局异常,代码立刻有了工程味
我见过不少项目,Controller返回什么类型的都有,有的返回Map,有的直接返回实体类,有的返回String。这样写一时爽,前端对接的时候就要哭了。
我习惯定义一个统一返回体Result<T>,包含code、message、data三个字段,再配一个静态方法:
public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> r = new Result<>(); r.setCode(200); r.setMessage("success"); r.setData(data); return r; } public static <T> Result<T> error(Integer code, String message) { Result<T> r = new Result<>(); r.setCode(code); r.setMessage(message); return r; } }配合全局异常处理@RestControllerAdvice,业务层直接throw new BizException("车辆不存在"),前端就能拿到统一的错误结构。Controller里不再出现try-catch,代码清爽很多。这是很低成本的工程化优化,但对项目的可维护性提升是立竿见影的。
5.2 JWT + 拦截器:前后端分离下的登录状态管理
登录方式现在的主流选择是JWT。原理不复杂,就是一个包含用户信息的Base64编码串,用密钥做签名,每次请求时带在请求头里,后端验签后就能知道是哪个用户。
JWT的特点是服务端无状态,所以后端不需要存session。对于毕设来说,需要处理的完整链路是:
- 用户登录成功后,根据
userId和username生成token返回给前端 - 前端把token存起来,每次请求放在
Authorization请求头里 - 后端自定义一个拦截器,拦截
/api/**下的请求,除了登录、注册、车辆列表这几个白名单接口 - 拦截器里解析token,如果解析失败或过期,直接返回401未登录的JSON
关键代码在拦截器里:
public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求 if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (token == null || token.isEmpty()) { response.setStatus(401); response.setContentType("application/json;charset=utf-8"); response.getWriter().write("{\"code\":401,\"message\":\"未登录\"}"); return false; } try { Claims claims = Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody(); request.setAttribute("userId", claims.get("userId")); return true; } catch (Exception e) { response.setStatus(401); response.setContentType("application/json;charset=utf-8"); response.getWriter().write("{\"code\":401,\"message\":\"登录已过期\"}"); return false; } } }解析用户信息这一步,我习惯用request.setAttribute存起来,Controller里再从request里取出userId。相比用ThreadLocal,这种方式更简单,写起来也不容易错。
5.3 MyBatis-Plus 三板斧:逻辑删除、自动填充、分页插件
MyBatis-Plus是毕设提速的神器,但有三个配置你得知道,不然用起来会有“怎么不生效”的困惑。
逻辑删除是第一个。在实体类的deleted字段上标注@TableLogic,BaseMapper的删除操作会自动变成更新操作,查询时自动带上deleted=0的条件。这样用户删除后还能在后台看到痕迹,不会真的把数据删没。配置逻辑删除后,注意所有自定义SQL里也要手动加条件,否则会有隐患。
自动填充是第二个。在create_time和update_time字段上标注@TableField(fill = FieldFill.INSERT)和@TableField(fill = FieldFill.INSERT_UPDATE),再实现一个MetaObjectHandler的配置类,插入和更新时自动填充时间。省去每次手动 set 时间的步骤。
分页插件是第三个。MyBatis-Plus的分页功能不是默认开启的,要在配置类里注册:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(200L); interceptor.addInnerInterceptor(pagination); return interceptor; } }不加这个Bean,selectPage执行后其实查的是全表数据,不会真正分页。这个坑我见过不少同学踩,特意提出来。
5.4 密码加密:BCrypt,别再用MD5
账密认证场景下,MD5和SHA这类哈希算法是不合适的。它们速度快、无盐值,彩虹表一查一个准。正确做法是用BCrypt,Spring Security里面就带了BCryptPasswordEncoder,单独引入spring-security-crypto依赖不用整个引入Spring Security,也能用。
BCrypt最大的特点是每次加密同一个明文,生成的密文都不一样,因为它内部自动加盐。验证时matches(明文, 密文)方法判断是否匹配。注册时加密存入,登录时匹配判断。
实际项目中我会在配置类里注册一个PasswordEncoder的Bean,然后用它来做注册和登录的加解密。这个点算是最容易演示的答辩亮点之一——你把密文展示给老师看,说明你考虑过真实系统的安全问题,比用明文存储的强太多。
6. 编码阶段最容易踩的六个坑,每个都真实发生在我调试过程中
6.1 图片上传后路径失效,刷新就404
图片保存到本地目录后会遇到一个经典问题:数据库存的是D:/upload/car/1.jpg这样的绝对路径,结果前后端一部署,路径根本访问不了。
我的解法是,数据库中只存相对路径/upload/car/1.jpg,然后在配置类里把本地磁盘目录映射成虚拟路径:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Value("${carrental.upload-dir}") private String uploadDir; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceHandler("file:" + uploadDir + "/"); } }这样前端只认/upload/xxx.jpg,底层文件放在哪里无所谓。部署到服务器时,只用改配置里的路径就行,代码不用动。简单有效。
6.2 LocalDateTime 前后端显示得一塌糊涂
Java 8的LocalDateTime和前端JS的日期格式默认对不上,经常出现前端收到一串数字(时间戳)或者“T”字符分隔的格式。解决起来很简单,在配置类里统一注册一个Jackson的序列化器,或者直接在实体类时间字段上加@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")。
我两种方式都用过,更推荐在配置类里全局配置,这样实体类上不用每个都加注解。
6.3 订单状态和车辆状态不同步,事务没生效
一个典型的“改订单状态成功,改车辆状态失败”场景。两个写操作必须在一个事务里,要么同成功,要么同失败。
容易踩的坑是@Transactional加在了私有方法上,或者同一个类内部调用this.xxx()方法时事务失效。Spring事务是基于代理实现的,内部调用代理不生效。
正确的打开方式是:
@Transactional加在 public 方法上- 事务方法由外部调用,不要同类内部自调用
如果遇到自调用场景,把事务方法拆到另一个Service类里,或者用AopContext.currentProxy(),后者毕设阶段不用深究,知道拆类就好。
6.4 分页插件配置了还是不生效
很多人配好了MybatisPlusInterceptor但还是查出全表,常见原因有两个:
一是版本不兼容。引入的MyBatis-Plus版本太老,分页插件类路径变了。3.5.x之后统一用MybatisPlusInterceptor,旧的用法已经弃用。
二是SQL里自己写了limit或者在XML里没有传递Page参数。分页生效的前提是Mapper方法第一个参数是Page对象,或者在XML里用IPage作为参数。不要自己在SQL里写死limit。
6.5 跨域拦截器报错:OPTIONS请求被卡住
前后端分离后,页面第一次发请求时会先发一个OPTIONS预检请求。如果拦截器把OPTIONS直接拦了,跨域错误层出不穷。
处理方式在前面JWT拦截器的代码里有,对OPTIONS请求直接放行。另外还需要一个 CORS 全局配置:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }把这两个点都处理好,跨域问题基本就消失了。
6.6 Lombok 装上却没生效
如果实体类上写了@Data,但代码里找不到 getter/setter 方法,大概率是IDEA的 Lombok 插件没启用,或者项目的 Annotation Processing 没打开。
结合IDEA的路径:Settings → Build, Execution, Deployment → Compiler → Annotation Processors,勾选Enable annotation processing。新版IDEA通常会询问是否启用插件,老版本需要手动确认。这个问题不大,但每次遇到都会让人莫名烦躁。
7. 答辩实战:把项目讲清楚比把项目写出来更重要
7.1 开场三分钟,按这个顺序讲不慌
很多同学答辩时喜欢从登录页面讲起,一页一页点过去,结果讲了五分钟老师还不知道你的系统整体是什么样。我的建议是开场按下述节奏来:
首先用一句话概括系统:这是一个基于SpringBoot的汽车租赁管理系统,面向管理员和普通用户两类角色,实现了从车辆发布、用户下单、订单审核到还车结算的完整租赁闭环。
然后快速画一张简单的业务流程图或状态流转图,不用太精细,目的是让老师建立全局认知。
接着讲技术架构:后端用SpringBoot,持久层用MyBatis-Plus,数据库MySQL,鉴权用JWT。如果用了Redis或Vue,在这时一并带出来。
最后演示核心功能时,挑三个链路讲,不要零散点按钮:
- 用户注册登录 → 浏览车辆 → 下单 → 管理员审核 → 还车结算
- 管理员新增车辆 → 上下架
- 用户取消订单或管理员拒绝订单的异常流程
按链路演示,会让人觉得你对自己的系统有完整把握。
7.2 高频追问Top 8,提前把答案准备好
我把评阅老师最爱问的问题整理成人话版答案,你最好能脱稿回答:
为什么用SpringBoot而不是SSM?SpringBoot简化了配置,内嵌Tomcat能直接打包运行,自动配置帮我们省掉了大量XML配置,让开发者更专注业务。这也是当前企业主流的后端框架。
SpringBoot自动配置的原理是什么?@SpringBootApplication包含@EnableAutoConfiguration,启动时根据META-INF/spring.factories里的配置类,结合当前classpath下的依赖,按条件装配Bean。
JWT和Session有什么差别?Session存在服务端内存里,分布式场景需要共享存储;JWT服务端不存状态,每次请求通过验签来认证,适合前后端分离和水平扩展。但JWT也有缺点,服务端无法主动吊销,所以过期时间不能设太长。
订单状态是怎么管理的?用状态字段加枚举,定义了6个状态并按流程转移。状态变更时会加事务,保证订单状态和车辆状态一致。
并发下同一辆车被两个用户同时下单怎么办?我用的是条件更新UPDATE car SET status=2 WHERE id=? AND status=1,利用数据库行锁保证只有一个请求能更新成功。
数据库有哪些索引,为什么建这些?用户表的用户名字段建了唯一索引,订单表的用户id、车辆id建了普通索引,车辆表的品牌建了普通索引。查询驱动的场景下,这些索引能有效减少回表次数。
分页查询是怎么实现的?MyBatis-Plus的分页插件,底层是拦截器在SQL执行前把Page参数解析成limit语句,自动帮我们拼分页。
项目做了哪些测试?核心接口用Postman做了完整的功能测试,覆盖正常流程和主要异常流程,比如重复下单、取消等。如果时间允许可以提一句用Swagger统一了接口文档。
这8个问题答好了,基本就能稳住了。
7.3 演示环节,三个容易翻车的细节
演示账号一定要提前准备好。管理员和普通用户各准备一个,密码设置成简单的,提前登录一次,避免现场输入错误或者账号被禁用。
数据库要提前启动好,SQL脚本重新执行一遍,保证数据干净。演示的时候在里面故意留一辆可租的车和一个待审核的订单,这样流程能一气呵成。
如果演示过程中代码报错,千万不要当着老师的面改代码。先深吸一口气,看错误信息,如果是环境问题,直接说“这个接口我在单元测试里验证过,稍等我看下环境状态”。如果是业务问题,诚实说“这里我在开发时可能短路了,我的设计预期是XX”,也比愣在原地强。
最后再分享一个小建议。源码拿到手之后,别急着跑起来,先用一个下午把三样东西各读两遍:建表SQL、订单状态流转和核心Service。能随手画出订单状态图,能在黑板上写出订单表字段,这比任何润色话术都好用。毕设源码不是什么玄学,它就是一套带着业务逻辑的代码,你顺着业务走一遍,自然就通了。