SpringBoot+Vue电动车租赁系统:状态机、并发与计费实战
2026/9/17 15:36:43 网站建设 项目流程

简介:面向计算机专业本科生毕业设计与课程项目的电动车租赁管理系统论文资料,围绕基于 SpringBoot 与 Vue 的前后端分离架构展开,适合正在做租赁平台、共享出行或车辆管理类课题的学生与技术爱好者。论文从行业背景与需求分析切入,梳理了 Spring、Spring MVC、Java、MySQL 等技术选型,并给出模块设计、数据库设计与实现细节,功能覆盖车辆信息管理、租赁管理、还车信息管理与评价管理等核心业务,最后一章还包含系统测试与优化措施,便于读者理解企业级 Web 应用的完整开发流程与论文写作结构。资源包内为 1 个 docx 文件,压缩包约 2.35MB,内容以完整毕业论文文档为主,下载与查阅较为轻便。目前已有 53 人学习或下载,可作为同类选题的参考模板,帮助读者快速定位需求分析、功能模块划分、数据库表设计与测试章节的写法,也能为后续系统开发与答辩准备提供思路。

1. 从一辆车被扫码骑走说起:电动车租赁系统真正要解决什么

电动车租赁管理系统的难点从来不在页面上摆了几张卡片,而在一辆车的状态要在「可租、已锁定、使用中、待归还、维修中」之间正确流转,同时押金、订单、计费三者必须对得上账。用户扫码那一秒,后端要做的是原子性地把车从可租改成已锁定并生成订单;用户还车那一刻,系统要把时长换算成金额、把押金差额退回、把车放回可租池。任何一步没兜住,就会出现同一辆车被两个人租走、或者还车后金额对不上这种答辩现场最尴尬的问题。

这个题目适合做前后端分离的毕设:SpringBoot 承担状态机、事务和计费,Vue 承担车辆检索、下单、订单列表和后台管理。门槛不高,但要把并发、金额精度、状态回滚讲清楚,工作量足够撑起一篇论文。下面按后端建模、前端组织、核心链路联调、答辩前工程技巧的顺序,把一套能跑起来、能讲得通的方案拆开。

2. SpringBoot 后端的域建模与接口分层

2.1 电动车租赁的四张主表与状态字段设计

先把表定下来,后面所有代码都围着它转。租赁业务的核心实体只有四个:用户、车辆、订单、押金流水。不要把「计费规则」硬编码进订单表,规则单独一张表,答辩时改价不用改代码。

表名关键字段设计要点
vehicleid、no、model、status、battery、station_id、price_rule_idstatus 用 tinyint,禁止用字符串,便于状态机判断
rent_orderid、order_no、user_id、vehicle_id、start_time、end_time、amount、statusorder_no 唯一索引,防止重复下单
deposit_recordid、user_id、order_id、type、amount、create_timetype 区分冻结、扣款、退还,只增不改
price_ruleid、free_minutes、base_fee、step_minutes、step_fee、day_cap规则可配置,避免写死在 Service

vehicle.status建议约定:0 可租、1 已锁定、2 使用中、3 待归还、4 维修、5 停用。rent_order.status建议:0 待支付、1 进行中、2 已完成、3 已取消、4 异常。状态字段一定要在数据库注释里写清楚,论文里也要有一张状态迁移表,这是评审最容易问的点。

2.2 SpringBoot 自动装配与多环境配置的落地写法

毕设阶段最容易被追问的就是「你的配置是怎么生效的」。SpringBoot 的自动装配靠spring.factories(2.7 之前)或AutoConfiguration.imports(2.7 之后)加载自动配置类,再配合@ConditionalOnMissingBean让自定义 Bean 覆盖默认实现。日常开发不需要改自动装配,但可以把多环境配置写规范。

# application.yml —— 公共配置 spring: profiles: active: dev # 切换 dev / prod,答辩演示用 dev jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 default-property-inclusion: non_null # 空字段不序列化,减小响应体 mybatis-plus: configuration: map-underscore-to-camel-case: true # 下划线字段自动映射驼峰 global-config: db-config: logic-delete-field: deleted # 逻辑删除字段 logic-delete-value: 1 logic-not-delete-value: 0
# application-dev.yml —— 开发环境 server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/ebike_rent?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 database: 0 timeout: 3000ms

逻辑说明:主配置只放与环境无关的公共项,连接信息全部下沉到application-dev.yml/application-prod.yml,通过spring.profiles.active切换。参数上,serverTimezone必须显式指定,否则 MySQL 8 与 JDBC 驱动时区不一致,订单时间会整体偏移 8 小时,这个问题在联调阶段极难排查。timeout是 Redis 连接超时,写 3000ms 而不是默认值,能让 Redis 未启动时快速失败而不是卡住线程池。

2.3 车辆状态机与订单创建的 Service 实现

订单创建是整套系统里最需要事务保护的一段。常见做法是把「查车状态」和「改车状态」放在同一个事务里,并用数据库行锁或版本号兜底。下面这段用 MyBatis-Plus 的条件更新做乐观控制,只有影响行数为 1 时才认为抢车成功。

@Service public class RentOrderServiceImpl implements RentOrderService { @Resource private VehicleMapper vehicleMapper; @Resource private RentOrderMapper orderMapper; @Override @Transactional(rollbackFor = Exception.class) // 任何异常都回滚,避免车锁住却没订单 public Long createOrder(Long userId, Long vehicleId) { // 条件更新:只有当前状态是「可租」才允许改成「已锁定」 LambdaUpdateWrapper<Vehicle> lock = new LambdaUpdateWrapper<Vehicle>() .eq(Vehicle::getId, vehicleId) .eq(Vehicle::getStatus, VehicleStatus.AVAILABLE.getCode()) .set(Vehicle::getStatus, VehicleStatus.LOCKED.getCode()); int rows = vehicleMapper.update(null, lock); if (rows != 1) { throw new BizException("车辆已被占用,请换一辆"); // 影响行数不为 1 说明被别人抢先 } RentOrder order = new RentOrder(); order.setOrderNo(IdUtil.getSnowflakeNextIdStr()); // 雪花号做业务单号,避免自增暴露量级 order.setUserId(userId); order.setVehicleId(vehicleId); order.setStartTime(LocalDateTime.now()); order.setStatus(OrderStatus.WAITING.getCode()); orderMapper.insert(order); return order.getId(); } }

这段代码里,eq(Vehicle::getStatus, AVAILABLE)是并发安全的关键:它把判断条件和更新语句合成一条 SQL,交给数据库保证原子性,比「先 select 再 if 再 update」靠谱得多。@TransactionalrollbackFor必须显式写成Exception.class,因为默认只回滚运行时异常,业务自定义的受检异常不会触发回滚,车会被永久锁死。IdUtil用的是 MyBatis-Plus 自带的雪花算法工具,参数上getSnowflakeNextIdStr()返回字符串,落到varchar字段里比bigint更省事。

2.4 分层选型理由与三个常见误用

Controller 只做参数校验和 VO 转换,Service 承载状态流转和事务,Mapper 只写 SQL。Controller 里塞业务判断,是毕设里最常被批的结构问题——答辩老师一问「如果换一个入口调用这段逻辑怎么办」就答不上来。

三个高频误用:把@Transactional加在 Controller 上(代理不生效或事务范围过大);在 Service 里用synchronized保证并发安全(多实例部署直接失效,应该用数据库条件更新或 Redis);把金额算成double(浮点误差会让 0.1 + 0.2 不等于 0.3,金额字段一律用decimal(10,2)BigDecimal)。

3. Vue 前端在租赁场景下的路由、状态与表单组织

3.1 Vue 3 + Vite 骨架与 axios 请求层封装

前端第一件事不是写页面,而是把请求层封死,否则后面每个页面都要处理一遍 token 和错误码。

// src/utils/request.js import axios from 'axios' import { useUserStore } from '@/stores/user' import router from '@/router' const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE, // 从 .env.development 读取,避免硬编码 IP timeout: 10000 // 超过 10 秒视为失败,防止页面一直转圈 }) service.interceptors.request.use(config => { const token = useUserStore().token if (token) config.headers.Authorization = `Bearer ${token}` return config }) service.interceptors.response.use( res => { const { code, data, msg } = res.data if (code === 200) return data if (code === 401) router.push('/login') // 登录态失效统一跳登录 return Promise.reject(new Error(msg)) }, err => Promise.reject(err) ) export default service

逻辑说明:请求拦截器统一注入 token,响应拦截器统一解包{code, data, msg}结构,业务代码里直接await getVehicleList()拿到的就是数据本身。参数上baseURL走环境变量,打包到服务器时只改.env.production,不用动源码;timeout设 10 秒是取舍——太长用户以为卡死,太短会把慢查询误判成失败。

3.2 Vue 路由守卫:把未登录用户挡在租赁页之外

租赁页、订单页、个人中心必须登录,后台管理页还要校验角色。用全局前置守卫一次搞定,比在每个页面里写onMounted判断清爽。

// src/router/index.js router.beforeEach((to, from, next) => { const userStore = useUserStore() if (to.meta.requiresAuth && !userStore.token) { next({ path: '/login', query: { redirect: to.fullPath } }) // 带上来源路径,登录后跳回 return } if (to.meta.roles && !to.meta.roles.includes(userStore.role)) { next('/403') // 角色不匹配,拒绝进入后台页 return } next() })

路由配置里用meta标记页面属性,比如{ path: '/order', meta: { requiresAuth: true, roles: ['USER'] } }redirect参数是为了登录后回到用户原本想去的页面,这个细节在答辩演示时很加分。注意next()只能调用一次,早返回的地方必须return

3.3 Vue Pinia 与 Vuex 的取舍:租赁会话状态放哪

新项目直接选 Pinia。它没有 mutation,actions里同步异步都能写,TypeScript 推导也比 Vuex 顺手,样板代码少一半。租赁系统里需要跨组件共享的状态只有三类:用户信息与 token、当前订单、车辆筛选条件。

// src/stores/order.js export const useOrderStore = defineStore('order', { state: () => ({ current: null, list: [] }), getters: { isRenting: state => state.current?.status === 1 // 有进行中订单就禁用再次下单按钮 }, actions: { async fetchCurrent() { this.current = await getCurrentOrder() } } })

车辆列表的筛选条件不要放 store,放路由 query 里,这样刷新页面条件还在,用户能直接分享链接。isRenting这类派生状态放 getter,页面里只读不改,避免多个组件各自维护一份布尔值导致按钮状态不一致。

3.4 车辆列表页与租车表单的校验字段对照

前后端校验必须对齐,否则前端放过、后端拒绝,用户看到的是莫名其妙的报错。

字段前端校验后端校验说明
手机号/^1[3-9]\d{9}$/@Pattern注解注册与实名共用
身份证号18 位正则 + 校验位@Pattern押金退还必需
租车时长1~30 天@Min/@Max超过 30 天走线下合同
车辆编号必填、去空格唯一索引兜底前端只能防手误,唯一性靠数据库

前端校验是体验,后端校验是底线。表格里的每一项都要在论文的「输入校验」小节出现,这是答辩老师确认你考虑过异常输入的直接证据。

4. 从扫码解锁到还车结算:核心链路的联调与并发控制

4.1 用 Redis 锁住同一辆车的重复下单

数据库条件更新已经能兜住并发,但秒杀式场景下大量请求打到数据库会拖慢响应。常见做法是在进入事务前加一层 Redis 分布式锁,把同一辆车的并发请求串行化。

public Long rent(Long userId, Long vehicleId) { String key = "lock:vehicle:" + vehicleId; // setIfAbsent 保证只有一个线程能设置成功,30 秒后自动释放,防止宕机死锁 Boolean ok = redisTemplate.opsForValue().setIfAbsent(key, userId.toString(), 30, TimeUnit.SECONDS); if (Boolean.FALSE.equals(ok)) { throw new BizException("操作太频繁,请稍后重试"); } try { return rentOrderService.createOrder(userId, vehicleId); // 真正的下单,内部还有数据库条件更新 } finally { redisTemplate.delete(key); // 必须在 finally 里释放,业务异常也要放锁 } }

setIfAbsent把「判断 key 是否存在」和「写入」合成一个原子命令,等价于SET key value NX EX 30。过期时间不能省,否则服务重启后锁永远不释放。释放动作放finally,是因为下单失败时如果没放锁,这辆车在 30 秒内谁都租不了。注意这里锁的是「操作」而不是「资源」,所以即使锁失效,数据库层的条件更新仍然是最后一道防线,两层防护叠加才是可靠做法。

4.2 计费公式与超时归还的边界处理

计费最怕边界算错:刚好 15 分钟算不算免费,跨天怎么封顶。把规则参数化,用一张price_rule表驱动,接口里只做算术。

public BigDecimal calcFee(LocalDateTime start, LocalDateTime end, PriceRule rule) { long minutes = Duration.between(start, end).toMinutes(); if (minutes < 0) throw new BizException("结束时间早于开始时间"); if (minutes <= rule.getFreeMinutes()) return BigDecimal.ZERO; // 免费时段直接返回 0 long billable = minutes - rule.getFreeMinutes(); // 不足一个计费单元按一个单元算,用向上取整,避免用户钻空子 long steps = (billable + rule.getStepMinutes() - 1) / rule.getStepMinutes(); BigDecimal fee = rule.getBaseFee() .add(rule.getStepFee().multiply(BigDecimal.valueOf(steps))); long days = (minutes + 1439) / 1440; // 向上取整到天 BigDecimal cap = rule.getDayCap().multiply(BigDecimal.valueOf(days)); return fee.min(cap); // 取封顶价与计算价的较小值 }

参数说明:freeMinutes是免费时长,stepMinutes是计费步长,stepFee是每步单价,dayCap是单日封顶。向上取整那行是整段代码的核心,用整数除法加步长减一实现,避免引入Math.ceil的浮点问题。最后fee.min(cap)保证长时间租用不会被算出天价。所有金额运算都在BigDecimal上做,multiplyadd不会丢精度,最后落库前用setScale(2, RoundingMode.HALF_UP)统一保留两位。

场景计算方式常见错误
14 分钟归还返回 0忘记免费时长判断,直接计费
16 分钟归还起步价 + 1 个步长用向下取整,少收一个步长
跨天 25 小时按 2 天封顶只按小时算,超过封顶价
超时未还后台定时任务补单只在还车时算,超时订单永远挂着

4.3 前后端联调的四个高频不一致

联调阶段 80% 的时间花在四件事上,提前定好约定能省掉大量返工。

问题现象解决方式
端口跨域浏览器报 CORS 错误后端配置@CrossOrigin或全局 CorsFilter,只放行前端域名
时间格式前端显示一串时间戳后端 Jackson 配date-format,前端统一用 dayjs 格式化
金额精度前端显示 12.300000000000001后端 BigDecimal 序列化为字符串,前端按字符串展示
状态码语义前端不知道何时跳登录约定 401 跳登录、403 跳无权限页、500 弹通用错误

特别提醒:金额字段在 VO 里用@JsonSerialize(using = ToStringSerializer.class)BigDecimal以字符串返回,前端不要做parseFloat再运算,展示就是展示。时间字段统一返回yyyy-MM-dd HH:mm:ss字符串,前端再转成 dayjs 对象做计算,避免双方对时区理解不一致。

4.4 下单失败时的排查顺序

固定一套排查路径,比随机改代码快得多。按下面顺序逐层确认:第一步看 Redis 里lock:vehicle:{id}是否还残留,残留说明释放逻辑没走到finally;第二步看数据库里车辆status是否被改成 1 但没有对应订单,这是事务没回滚的典型症状;第三步看订单表order_no是否有重复,有则说明唯一索引没建;第四步看后端日志里条件更新的影响行数,行数为 0 就是被别人抢了,属于正常业务拒绝而不是 bug。把这四步写进论文的「测试与排错」章节,评审能看到你有系统性的调试方法。

5. 答辩前的三个工程技巧:让系统能被验证

5.1 用初始化脚本和造数脚本让演示可复现

答辩现场最怕数据库是脏的,演示到一半发现没有可租车辆。把建表语句和种子数据固化成schema.sqldata.sql,放在resources下配合 SpringBoot 启动执行,或者单独提供一份init.sql手工导入。种子数据要覆盖关键状态:至少 2 辆可租、1 辆使用中、1 辆维修,再加 3 个已完成订单和 1 个进行中订单,这样订单列表、统计图表、还车结算三条演示路径都能走通。造数脚本用固定时间戳而不是now(),保证每次初始化后的数据形态一致,截图和论文插图才对得上。

5.2 用拦截器记录操作日志,答辩时讲得清数据流

加一个HandlerInterceptor,在afterCompletion里把请求路径、用户 ID、耗时、异常信息写进operate_log表。代码量不超过 50 行,但答辩时价值很高:老师问「怎么证明这个订单是用户本人创建的」,直接打开日志表按时间排序,请求链路一目了然。日志表字段建议包含trace_id,在前端请求头里生成一个随机串带过来,前后端日志能通过它串起来。

5.3 Vue 打包后布局异常的定位与修复

npm run build之后页面错乱,绝大多数是三类原因。第一类是静态资源 404,vite.config.js里的base没设成部署路径,打包产物引用的是绝对路径/assets/...;第二类是路由刷新 404,用了 history 模式但 Nginx 没配try_files $uri $uri/ /index.html;;第三类是样式被覆盖,公共样式引入了两次,或者 UI 组件库按需引入配置写错导致基础样式缺失。定位方法是打开浏览器控制台看 Network 面板,资源返回 404 就是路径问题,样式文件都在但布局不对就查重复引入。修完这三处,本地能跑的页面在服务器上基本就能跑。

# Nginx 部署 Vue 打包产物的最小配置片段 location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; # history 模式必须,否则刷新任意路由都 404 } location /api/ { proxy_pass http://127.0.0.1:8080/; # 前端同源代理到后端,省掉跨域配置 }

用反向代理把/api转发到 SpringBoot,前端就不需要处理跨域,打包后的VITE_API_BASE写相对路径/api即可,本地开发和线上环境配置保持一致。到这一步,一辆车从扫码到结算的完整链路就算闭合了,剩下的精力应该花在把状态迁移表、并发处理和计费边界这三处讲透,它们才是这个题目真正能拿分的地方。

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

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

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

立即咨询