1. 需求边界:做一个能落地的租赁与销售一体化平台
房车旅游这两年确实火起来了,但真正用过房车的人都知道,找车渠道极其有限。我翻过好几个房车租赁小程序,发现大部分还停留在"表单+人工对接"的阶段:用户在线选车、线下打电话确认、再当面签合同签到手软。这个项目的目标很明确——用SpringBoot搭一套旅居车租赁与销售一体化服务平台,把选车、下单、计费、支付、车辆状态管理全部搬到线上,既解决租赁需求,也顺带把房车销售业务管起来。
1.1 房车市场的核心痛点
房车这个品类跟普通轿车完全是两回事。普通轿车租赁是标准的"取车—开走—还车"线性流程,但房车涉及的东西要多得多:
- 车辆属性复杂:一辆房车有座位数、床位数、排量、能源类型(柴油/汽油/混动)、厨卫配置、黑水箱处理方式等等多维参数,用户决策链条比租普通轿车长得多。
- 租期跨度大:普通租车按天算,房车动辄三五天起步,甚至有半个月一个月的长途自驾,订单周期长意味着状态管理必须严谨。
- 价格结构复杂:日租金之外还有保险费、押金、里程费(超过每日限驶里程后按公里计价)、增减配置费用,单纯用"单价×天数"完全不够。
- 销售与租赁并存:很多房车厂商/运营商的库存车辆既做长租体验,也接受整车销售,两种业务共用同一批车辆资源,库存状态一旦不同步就会出乱子。
这些痛点直接决定了系统设计的方向:车辆信息模型要够灵活,订单流程要覆盖完整的租车生命周期,同时必须有一套可靠的状态机来管理车辆在"可租—已租—维保—已售"之间的切换。
1.2 功能清单与角色划分
我把这个平台拆成两类角色、三大模块:
用户端(C端)功能
- 注册登录:手机号+验证码为主,邮箱可选
- 车辆浏览:按类型、能源、座位数、价格区间筛选,支持关键词搜索
- 车辆详情:多图展示、参数表、租用价格、销售价格、档期日历
- 租赁下单:选车→选起止日期→选保险方案→计算费用→提交订单→支付押金和租金
- 销售下单:意向金支付、尾款支付流程
- 个人中心:我的订单、我的车辆(购买后)、租车评价、退款记录
管理员端功能
- 车辆管理:车辆信息CRUD、上下架、状态修改、库存档期管理
- 订单管理:租赁订单审核、取车/还车确认、销售订单收款确认
- 用户管理:用户列表、实名认证审核、驾照信息审核
- 营销管理:优惠券、节假日调价策略
- 数据统计:租赁订单量、销售额、车辆利用率
公共模块
- 系统公告、车辆评价、意见反馈
这个功能清单看着常规,但真正做下来你会发现,核心难点不在页面多不多,而在订单状态流转和车辆状态联动这两个地方。这也是我在后面章节里重点展开的内容。
1.3 租赁+销售两条业务线的取舍
有些同类系统会把租赁和销售做成完全独立的两套模块,车辆表都分成两张。我实际做的时候选择了共用一张车辆表,用状态字段区分业务线。理由有三:
一是房车运营商的库存就是这个特点,同款车既投放到租赁市场,也接受客户购买,分表会带来大量重复数据和同步逻辑。二是租赁订单和销售订单本质都是"对车辆资源的占用",共用车辆表才能做全局的档期和库存校验。三是管理端看板需要看到全量车辆的统一视图,分表会逼着每次查询都做union操作,性能反而更差。
当然,共用一张表对状态管理的要求更高了,这也是为什么我把状态机设计看得比其他模块都重。
2. 技术选型与工程结构:为什么主框架是SpringBoot
现在Java生态里做Web项目,SpringBoot基本是默认选项,但"默认"和"为什么"还是要说清楚。对于这个项目体量来说,SpringBoot最大的价值不是功能多,而是把Spring家族的配置复杂度降到了最低。内嵌Tomcat解决了部署问题,自动配置省掉了大量XML,起步依赖让我不用纠结版本兼容性——这些对单人开发、工期有限的场景来说都是实打实的优势。
2.1 周边组件怎么搭配
我最终确定的组合是:
| 组件 | 选型 | 选型理由 |
|---|---|---|
| 基础框架 | SpringBoot 2.7.x | 稳定、资料多、兼容性好,社区问题基本都能搜到答案 |
| ORM框架 | MyBatis-Plus 3.5.x | 单表CRUD不用手写SQL,复杂查询保留原生SQL灵活性 |
| 数据库 | MySQL 8.0 | 业务成熟度最高,InnoDB对事务支持好 |
| 缓存/分布式锁 | Redis | 车辆档期缓存、防并发重复预订锁、验证码存储 |
| 认证方案 | JWT + 拦截器 | 无状态认证,前后端分离场景下比Session好部署 |
| 前端 | Vue 2 + Element UI | 后台管理页面开发效率高,毕设演示也直观 |
| 项目管理 | Maven | 最主流,不解释 |
这里要说一下为什么没有引入Spring Security。Spring Security功能强,但配置复杂,对单体管理员+普通用户两套角色的授权场景来说,用拦截器加自定义注解就够了。JWT登录成功后把用户ID和角色塞进token,拦截器里解析校验,简单可靠,还方便前后端联调。
2.2 工程分包与公共层设计
项目结构上我没有用过多的分层技巧,就是标准的controller-service-mapper三层,加上一个公共模块:
com.rv.platform ├── controller // 接口层,只做参数接收和结果包装 ├── service // 业务逻辑层,订单服务单独拆两个实现类 │ ├── rental │ └── sales ├── mapper // MyBatis-Plus的Mapper接口 ├── entity // 数据库实体 ├── dto // 前端交互的数据传输对象 ├── vo // 展示层对象,避免把数据库表字段直接暴露给前端 ├── config // 拦截器、跨域、Redis等配置 ├── common // 统一返回结果、异常处理、常量枚举 └── utils // JWT、日期处理等工具类一个容易忽略的细节是DTO和VO要跟Entity分开。比如车辆表里存的上架状态是0/1数字,前端列表页需要显示"上架/下架/已出租"这样的文案,后端接口直接返回数字,前端就得写一堆if判断,后期字段稍微调整就全乱了。我在VO里做了状态映射,前端拿到的就是可直接展示的数据结构。
2.3 统一返回结构与全局异常处理
这块是很多人刚开始做容易忽略的。接口全部返回一个统一格式:
@Data public class Result<T> { private Integer code; // 200成功 400参数错误 401未登录 500系统异常 private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("操作成功"); result.setData(data); return result; } }配合@RestControllerAdvice做全局异常捕获,业务异常(比如"该车辆档期已被预订")统一抛出BusinessException,由切面转成JSON返回。一开始不觉得这有什么,等到前端联调阶段你就知道了——没有统一格式的话,每个接口的返回结构都不一样,前端封装axios拦截器的时候就是一场灾难。
3. 数据库设计:租赁与销售共用的核心表结构
这个项目的数据库设计是整个系统的基础,前后改了四版才定下来。核心原则就一句话:车辆只有一份全局数据,所有业务围绕它转。下面是我最终落地的关键表结构,每一张我都会说说为什么这么设计。
3.1 车辆表:一条数据承载多种业务状态
房车车辆表是所有业务的锚点,字段设计上除了基本的品牌型号,我特别加了几个对房车场景有意义的字段:
CREATE TABLE t_vehicle ( id BIGINT PRIMARY KEY AUTO_INCREMENT, vehicle_name VARCHAR(100) NOT NULL COMMENT '车辆名称,如大通V80 C型房车', brand VARCHAR(50) COMMENT '品牌', vehicle_type TINYINT COMMENT '1-自行式A型 2-自行式C型 3-拖挂式 4-商务旅居', seat_count INT COMMENT '核载人数', bed_count INT COMMENT '床位数', fuel_type TINYINT COMMENT '1-汽油 2-柴油 3-混动 4-纯电', gearbox_type TINYINT COMMENT '1-手动 2-自动', license_plate VARCHAR(20) COMMENT '车牌号,销售后可变更归属', daily_price DECIMAL(10,2) COMMENT '日租金(淡季基准价)', sale_price DECIMAL(12,2) COMMENT '整车销售指导价', deposit_price DECIMAL(10,2) COMMENT '押金金额', mileage_limit INT DEFAULT 200 COMMENT '每日限驶里程(公里)', mileage_unit_price DECIMAL(6,2) COMMENT '超出限驶里程后的每公里单价', location VARCHAR(255) COMMENT '取还车城市/门店', sale_status TINYINT DEFAULT 0 COMMENT '销售状态 0-不可售 1-在售 2-已售', rent_status TINYINT DEFAULT 0 COMMENT '出租状态 0-可租 1-已租 2-停用', vehicle_status TINYINT DEFAULT 0 COMMENT '车辆状态 0-正常 1-维保 2-下架', cover_image VARCHAR(255), detail_images TEXT COMMENT 'JSON数组存储多图URL', description TEXT, create_time DATETIME, update_time DATETIME );我把销售状态和出租状态拆成了两个字段,这个设计决策值得说两句。一辆车可以"在售但暂不可租"(比如客户看中后正在谈判),也可以"正在出租但未挂销售"(比如运营方暂时不打算卖这辆车),拆开来管理比用一个复合状态字段灵活得多。后面做车辆列表筛选时,只需要分别过滤两个字段,逻辑也很清晰。
3.2 租赁订单与销售订单:两张表分开是正确的
订单我坚持分成了t_rental_order和t_sales_order两张表,原因是两种订单的生命周期完全不同。
CREATE TABLE t_rental_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) UNIQUE NOT NULL COMMENT '业务订单号', user_id BIGINT NOT NULL, vehicle_id BIGINT NOT NULL, start_date DATE NOT NULL COMMENT '预计取车日期', end_date DATE NOT NULL COMMENT '预计还车日期', actual_return_date DATE COMMENT '实际还车日期', daily_price DECIMAL(10,2) COMMENT '下单时的日租金快照', total_days INT COMMENT '租期天数', rental_fee DECIMAL(12,2) COMMENT '租金小计', insurance_type TINYINT COMMENT '0-基础险 1-全险', insurance_fee DECIMAL(10,2), mileage_limit INT COMMENT '租期内总里程额度', except_fee DECIMAL(10,2) DEFAULT 0 COMMENT '超里程或违章等附加费', deposit_amount DECIMAL(10,2) COMMENT '押金', total_amount DECIMAL(12,2) COMMENT '应付总金额', status TINYINT COMMENT '0-待支付 1-待取车 2-使用中 3-待归还 4-已完成 5-已取消', take_location VARCHAR(255), return_location VARCHAR(255), create_time DATETIME, update_time DATETIME );销售订单表则更关注车辆交付和付款节奏:
CREATE TABLE t_sales_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) UNIQUE NOT NULL, user_id BIGINT NOT NULL, vehicle_id BIGINT NOT NULL, sale_price DECIMAL(12,2) COMMENT '成交价快照', down_payment DECIMAL(12,2) COMMENT '定金/意向金', balance_payment DECIMAL(12,2) COMMENT '尾款金额', payment_status TINYINT COMMENT '0-未付款 1-已付定金 2-已付全款 3-已退款', delivery_status TINYINT COMMENT '0-未交车 1-已交车', agreement_no VARCHAR(64) COMMENT '合同编号', create_time DATETIME );这里的关键设计是价格快照。用户在订单创建那一刻看到的价格,必须原样存进订单表。如果订单创建后去关联车辆表当前价格,车辆调价或特惠活动结束后,历史订单的所有金额都会错乱。所有涉及金额的交易类业务都应该遵循这个"下单即快照"原则。
3.3 支付、评价、公告等辅助表
辅助表数量不多但每个都有讲究:
- t_payment_record:记录每笔支付流水,关联订单号、支付金额、支付方式(模拟支付/微信/支付宝)、支付状态。这张表除了对账,还要支撑"押金原路退回"这种操作。
- t_review:租车评价表,关联用户和车辆。评价内容里我加了
order_no字段,保证只有实际完成过订单的用户才有权限评价对应车辆,防止刷评。 - t_notice:系统公告表,后台发布,用户端首页展示。
- t_user:用户表,除了基础账号信息,必须有身份证号和驾照号字段。房车租赁实名和驾照审核是运营刚需,很多同学做项目会漏掉这两个字段,实际业务根本绕不开。
四张核心表之间的关系可以用一句话概括:用户既可以下租赁订单,也可以下销售订单;订单关联车辆;支付记录支撑订单;评价反哺车辆信息。一对多、多对一的关联关系都很直观,不需要中间表,这也是这个系统相对好上手的原因之一。
4. 租赁订单状态机:从下单到还车的完整流转设计
如果你只记住了这篇文章的一个知识点,我建议就是这一节。订单状态机是整个系统最核心的业务逻辑,也是面试/答辩时最能体现设计能力的地方。
4.1 六个状态的流转路径
我把租赁订单的生命周期定义为六个状态,每个状态之间的转换都有明确的触发条件:
| 状态 | 触发条件 | 后续可达状态 |
|---|---|---|
| 0-待支付 | 用户提交订单,锁定车辆档期 | 1-待取车、5-已取消 |
| 1-待取车 | 支付完成,等待用户到店取车 | 2-使用中、5-已取消(超时未取车) |
| 2-使用中 | 管理员确认取车,车辆状态变为已租 | 3-待归还 |
| 3-待归还 | 用户发起还车申请或到达还车日期 | 4-已完成、2-使用中(延长租期) |
| 4-已完成 | 管理员确认还车,结算费用并退押金 | 终态 |
| 5-已取消 | 用户取消或超时未支付 | 终态 |
实际开发中,状态流转的校验我放在一个统一的方法里,避免散落到各个service方法中:
private void validateTransition(Integer currentStatus, Integer targetStatus, String orderNo) { Map<Integer, Set<Integer>> stateMap = new HashMap<>(); stateMap.put(0, new HashSet<>(Arrays.asList(1, 5))); stateMap.put(1, new HashSet<>(Arrays.asList(2, 5))); stateMap.put(2, new HashSet<>(Arrays.asList(3))); stateMap.put(3, new HashSet<>(Arrays.asList(4, 2))); Set<Integer> allowedTargets = stateMap.get(currentStatus); if (allowedTargets == null || !allowedTargets.contains(targetStatus)) { throw new BusinessException("订单状态不允许从" + currentStatus + "流转到" + targetStatus); } }这段代码看着简单,但它保证了任何一个调用方都不可能把"待支付"订单直接改成"已完成",把非法状态流转的bug消灭在源头。
4.2 租金、押金、保险费的拆分计算
房车租金的构成比普通租车多几项,我的计算逻辑是这样的:
public RentFeeDetail calculateFee(CalculateFeeRequest req) { Vehicle vehicle = vehicleMapper.selectById(req.getVehicleId()); // 1. 租期天数计算:取车日到还车日,头尾都算,至少1天 long dayDiff = (req.getEndDate().getTime() - req.getStartDate().getTime()) / (24 * 60 * 60 * 1000L); int totalDays = (int) dayDiff + 1; if (totalDays < 1) { throw new BusinessException("租期至少为1天"); } // 2. 租期跨节假日时,按节假日调价系数计算 // dailyEffectivePrice = vehicle.dailyPrice * holidayFactor BigDecimal dailyEffectivePrice = getEffectiveDailyPrice(vehicle, req.getStartDate(), req.getEndDate()); BigDecimal rentalFee = dailyEffectivePrice.multiply(BigDecimal.valueOf(totalDays)); // 3. 保险费:基础险按天收固定费用,全险按租金的8%但设上限 BigDecimal insuranceFee; if (req.getInsuranceType() == 0) { insuranceFee = new BigDecimal("50").multiply(BigDecimal.valueOf(totalDays)); } else { insuranceFee = rentalFee.multiply(new BigDecimal("0.08")) .min(new BigDecimal("1000")); } BigDecimal totalAmount = rentalFee.add(insuranceFee).add(vehicle.getDepositPrice()); // ... }这里提醒一个新手容易犯的错误:金额计算一律用BigDecimal,千万别用double。double的浮点精度问题在涉及金额的系统中是绝对不能踩的坑,0.1+0.2不等于0.3这种问题在支付场景里会直接导致账单不平。
押金的处理方式也值得说明:押金不是"先付给平台"这么简单。我做的是下单时支付租金+保险,押金在取车时由门店线下收取并录入系统押金单号,还车确认无违章无损坏后退还。这样设计贴近真实业务,也避免了开发期押金退款流程的复杂度。
4.3 并发预订与库存扣减的防护
这是整个系统技术上最难的一个点,也是认真做过的同学和糊弄的同学差距最大的地方。想象这个场景:一辆热门房车在国庆档期只剩下最后一天的库存,两个用户同时点了下单,如果代码里只做了"先查有没有库存,再插入订单",这两个请求都会查到"有库存",然后各自成功下单——超卖了。
解决思路是用数据库行锁+Redis分布式锁双层防护:
@Transactional(rollbackFor = Exception.class) public Long createRentalOrder(CreateRentalOrderRequest req) { // 第一步:尝试获取Redis锁,防止同一个车辆在极短时间内被重复提交 String lockKey = "vehicle:rentLock:" + req.getVehicleId(); String lockValue = UUID.randomUUID().toString(); boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, lockValue, 30, TimeUnit.SECONDS); if (!locked) { throw new BusinessException("操作过于频繁,请稍后重试"); } try { // 第二步:数据库行锁查询,确认车辆档期未被占 Vehicle vehicle = vehicleMapper.selectByIdForUpdate(req.getVehicleId()); if (vehicle == null || vehicle.getRentStatus() != 0) { throw new BusinessException("该车辆当前不可租"); } // 检查档期冲突(查租赁订单表里该车是否存在时间重叠的订单) int conflictCount = rentalOrderMapper.countOverlappingOrders( req.getVehicleId(), req.getStartDate(), req.getEndDate(), null); if (conflictCount > 0) { throw new BusinessException("该车辆在所选租期内已被预订"); } // 第三步:插入订单,更新车辆状态 // ... return orderId; } finally { // 释放Redis锁,注意只有持有锁的线程才能释放 if (lockValue.equals(redisTemplate.opsForValue().get(lockKey))) { redisTemplate.delete(lockKey); } } }对应的Mapper方法:
<select id="countOverlappingOrders" resultType="int"> SELECT COUNT(*) FROM t_rental_order WHERE vehicle_id = #{vehicleId} AND status NOT IN (5) <!-- 排除已取消订单 --> AND (#{startDate} BETWEEN start_date AND end_date OR #{endDate} BETWEEN start_date AND end_date OR start_date BETWEEN #{startDate} AND #{endDate}) </select>这个查询里的区间重叠判断要理解:两个租期不重叠的条件是"我的结束日 < 你的开始日"或"我的开始日 > 你的结束日",取反就是重叠。直接记三个BETWEEN条件的OR即可,但要注意边界值——如果还车日是当天中午12点,取车日是当天下午2点,严格意义上不算冲突,需要在代码里对起止日期做精确到小时的判断,而不是只按天判断。
5. 销售模块与车辆状态联动:一条数据两种业务
销售模块的设计重点不是支付功能,而是车辆从租赁库存池转移到销售库存池时,状态怎么平滑切换。
5.1 销售订单的业务流程
整个销售链路我分为几个阶段:
- 客户选车:用户浏览车辆时可以看到"可售"标识和销售价格,点下"购买意向"后生成一条销售意向单。
- 支付定金:在线支付意向金(设为销售价的10%,上限两万)。支付成功后,车辆销售状态变为"1-在售",但这只是标记,车辆还留在租赁池里。
- 签约交付:线下签约后,管理员在后台录入合同编号,确认交车。这一步才把车辆状态从"在售"改为"已售",租赁状态强制置为"停用",整个车辆从租赁池中移除。
- 尾款结清:缴清尾款后,销售订单的支付状态改为"已付全款"。
有一点必须想清楚:支付定金不等于车辆立刻停止出租。如果客户付了定金但还没交车,车辆继续出租可以帮运营商创造收益,只要在交付日前确保车辆能按时收回即可。所以设计时定金状态不改变车辆出租状态,而是通过一个"交付截止日期"字段来提醒管理员。
5.2 车辆状态检查的统一入口
车辆状态联动逻辑散落在多个service里很容易出错,我选择把它们收口到一个方法里:
public void checkVehicleOperable(Long vehicleId, String scene) { Vehicle vehicle = vehicleMapper.selectById(vehicleId); if (vehicle == null) { throw new BusinessException("车辆不存在"); } switch (scene) { case "RENT": if (vehicle.getRentStatus() != 0 || vehicle.getVehicleStatus() != 0) { throw new BusinessException("该车辆当前不可租赁"); } break; case "SALE": if (vehicle.getSaleStatus() != 1 || vehicle.getVehicleStatus() != 0) { throw new BusinessException("该车辆当前不可销售"); } break; case "MAINTAIN": if (vehicle.getRentStatus() == 1) { throw new BusinessException("车辆出租中,无法转入维保"); } break; default: break; } }每个业务入口(租赁下单、销售下单、申请维保)都先调用这个检查方法,保证了任何一条业务线都不会拿到状态不对的车辆。实际运营中,最常见的一个问题场景是:管理员把一辆正在租赁中的车标记为"已售",导致用户还车时找不到车辆。这个统一入口直接从代码层面堵死了这个操作。
6. 接口设计与前端对接要点
前端我用的是Vue 2 + Element UI,后台管理系统和用户端小程序风格的H5页面复用同一套后端接口。接口设计的规范程度直接决定了前端开发效率,这节说几个关键点。
6.1 RESTful接口规范与命名
我规定了几条接口设计规则:
- 资源用复数名词,路径结尾不加动词:
/api/vehicles、/api/rental-orders/{id} - 查询用GET、新增用POST、更新用PUT、删除用DELETE
- 列表接口统一接收
pageNum和pageSize,返回分页结构 - 所有写操作必须带操作人信息,从JWT中解析,不从前端参数接收
一个容易踩的坑是:前端传日期的时候,如果不统一格式,后端解析就会出问题。我在全局配置里指定了时间格式:
spring.jackson.date-format=yyyy-MM-dd HH:mm:ss spring.jackson.time-zone=GMT+8接口层面,日期参数用@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")标注,这样前端无论传什么格式,后端都能正确解析。
6.2 三个核心接口的实现细节
车辆列表接口:需要考虑筛选条件组合。我的Mapper用了MyBatis-Plus的LambdaQueryWrapper动态拼接条件:
public Page<VehicleVO> listVehicles(VehicleQuery query) { LambdaQueryWrapper<Vehicle> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(query.getVehicleType() != null, Vehicle::getVehicleType, query.getVehicleType()) .eq(query.getFuelType() != null, Vehicle::getFuelType, query.getFuelType()) .between(query.getMinPrice() != null && query.getMaxPrice() != null, Vehicle::getDailyPrice, query.getMinPrice(), query.getMaxPrice()) .eq(query.getOnlyRentable() != null && query.getOnlyRentable(), Vehicle::getRentStatus, 0) .eq(query.getOnlySaleable() != null && query.getOnlySaleable(), Vehicle::getSaleStatus, 1) .orderByDesc(Vehicle::getUpdateTime); return vehicleMapper.selectPage(new Page<>(query.getPageNum(), query.getPageSize()), wrapper); }核心技巧是condition参数的用法:条件为false时,这个查询条件自动跳过。前端没传的参数不会拼进SQL,传了才生效。
租赁订单创建接口:必须做成一个完整的事务单元,内部包含:校验车辆状态→校验档期冲突→计算费用→生成订单号→扣车辆→返回支付参数。事务回滚用@Transactional(rollbackFor = Exception.class),注意要指定rollbackFor,否则默认只回滚RuntimeException,遇到受检异常会把数据留在半成品状态。
订单号生成:不依赖数据库自增,我用的是"日期+随机数"方案:
String orderNo = "R" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")) + String.format("%04d", new Random().nextInt(10000));单机部署下这个方案够用。订单号要唯一,所以加了个数据库唯一索引兜底,万一重复就重建。
6.3 管理员端的数据看板
后台首页我放了一个简易运营看板,统计今日订单数、本月交易额、车辆利用率TOP5。当时是为了答辩演示效果,但做完发现这个模块对运营方确实有用。
车辆利用率的SQL很有意思:
SELECT v.id, v.vehicle_name, SUM(CASE WHEN o.status NOT IN (0, 5) AND o.start_date <= CURDATE() AND o.end_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) THEN 1 ELSE 0 END) / 30 AS usage_rate FROM t_vehicle v LEFT JOIN t_rental_order o ON v.id = o.vehicle_id GROUP BY v.id, v.vehicle_name ORDER BY usage_rate DESC LIMIT 5;这个统计虽然简化了(没有按天精确计算占用天数),但作为运营参考足够直观。
7. 避坑记录与性能优化实测
这部分是我实际开发中踩过的坑,每一个都花了不少时间排查,写出来给大家省点时间。
7.1 租期跨天的日期计算
刚开始实现租金计算时,我用的是"结束日期减开始日期"直接得到天数。结果测试发现,用户选了8月1日到8月3日的租期,算法算出的是2天,实际上应该算3天——因为1号取车、3号还车,中间包含了3个日历日。
修正方案是取车日当天算一天,+1处理。同时还要处理用户选"今天取车、今天还车"的边界情况,以及跨月份的日期。这些都测过之后,我又遇到了夏令时/时区的坑——还好国内统一北京时间,GMT+8配置好就没问题了。
7.2 超卖问题的实测记录
开发阶段我在本地用100个并发线程同时下单同一辆车的同一天,跑了三轮测试。第一轮没有加锁,三个线程全部下单成功,直接暴露了超卖问题。第二轮加了Redis锁,生产环境单机Redis下100个并发只有1个成功,其余全部快速失败,符合预期。
但Redis锁有个坑要注意:如果Redis服务重启导致锁丢失,防护就失效了。所以我在数据库层面又加了一层FOR UPDATE行锁作为兜底,所谓"双层防护"就是这个意思。纯数据库锁在单体项目里其实也够用,但对高并发场景,Redis锁先挡住大部分请求,数据库锁作为最终防线,两者配合更稳。
7.3 事务失效的经典场景
有段时间出现了一个诡异bug:订单创建后车辆状态没有变为"已租",排查半天发现是方法调用的方式不对。
// 错误写法:同类内部调用,事务注解不生效 public void createOrder(...) { this.insertOrderAndUpdateVehicle(...); } @Transactional public void insertOrderAndUpdateVehicle(...) { // 事务操作 }Spring的事务是通过AOP代理实现的,同类内部方法调用不会走代理,所以@Transactional注解完全不生效。解决方案是事务方法放到另一个Service类里,通过Spring注入的Bean去调用,或者把事务控制在Controller层调用的Service方法上。
这个坑太经典了,面试的时候十有八九会问到,我建议找机会亲手复现一次,印象会非常深刻。
7.4 Redis缓存车辆列表的性能优化
列表接口是访问量最大的接口,车辆信息变化频率又不高(主要是状态字段要实时),我的方案是把列表数据缓存到Redis 5分钟,车辆状态变化时主动清除相关缓存:
public Page<VehicleVO> listVehicles(VehicleQuery query) { String cacheKey = "vehicle:list:" + query.hashCode(); Page<VehicleVO> cached = redisTemplate.opsForValue().get(cacheKey); if (cached != null) { return cached; } Page<VehicleVO> result = doQuery(query); redisTemplate.opsForValue().set(cacheKey, result, 5, TimeUnit.MINUTES); return result; }简单粗暴,但实测列表接口响应从平均180ms降到了20ms以内。注意缓存key里要包含查询条件的hash,否则不同筛选条件会拿到同一份数据。
8. 部署运行与演示数据准备
项目做完了,部署和演示数据这两件事如果在答辩/交付前没准备好,前面所有工作都会大打折扣。
8.1 本地启动与生产部署
本地开发环境需要准备:JDK 8以上、MySQL 8.0、Redis、Maven 3.6+。启动流程是:先执行springboot_rv_platform.sql初始化数据库,再配置application.yml里的数据库连接和Redis地址,然后启动SpringBoot主类,最后启动前端工程执行npm install && npm run serve。
生产环境部署我推荐打包成jar后用systemd管理:
# 打包(跳过测试,测试单独跑) mvn clean package -DskipTests # 启动 java -jar rv-platform.jar --spring.profiles.active=prod注意生产环境要配置server.address和端口,数据库账号密码用环境变量注入,不要写死在配置文件里。日志输出到文件并配置滚动策略,避免磁盘被日志撑爆。
8.2 演示数据:让系统看起来"活"起来
空数据库演示效果很差,我总结了一个演示数据准备的顺序:
- 用户数据:准备6-8个测试用户,手机号用13x开头的规范号码,密码统一123456方便演示。
- 车辆数据:至少10辆车,覆盖不同的类型(自行式C型、B型、拖挂式)、品牌、价格区间。图片用真实感较强的房车图,描述里写上配置亮点。
- 订单数据:造3-4个不同状态的历史订单,特别是已完成的订单要有对应的评价记录。
- 档期数据:热门车型在近一周留一些"已被预订"的日期,这样演示时展示档期冲突的效果最直观。
演示时最有说服力的操作流程是:登录用户端浏览车辆→选择一辆车选定日期下单→到管理端看到新订单→确认取车→模拟还车结算→用户端评价。这条链路走通,整个系统的业务闭环就展示得明明白白。
最后再分享一个我实际体会很深的小经验:开发这种带时间维度的业务系统,测试用例一定要覆盖边界时间。比如跨月租期、跨年租期、当天取当天还、还车时间在取车时间之前这些异常场景。把测试数据准备好,很多隐藏bug在开发阶段就能暴露,而不是等到演示现场才发现计费逻辑算错了。房车租赁和销售平台的业务逻辑并不算复杂,难的是把每个细节做扎实,状态别乱、金额别错、并发别超卖——这三个点做好,这个系统就立住了。