简介:一份面向计算机专业学生的校园电动车租赁系统毕业设计论文文档,以Spring Boot与Vue.js前后端分离方案为主线,系统阐述基于MySQL的电动车租赁平台的设计与实现。文档包含摘要、关键词、目录、绪论、需求分析、系统设计、功能实现等完整章节,覆盖用户注册登录、电动车租赁与还车、扫码操作、报障反馈、管理员信息管理、数据统计及在线客服等核心模块,可作为同类课题选题、技术选型和论文架构的直接参考。整个资源共1个docx文件,压缩包大小6.71MB,内容集中、结构清晰,便于按章节查阅和二次修改。目前已有72人学习下载,适合毕业设计开题、系统开发与论文撰写阶段参考;借助该文档可快速梳理业务场景与功能模块,并借鉴其论述方式和目录组织,提高论文写作效率。
1. 校园电动车的租赁需求,最终落在一张订单表上
校园面积越扩越大,从宿舍楼到实验楼动辄一公里以上,高峰期共享单车又常常无车可扫,电动车租赁就成了校园出行的刚需。这个基于 Spring Boot 的校园电动车租赁系统,后端用 Spring Boot 暴露 REST 接口,前端用 Vue.js 渲染页面,MySQL 存车辆台账与订单记录,定位就是一套前后端分离的典型管理类项目,面试里常被问到的 springboot 项目场景也和它高度重合。它没有复杂算法,真正的难点是把租车、还车、计费、报障这条状态链路做对。本文按“数据表 → 扫码租车 → 统计调度 → 部署排查”的顺序,把一台电动车从上线到归还的全过程拆开讲。
2. Spring Boot + MySQL 数据建模:车辆台账与租赁订单状态
刚拿到这类需求,大多数人先写用户管理,再写车辆管理,最后才补租赁记录,结果发现租赁记录里不知道该存哪些字段。我在这个项目里建议反过来,先定义租赁订单表,因为用户、车辆、报障都是围绕订单展开的。订单表设计定了,其他表都只是它的关联维度。
2.1 核心表结构:从电动车台账到租赁订单
2.1.1 电动车台账表(ebike)
电动车表的字段并不需要很多,关键是位置、状态与车辆编号具有唯一性。下面是去掉冗余字段后的建表 SQL。
CREATE TABLE `ebike` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `bike_no` varchar(32) NOT NULL COMMENT '车辆编号', `type_name` varchar(64) DEFAULT 'standard' COMMENT '车型:standard/comfort/large', `lat` decimal(10,6) DEFAULT NULL COMMENT '纬度', `lng` decimal(10,6) DEFAULT NULL COMMENT '经度', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0可租 1租赁中 2维修中 3下线', `qr_code` varchar(128) DEFAULT NULL COMMENT '车身二维码内容', `last_maintain_time` datetime DEFAULT NULL COMMENT '最近维保时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_bike_no` (`bike_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;status 用 tinyint 而不是字符串,是为了 Java 侧能用枚举映射、前端再翻译成中文标签,也方便在 SQL 里直接WHERE status = 0做筛选。qr_code 存二维码原始内容,通常是一个带车辆 ID 的 URL,扫码时前端解析出 bikeId 再调后端,不在二维码里塞多余信息。last_maintain_time 是给管理员调度用的,连续 30 天没有维保记录的车要在管理列表里标红。
2.1.2 租赁订单表(rental_order)
订单表是租赁状态机的载体。
CREATE TABLE `rental_order` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '订单号', `user_id` bigint NOT NULL COMMENT '用户ID', `ebike_id` bigint NOT NULL COMMENT '车辆ID', `start_time` datetime NOT NULL COMMENT '租车时间', `end_time` datetime DEFAULT NULL COMMENT '还车时间', `amount` decimal(10,2) DEFAULT '0.00' COMMENT '费用', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0租赁中 1已归还 2异常关闭', `fault_desc` varchar(255) DEFAULT NULL COMMENT '还车时上报的故障', PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`), KEY `idx_ebike_id` (`ebike_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;order_no 不用自增主键直接对外暴露,因为订单号会出现在二维码回跳地址和客服记录里,用时间戳加随机数生成更安全。end_time 允许为空,空值代表这次租赁还没结束,后续统计活跃订单时WHERE end_time IS NULL就能捞出来。amount 在还车时才计算,租车时只记录 start_time。
2.2 订单状态机的流转规则
订单状态是全局最容易出 bug 的地方。我一般会把状态和流转规则列成一张表,开发时对照着写:
| 状态 | 数值 | 允许流入状态 | 触发场景 |
|---|---|---|---|
| 租赁中 | 0 | 租车成功 | 用户扫码确认租车 |
| 已归还 | 1 | 租赁中 | 用户正常还车 |
| 异常关闭 | 2 | 租赁中 | 上报故障或超时未还 |
租车时,ebike.status和rental_order.status要在同一个事务里更新;还车时反过来恢复。如果两个表分开写库并且没加@Transactional,会出现订单已归还、车辆仍是租赁中的脏数据。这一点在 springboot 面试题里经常以“订单超时未还怎么处理”的方式出现,提前把状态机定义清楚,回答时就能直接说该把哪个字段从 0 改成 2。
2.3 MyBatis-Plus 实体映射与条件查询
实体用 MyBatis-Plus 注解映射到表。
@Data @TableName("ebike") public class Ebike { @TableId(type = IdType.AUTO) private Long id; private String bikeNo; private String typeName; private BigDecimal lat; private BigDecimal lng; private Integer status; private String qrCode; private LocalDateTime lastMaintainTime; }查询用户附近可租车辆时,用 LambdaQueryWrapper 组装条件比手写 XML 更直观:
List<Ebike> list = ebikeMapper.selectList( new LambdaQueryWrapper<Ebike>() .eq(Ebike::getStatus, 0) .between(Ebike::getLat, lat - 0.01, lat + 0.01) .between(Ebike::getLng, lng - 0.01, lng + 0.01) .last("limit 20"));eq 过滤车辆状态为可租;between 以用户当前经纬度为中心圈出一公里左右的范围;last 拼接 limit 防止接口一次返回过多空闲车。这里没有用 ge、le 分别写四行,between 两个字段就能表达同样的语义。校园场景下车辆数量有限,这种轻量查询完全够用;如果以后扩展到多校区再考虑引入 Redis GEO 也不迟。
3. 扫码租车与还车API实现:二维码、鉴权与状态校验
用户在前台看到车辆位置只是第一步,真正决定项目质量的是扫码租车和还车这两个动作。二维码里存什么、登录态怎么传递、租车接口如何防止重复提交、还车时如何保证费用与状态一致,下面按这四个问题展开。
3.1 二维码内容与登录态传递
二维码内容不要存 JSON 字符串,扫码工具对特殊字符兼容性差。常见做法是存一个短链接,例如https://api.campus-ebike.com/rent?bikeId=123。用户扫码后,前端先判断本地有没有 JWT token:没有就跳登录页,有则进入确认页。后端用一个拦截器统一解析请求头里的 Authorization 并写入当前用户上下文,租车和还车接口就不必重复写解析逻辑。
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (token == null || !jwtUtil.validate(token)) { response.setStatus(401); return false; } UserContext.set(jwtUtil.getUserId(token)); return true; } }token 过期返回 401,前端收到后自动跳回登录页重新登录。这个拦截器只作用于需要登录的接口,公告资讯这类公开接口单独放行。bikeId 放在 URL 里本身没有问题,后端接口会校验车辆存在性和状态,不依赖前端传值,所以即使有人拿着 URL 直接调接口也绕不过业务校验。
3.2 租车接口的幂等与并发控制
连续点击“立即租车”两次,不能生成两笔租赁中的订单。租车接口的防御手段不是靠前端按钮置灰,而是后端在事务里做状态校验。
@Transactional(rollbackFor = Exception.class) public RentResult rent(Long userId, Long ebikeId) { Ebike ebike = ebikeMapper.selectById(ebikeId); if (ebike == null || ebike.getStatus() != 0) { throw new BizException(400, "车辆不可租"); } // 防重:同一辆车只允许存在一笔进行中的订单 Long active = rentalOrderMapper.selectCount( new LambdaQueryWrapper<RentalOrder>() .eq(RentalOrder::getEbikeId, ebikeId) .eq(RentalOrder::getStatus, 0)); if (active > 0) { throw new BizException(400, "车辆正在租赁中"); } Ebike update = new Ebike(); update.setId(ebikeId); update.setStatus(1); ebikeMapper.updateById(update); RentalOrder order = new RentalOrder(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setEbikeId(ebikeId); order.setStartTime(LocalDateTime.now()); order.setStatus(0); rentalOrderMapper.insert(order); return new RentResult(order.getOrderNo()); }这段代码有三道防线:第一,查询车辆状态,状态不是 0 直接拒绝;第二,统计该车状态为租赁中的订单数,大于 0 说明这台车已经被租走;第三,先更新车辆状态再插入订单,全程在同一个事务里,任何一个步骤抛异常都会回滚。generateOrderNo() 我习惯用yyyyMMddHHmmss加 4 位随机数拼成字符串,并发量不大的场景基本不会碰撞。
3.3 还车接口:费用计算与状态回写
还车时有两条分支:正常归还,或者上报故障归还。上报故障的车辆要直接进入维修流程,不能按普通还车把车辆状态恢复成可租。
@Transactional(rollbackFor = Exception.class) public void returnBike(Long userId, Long orderId, ReturnRequest req) { RentalOrder order = rentalOrderMapper.selectById(orderId); if (order == null || !order.getUserId().equals(userId) || order.getStatus() != 0) { throw new BizException(400, "订单不存在或已归还"); } LocalDateTime now = LocalDateTime.now(); if (req.getFaultFlag()) { order.setStatus(2); order.setFaultDesc(req.getFaultDesc()); rentalOrderMapper.updateById(order); Ebike update = new Ebike(); update.setId(order.getEbikeId()); update.setStatus(2); ebikeMapper.updateById(update); return; } BigDecimal amount = calcAmount(order.getStartTime(), now); order.setEndTime(now); order.setAmount(amount); order.setStatus(1); rentalOrderMapper.updateById(order); Ebike update = new Ebike(); update.setId(order.getEbikeId()); update.setStatus(0); update.setLat(req.getLat()); update.setLng(req.getLng()); ebikeMapper.updateById(update); }细读这段代码:正常还车时会把前端上报的经纬度回写到车辆表,管理员端看到的车辆位置就是最近一次归还地,而不是车辆出厂坐标。费用计算按分钟向上取整,避免出现“骑了 1 分 20 秒收两分钟钱”的投诉。故障还车时订单状态置为 2,车辆状态置为 2(维修中),同时记录故障描述,供管理员后续派单处理。
| 接口 | 请求方式 | 核心入参 | 关键校验 |
|---|---|---|---|
| /rent | POST | userId, ebikeId | 车辆状态为 0 且无进行中订单 |
| /return | POST | userId, orderId, faultFlag | 订单存在且属于当前用户 |
提示:单校园规模下不要为了并发控制而引入 Redis 分布式锁,数据库自带的行锁配合状态校验已经够了。锁超时反而会带来误判,把正常租车请求当成重复请求拒绝掉。
4. 管理端数据统计与报障处理:调度决策的数据支撑
管理员端的价值不在增删改查,而在统计。没有统计,管理员不知道哪几辆车天天满负荷、哪几辆停在角落里吃灰、报障集中发生在哪个区域。这一章把统计 SQL 和报障闭环分开讲清楚。
4.1 车辆使用率统计:一条 SQL 看懂全局
统计每辆车的租赁次数和累计骑行时长,用 group by 加两个聚合函数就够了。
SELECT ebike_id, COUNT(*) AS rent_count, SUM(TIMESTAMPDIFF(MINUTE, start_time, COALESCE(end_time, NOW()))) AS total_minutes FROM rental_order WHERE start_time >= DATE_FORMAT(CURDATE(), '%Y-%m-01') GROUP BY ebike_id ORDER BY rent_count DESC;重点在COALESCE(end_time, NOW()):订单没还时 end_time 为空,统计时长就用当前时间兜底。TIMESTAMPDIFF 返回分钟数,前端展示时再除以 60 转成小时。rent_count 是最直接的调度指标,连续两周排在后 20% 的车辆应该被人为挪到人流更密集的停车点。
4.2 闲置车辆与异常订单定位
使用率统计面向活跃车辆,闲置车辆还得换一个查询思路。
SELECT e.id, e.bike_no, MAX(o.start_time) AS last_rent_time, DATEDIFF(NOW(), MAX(o.start_time)) AS idle_days FROM ebike e LEFT JOIN rental_order o ON e.id = o.ebike_id GROUP BY e.id, e.bike_no HAVING idle_days > 7 OR idle_days IS NULL;LEFT JOIN 保证从未被租过的新车也能出现在结果里,HAVING 里的idle_days IS NULL专门捞那些上线一周多还没有订单的车。管理员把这份列表导出后,结合线下停车点的实际客流决定是换位置还是下线维护。
统计数据不需要实时算。常见做法是凌晨用定时任务把前一天的数据汇总到结果表,白天前端只读结果表,避免聚合查询拖慢在线订单库。springboot 项目里加一个@Scheduled方法即可:
@Component public class StatTask { @Scheduled(cron = "0 0 2 * * ?") public void collectDailyStat() { // 按车辆、按天写入使用率汇总表,保留最近 30 天 statService.collectDaily(); } }cron 表达式0 0 2 * * ?表示每天凌晨两点执行,这个时间订单量最低,即使任务跑慢也不会影响正常租车。任务调度的核心是错峰执行,不要在白天业务高峰期跑大批量聚合统计。
注意:定时任务里做的写操作要带重试,统计任务偶尔失败一次可以接受,但不能连续失败还无人感知。建议任务结束时的成功、失败状态落到一张任务日志表,管理员第二天打开后台先看日志表有没有红条。
4.3 报障反馈的闭环设计
报障不是“用户提交、管理员看一眼”就结束,而要形成可追踪的闭环。用户提交时关联订单号和车辆编号,管理员处理时更新处理备注,用户端实时看到当前进度。
| 状态 | 含义 | 后续动作 |
|---|---|---|
| 待处理 | 用户刚提交 | 系统通知管理员指派人员 |
| 处理中 | 维修人员已接单 | 更新处理备注 |
| 已解决 | 车辆恢复正常 | 车辆状态从维修中改为可租 |
报障反馈表的结构可以这样建:
CREATE TABLE `fault_report` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_id` bigint DEFAULT NULL COMMENT '关联订单', `ebike_id` bigint NOT NULL COMMENT '车辆ID', `user_id` bigint NOT NULL COMMENT '上报人', `fault_type` varchar(32) COMMENT '刹车/电池/轮胎/其他', `description` varchar(255), `status` tinyint DEFAULT '0' COMMENT '0待处理 1处理中 2已解决', `handle_remark` varchar(255) COMMENT '处理备注', `create_time` datetime, `handle_time` datetime, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;管理员把 status 改成已解决时,要同步判断车辆状态。如果 fault_type 是“电池”而车辆还停在维修中,说明问题并未真正处理完,不能直接恢复可租。这个判断放在 Service 层而不是依赖管理员手动操作,能少踩很多次误操作。
5. 部署联调与排错:application.yml 配置与常见坑
开发环境跑通不代表能上线,实际部署里问题往往集中在配置文件、跨域和时区这几处。这一章给出关键配置和排查顺序,照着做能省去大半联调时间。
5.1 application.yml 里的三个关键配置
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/campus_ebike?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0url 里必须写 serverTimezone,否则连接 MySQL 8.x 会直接报时区相关异常;characterEncoding=utf8保证中文不会乱码。log-impl 打开后控制台会打印每条 SQL,联调时看日志比盲猜快得多。logic-delete 是 MyBatis-Plus 的逻辑删除配置,表里有 deleted 字段时删除操作自动变成 update,防止车辆台账被物理删除。
5.2 前后端分离联调:跨域与请求路径
Vue 开发服务器默认跑在 5173,后端是 8080,浏览器默认拦截跨域请求。本地联调建议直接加一个 CORS 配置类。
@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOrigin("http://localhost:5173"); config.addAllowedMethod("*"); config.addAllowedHeader("*"); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }addAllowedOrigin 只放行本地开发服务器,不要图省事写成*,否则部署后任何网页都能回调接口。如果后续要对接小程序,把小程序合法域名加进白名单即可,不需要改业务代码。
5.3 排查顺序:先看 SQL 再看状态
联调时遇到“租车失败”不要急着改业务代码,按三步检查:先看控制台有没有打印出租车接口的 update SQL;再查数据库里这辆车的 status 是否已经是 1,如果是 1 说明上一笔订单没有正常释放;最后看异常消息里有没有 BizException 抛出的中文提示。绝大多数问题都出在订单未释放或车辆状态被前一次测试污染,而不是接口逻辑写错。
扫码页面在微信里打开时还容易出现一个坑:二维码内容里存的是内网 IP,外网用户根本访问不到。处理办法是把二维码内容做成可配置项,上线后替换成公网域名,不要把 IP 写死在车辆初始化数据里。
本文还有配套的精品资源,点击获取