房车旅游这两年确实火起来了,但真去搜一下“房车租赁系统”“房车买卖平台”这类题目,你会发现网上能直接参考的完整项目少得可怜。要么是老掉牙的JSP+Servlet,要么是只做了个租赁模块的半成品。我见过太多学生拿到这种题目后一头扎进代码里,最后卡在“车被租出去了怎么还能卖”“订单时间重叠怎么判断”这种实际业务问题上。这篇博文我就拿“基于SpringBoot的房车租赁与售卖一体化平台”这个项目当例子,把从选题拆解、技术选型、数据库设计到前后端联调、答辩准备的完整链路讲清楚,代码和SQL都是能直接复用的。
1. 项目定位与整体设计思路
1.1 为什么做“租赁+售卖”一体化,而不是单独做租赁
很多同学拿到这个题目,第一反应是“那我就做个租车系统呗”。但题目里明确写了“租赁与售卖一体化平台”,这才是真正的考点和加分点。
从业务场景看,房车这个品类的特殊性在于:一辆房车既可以作为租赁资源挂在租赁板块,也可以作为二手车放在售卖板块。如果只做租赁,就丢了“售卖”这条业务线;如果只做售卖,又和普通二手车平台没区别。把它俩揉进同一个平台,本质上是在考验你对“同一资源的多状态管理能力”——这是真实企业级系统里非常常见的需求,比如仓库里的库存商品既能零售也能批发,酒店房间既能单卖也能包月。
从毕设答辩的角度看,一体化平台的业务链路更长:顾客可以浏览在租车辆下单、可以浏览在售车辆提交购买意向、可以查看自己的租赁订单和购车订单、可以评价车辆;管理员可以维护车辆信息、审核订单、管理用户、查看统计报表。业务角色多,功能模块自然就多,论文的“系统设计”和“功能实现”章节就能写得非常充实。我见过不少选了简单题目的学生,论文写到后面无话可说,只能硬凑字数,而这个题目天然就有写不完的内容。
1.2 技术选型为什么锁定SpringBoot+Vue
先说结论:计算机毕业设计这个场景下,SpringBoot+Vue就是当前的最优解,没有之一。
后端选SpringBoot的理由很直接。它是现在Java后端的事实标准,内置了Tomcat,不用像SSH、SSM那样做大量的XML配置。你在pom.xml里引入依赖、写个启动类,一个可运行的服务就起来了。这对毕设来说极其重要——时间是有限的,把精力花在业务逻辑上,而不是花在“配一个web.xml配三天”上。
前端选Vue的理由也类似。Vue的组件化开发方式让页面拆分成一个个组件,比如车辆卡片组件、订单状态组件、轮播图组件,开发效率和可维护性都远超传统的JSP + jQuery。配合Element Plus组件库,表格、表单、弹窗、分页这些后台管理最常见的UI组件都是现成的,拖出来就能用。Vue Router负责前端路由跳转,Vuex或Pinia负责状态管理,axios负责HTTP请求,这套组合拳打下来,一个完整的管理后台加用户端页面,一个人两周左右完全可以拿下。
这里我要特别强调一点:不要用前后端不分离的写法,更不要用模板引擎套Vue。我见过有学生把Vue当成一个库引入HTML页面里,然后用Thymeleaf渲染数据,最后项目变得四不像。既然用了Vue,就老老实实建一个独立的Vue工程,通过HTTP接口和后端通信。这样前端工程和后端工程边界清晰,答辩时也能理直气壮地讲“前后端分离架构”。
1.3 角色权限与核心功能模块划分
这个平台我建议设计三种角色:管理员、销售员、普通用户。有人会问,销售员是不是没必要?如果你想让项目“看起来”更完整,销售员这个角色非常值钱。它让系统有了“员工管理”和“业务分配”的概念,论文里能多出一整节“多角色权限控制”。
- 普通用户:注册登录、浏览车辆(租赁+售卖)、提交租赁订单、提交购车意向、在线支付模拟、查看个人订单、取消订单、评价车辆、个人信息维护。
- 销售员:管理自己名下的车辆上下架、处理租赁订单审核、处理购车意向跟进、库存查看。
- 管理员:用户管理(禁用/启用)、车辆管理(新增、编辑、上下架、删除)、订单管理(全量)、数据统计(租赁收入、车辆热度)、公告管理。
梳理完角色后你会发现,所有功能都围绕“车”和“订单”这两条主线展开。租赁是一条线:浏览→下单→待取车→租赁中→已还车→已完成;售卖是一条线:浏览→提交意向→销售跟进→成交→车辆下架。两条线交叉的地方就是“车辆状态”这个核心字段,这也是整个项目最重要的技术难点,下一节我详细拆。
2. 核心难点拆解与关键技术实现
2.1 车源状态机的设计:租赁和售卖如何共用一套库存
这个项目的灵魂,就是车辆状态流转。一辆车不是单纯的“在租”或“在售”,它的状态是一个有限状态机。
我用一个字段status来标识车辆当前状态,取值如下:
0空闲:既可以被租赁下单,也可以被售卖下单。1租赁中:已被租赁订单占用,不可再被租赁,也不可被售卖。2维护中:管理员手动标记,不可被租赁,不可被售卖。3已售出:已完成售卖流程,车辆从平台下架。4已停用:管理员手动下架,不对外展示。
这五个状态之间的转换规则不能乱来。租赁订单取消时,车辆从“租赁中”回到“空闲”;租赁订单正常还车后,回到“空闲”;购车意向成交后,车辆从“空闲”或“租赁中”(如果还车了)变为“已售出”。这里最容易出bug的场景是:用户在浏览列表时看到一辆“空闲”的车,准备下单时这辆车恰好被另一个人抢先租了。如果你不做并发控制,两个人可能会同时下单成功,这就是典型的“超卖”问题。
解决方案是在租赁订单生成的SQL层面加一个条件更新:
UPDATE car SET status = 1 WHERE id = #{carId} AND status = 0然后判断这次更新的影响行数,如果为0说明车辆已被占用,下单失败。这样从数据库层面保证了“先到先得”,比在代码里先查询再判断要可靠得多。
2.2 租赁订单的日期冲突检测:时间段重叠判断
租赁业务有个经典问题:用户A租了7月1日到7月5日,用户B想租7月4日到7月8日,这辆车能不能租给B?答案是不能,因为时间段有重叠。
判断两个时间段是否重叠,SQL写法非常经典:
WHERE car_id = #{carId} AND status = 1 AND start_date < #{endDate} AND end_date > #{startDate}这个条件的含义是:已存在的某条租赁订单的开始日期早于新订单的结束日期,并且已存在订单的结束日期晚于新订单的开始日期。满足这个条件就说明时间有交集。这个SQL是租赁系统的核心,建议把(car_id, start_date, end_date)建一个联合索引,否则车辆数据量大了之后查询会明显变慢,答辩时老师也喜欢问这个索引怎么设计。
注意一个坑:start_date < #{endDate}这边用严格小于,end_date > #{startDate}也用严格大于。如果你允许“当天还车当天续租”这种场景,边界条件要提前和需求方确认清楚。我个人习惯是“还车日当天中午12点前可续租”,但毕设里不要搞那么复杂,统一按整天算,日期重叠就拒绝下单。
2.3 多条件组合查询:车辆检索功能的后台实现
用户端车辆列表页通常需要这样几个筛选项:车辆名称关键字、车型(自行式/拖挂式)、核载人数、日租金区间、状态(只看可租/只看可售)。这种多条件且条件可空的查询,用MyBatis Plus的QueryWrapper最舒服:
LambdaQueryWrapper<Car> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.hasText(keyword), Car::getCarName, keyword); wrapper.eq(StringUtils.hasText(carType), Car::getCarType, carType); wrapper.ge(minPrice != null, Car::getRentPrice, minPrice); wrapper.le(maxPrice != null, Car::getRentPrice, maxPrice); wrapper.eq(status != null, Car::getStatus, status); wrapper.orderByDesc(Car::getCreateTime);这段代码的精髓在于:like、eq、ge、le这些方法的第一个参数是个布尔值,条件不成立时这个条件直接不拼进SQL。这样就省掉了一大堆if (xxx != null && !xxx.equals(""))的判断,代码非常干净。
分页用MyBatis Plus的Page对象:
Page<Car> page = new Page<>(current, size); IPage<Car> result = carMapper.selectPage(page, wrapper);返回给前端时带上总条数total和当前页数据records,前端Element Plus的el-pagination组件直接对应上。
2.4 模拟支付与订单状态流转
真实对接支付宝/微信支付在毕设里成本太高,要商户号、要证书、要回调域名,完全没必要。我推荐的做法是模拟支付回调:前端点击“去支付”后,调用后端接口,后端直接修改订单状态为“已支付/待取车”,同时生成一笔支付流水记录,模拟一笔交易成功。
代码实现大概是:
@Transactional public void payOrder(Long orderId) { RentOrder order = rentOrderMapper.selectById(orderId); if (order == null || !order.getStatus().equals(OrderStatus.UNPAID)) { throw new BizException("订单不存在或状态异常"); } // 模拟支付成功 order.setStatus(OrderStatus.PAID); rentOrderMapper.updateById(order); // 记录支付流水 PaymentRecord record = new PaymentRecord(); record.setOrderId(orderId); record.setAmount(order.getTotalPrice()); record.setType("ALIPAY_SIMULATE"); paymentRecordMapper.insert(record); }注意@Transactional注解,保证“修改订单状态”和“记录流水”要么一起成功要么一起失败。答辩时老师经常会问“如果支付成功但流水没记录下来怎么办”,你回答“用事务保证原子性”,这就是加分点。同时你会定义一个PaymentRecord表,之后想扩展真实支付时,只需要替换controller层逻辑,让支付成功后调用微信支付接口,再把回调地址写成一个新接口即可,这种“面向扩展设计”的思路也是答辩亮点。
有一点要提醒:模拟支付不是说“点击支付直接改状态”这样简单。你仍然要把“生成订单”和“支付”拆成两个接口:用户提交订单时订单状态是“待支付”,支付按钮调用另一个接口。这样整个流程才完整,各种异常场景(比如超时未支付)才有地方处理。
2.5 JWT权限控制与前端路由守卫
后端接口不能裸奔,除了登录注册接口外,其他接口都要校验身份。我推荐JWT方案:用户登录成功后后端签发一个token(包含用户ID、用户名、角色),前端存到localStorage里,之后每次请求都带上。
后端写一个拦截器来做统一认证:
public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { token = token.substring(7); try { Claims claims = JwtUtil.parseToken(token); request.setAttribute("userId", claims.get("userId")); request.setAttribute("role", claims.get("role")); return true; } catch (Exception e) { // 解析失败,token无效 } } response.setStatus(401); return false; } }然后在配置类里注册拦截器,并放行登录、注册和车辆列表查询这些公开接口。这里我踩过一个坑:如果车辆列表接口也加了拦截器,前端未登录用户就看不到任何车辆,整个系统直接用不了了。所以公开接口的放行路径要仔细核对。
前端对应做两层保护。第一层是在axios请求拦截器里携带token:
axios.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers['Authorization'] = 'Bearer ' + token; } return config; });第二层是在Vue Router里加全局前置守卫:如果用户访问一个需要登录的页面但没有token,跳转到登录页。如果用动态路由,管理员和销售员登录后加载各自的菜单和路由表,效果更高级。
3. 实操过程:从数据库设计到前后端联调
3.1 核心数据库表设计
这个项目的核心表我列出主要的几张:
user:用户表。字段包括 id、username、password(BCrypt加密)、real_name、phone、role(0普通用户/1销售员/2管理员)、avatar、status、create_time。car:车辆表。字段包括 id、car_name、brand、car_type(自行式/拖挂式)、seats、rent_price(日租金)、sale_price(售价)、status(状态机)、description、cover_image、create_time。rent_order:租赁订单表。字段包括 id、order_no(订单编号,建议用时间戳+随机数生成)、user_id、car_id、start_date、end_date、total_days、total_price、deposit(押金)、status(0待支付/1已支付待取车/2租赁中/3已完成/4已取消)、create_time。sale_order:售卖订单表(或者叫购买意向表)。字段包括 id、order_no、user_id、car_id、sale_price、pay_status(0意向提交/1已支付/2已完成交易/3已取消)、handler_id(负责销售的员工)、remark、create_time。comment:评价表。字段包括 id、user_id、car_id、order_type(租/买)、content、score、create_time。payment_record:支付流水表。字段包括 id、order_no、order_type、amount、pay_type、pay_time。
建表时注意几点:日期字段统一用datetime;金额字段用decimal(10,2),不要用double,否则精度会出问题;所有表都带create_time和update_time,便于排查问题;字段注释一定要写,论文里放数据库设计表的时候直接截图即可。
订单编号一定要单独设计,不能直接用自增id。我习惯用yyyyMMddHHmmss加6位随机数生成,比如20250615143025123456。这样订单号看起来专业,而且保证了唯一性。
3.2 SpringBoot后端工程结构搭建
后端工程结构直接决定代码是否清晰。我推荐这样分包:
com.example.rvplatform ├── config // 配置类:CorsConfig、JwtInterceptorConfig、MybatisPlusConfig ├── controller // 控制器层 ├── service // 业务接口 │ └── impl // 业务实现 ├── mapper // MyBatis Plus Mapper接口 ├── entity // 实体类 ├── common // 通用类:统一返回结果R、异常类BizException、页面参数PageParam ├── util // 工具类:JwtUtil、OrderNoUtil └── RvApplication.java // 启动类common包里放一个统一返回结果类R,所有接口返回R.success(data)或R.error("msg"),前端根据返回码统一处理。这个习惯一定要养成,不要有的接口返回Map、有的返回JSONObject、有的直接返回实体,不然前后端联调时你会疯掉。
MyBatis Plus的分页插件需要在配置类里注册:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }这个地方漏掉配置会导致分页不生效,查出来的数据永远是全表,非常坑。
3.3 前端Vue工程与页面开发
前端用Vue 3 + Vite + Element Plus + Pinia + Vue Router这套组合。Vite创建项目很快:
npm create vite@latest rv-frontend -- --template vue npm install element-plus axios pinia vue-router页面结构我建议这样组织:
src ├── api // 接口请求封装:car.js、order.js、user.js、comment.js ├── components // 通用组件:CarCard、UploadImage、SearchBar ├── layouts // 布局组件:前台Layout、后台Layout ├── router // 路由配置 ├── store // Pinia状态:userStore ├── views │ ├── home // 首页 │ ├── car // 车辆列表、车辆详情 │ ├── order // 订单确认、我的订单 │ ├── user // 登录注册、个人中心 │ └── admin // 管理员后台:车辆管理、订单管理、用户管理、统计这里有个关键点:管理后台可以用独立的Layout,侧边栏菜单直接写死或根据角色动态生成。做动态菜单也不难,登录后根据后端返回的角色权限列表,用addRoute动态注册路由。答辩时这个功能很加分,因为大部分人做的都是死路由。
Element Plus的表格和表单是后台开发的效率神器。比如车辆管理页面,一页表格 + 一个新增编辑弹窗 + 一个删除确认框,基本就是复制粘贴改改字段的事。重点要关注图片上传组件,配置接口地址时注意后端要支持文件接收,并且返回可访问的URL。
3.4 前后端联调与接口规范
接口统一使用RESTful风格,路径清晰。比如:
POST /api/user/login登录GET /api/car/list车辆分页列表POST /api/rent/order提交租赁订单POST /api/rent/pay/{orderId}模拟支付GET /api/order/my-list我的订单列表POST /api/admin/car/save管理员保存车辆
接口返回统一结构:
{ "code": 200, "message": "操作成功", "data": { "records": [], "total": 34 } }前端axios封装时,基于返回的code做统一的错误提示:
// 响应拦截器 axios.interceptors.response.use( response => { const res = response.data; if (res.code !== 200) { ElMessage.error(res.message); return Promise.reject(new Error(res.message)); } return res.data; }, error => { if (error.response && error.response.status === 401) { router.push('/login'); } else { ElMessage.error('服务器异常,请稍后重试'); } return Promise.reject(error); } );这样封装完之后,业务代码里只需要关心拿数据,不用到处写if (res.code === 200)。
4. 常见问题与排查技巧实录
4.1 CORS跨域问题
前后端分离联调第一个拦住人的基本就是跨域。前端跑在http://localhost:5173,后端跑在http://localhost:8080,浏览器的同源策略会直接拦截请求。解决方案有两个,我建议两个都做。
后端加CORS配置:
@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }前端Vite配置代理(开发环境更推荐,避免暴露后端地址):
// vite.config.js server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }这样请求http://localhost:5173/api/car/list会被转发到后端。有个坑要注意:设置了代理之后,前端发的请求路径要写/api/...而不是完整URL。
4.2 LocalDateTime序列化与日期参数接收问题
SpringBoot默认对LocalDateTime的JSON序列化格式是"2025-06-15T14:30:00",这个带T的格式前端用日期选择器直接回显不了。解决办法是在application.yml里全局配置:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8但要注意,spring.jackson.date-format只对java.util.Date生效,对LocalDateTime有时不生效。更稳妥的办法是加一个Jackson统一配置,或者直接在实体类的日期字段上加@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")注解。前端传日期字符串给后端时,后端接收参数也得加@DateTimeFormat(pattern = "yyyy-MM-dd"),不然会报转换异常。
4.3 SpringBoot版本太高导致的兼容性问题
这是一个非常现实的坑。现在很多初学者创建项目时直接选SpringBoot 3.x,然后发现一系列问题:
- SpringBoot 3.x要求Java 17+,你本地如果是JDK 8就启动不了。
- SpringBoot 3.x的
javax.servlet换成了jakarta.servlet,很多老教程的代码直接报错。 - 一些老版本的MyBatis Plus、Druid和SpringBoot 3.x不兼容,需要升级到特定版本。
我的建议是:毕设项目优先选SpringBoot 2.7.x。这个版本非常成熟稳定,网上资料最多,MyBatis Plus、Druid、JWT这些组件的兼容性问题最少,教程说怎么配就能怎么配,不会卡在环境问题上。如果你非要尝鲜用3.x,那就要做好自己排查兼容性问题的准备,这会消耗大量时间,对毕设整体进度不划算。
4.4 租车价格计算与押金问题
租赁价格计算看似简单,但有个很容易忽略的点:租期跨月怎么办?我推荐按“整天数”计算,也就是结束日期减去开始日期然后加1(当天取车算一天)。比如7月1日取车,7月3日还车,共3天。
计算逻辑:
long days = ChronoUnit.DAYS.between(startDate, endDate) + 1; BigDecimal totalPrice = rentPrice.multiply(BigDecimal.valueOf(days));注意用BigDecimal做乘法,不要用double直接算,否则会出现0.1+0.2这种精度问题。押金逻辑可以简化:按车辆售价的10%或设置一个固定值,下单时收取,还车后“自动原路退回”(模拟)。答辩时把这个逻辑说明白就行。
4.5 图片上传与回显路径问题
图片上传功能毕设里几乎必做,但经常出问题。后端接收文件:
@PostMapping("/upload") public R<String> upload(MultipartFile file) { String originalFilename = file.getOriginalFilename(); String suffix = originalFilename.substring(originalFilename.lastIndexOf(".")); String fileName = System.currentTimeMillis() + suffix; // 保存到本机某个目录 file.transferTo(new File("D:/upload/" + fileName)); return R.success("/images/" + fileName); }前端拿到返回的URL后拼上后端地址使用,比如http://localhost:8080/images/xxx.jpg。这里要配置一个静态资源映射,否则图片访问不到:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/images/**") .addResourcePath("file:D:/upload/"); } }另外注意SpringBoot默认上传文件大小限制是1MB,传大图会报错,需要在yml里调大:
spring: servlet: multipart: max-file-size: 10MB max-request-size: 20MB4.6 答辩时的高频追问与准备方向
毕设答辩老师一般会盯着这几个点问,提前准备好答案能稳住场子:
- “车辆状态怎么保证并发下单不出问题?” 答:SQL条件更新,
update ... where id=? and status=0,用影响行数判断是否抢占成功。 - “为什么用JWT不用Session?” 答:JWT无状态,适合前后端分离,后端不需要存储会话信息,扩展性好。
- “分页是怎么实现的?” 答:MyBatis Plus的分页插件,底层是拦截器,自动拼接
limit语句。 - “如果用户没支付,订单会一直占用车辆吗?” 这里可以做一个超时自动取消逻辑:下单后15分钟未支付,定时任务或延时处理把订单取消并把车辆状态恢复为空闲。毕设里可以用Spring的
@Scheduled定时扫表实现。 - “数据库索引怎么设计的?” 答:高频查询字段比如车辆状态、订单日期加了索引,联合索引
(car_id, start_date, end_date)。
4.7 一个特别容易翻车的运维坑:Vue打包后放到SpringBoot里
很多学校要求最终交付一个能直接运行的项目,有的还要求“把前端打包进后端”。操作其实很简单:
npm run build生成dist目录,然后把dist里的文件复制到后端项目的src/main/resources/static下,重新打包后端。访问http://localhost:8080就能直接看到页面。但要注意:前端里所有接口地址必须写成相对路径/api/...,不能写http://localhost:8080或者http://localhost:5173,否则打包后接口全废。
如果路由用了history模式,刷新页面会404,这时需要后端做转发处理,把非接口路径都转发到index.html。SpringBoot里可以写一个简单的Controller来实现。
我个人在实际操作中的体会是:这个项目最值钱的部分不在于你写了多少页面,而在于“车源状态机”和“订单时间冲突检测”这两个业务细节有没有做对、讲清楚。很多人的毕设项目功能看着挺多,但一问业务细节就露馅。你把这两个核心点吃透,再把并发控制、事务、JWT这些技术点讲明白,答辩拿个不错的成绩是水到渠成的事。最后再分享一个经验:代码里要多写注释,尤其是业务逻辑复杂的地方,比如状态流转的判断条件。这不仅能帮你自己在答辩时快速回想起设计思路,也能让指导老师在检查代码时对你的认真程度留下好印象。