ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

校园单车租赁系统:SpringBoot + Vue 并发事务实战解析

校园单车租赁系统:SpringBoot + Vue 并发事务实战解析 简介这是一份基于SpringBootVue的校园单车租赁管理系统完整毕业设计项目面向Java方向的毕业生、课程设计学生及需要快速搭建前后端分离项目的开发者。系统包含后台框架SpringBoot、前端Vue/JSP、MySQL数据库及Maven管理工具已内置代码注释部署教程清晰下载后简单配置即可运行。资源包共6个文件压缩后约47.44MB主要包含项目源码rar、数据库sql脚本、论文及PPT压缩包、使用说明txt等结构明了便于按需取用。目前已有190人学习下载。除完整可运行代码外还提供配套论文、演示PPT与详细部署指引覆盖从环境搭建到功能演示的全流程能为毕业设计答辩或课程设计验收提供直接参考。1. 校园单车租赁系统的并发风险为什么不能只写 SpringBoot Vue 的 CRUD下午两点正是下课高峰两辆手机同时扫上同一辆车的二维码后端两个请求同时把这个车标成「已租」。如果这个系统的租车接口只做了「查状态 → 改状态」两步第二个人照样能扫开锁订单表里就会出现两条租用同一辆车的记录。校园单车租赁管理系统表面上是一个 SpringBoot Vue 的前后端分离课程设计但它的核心难点不在增删改查而在「同一辆车的状态只能被一个事务改成功」这件事上。这套系统适合两类人一类是拿它做毕业设计、想给答辩老师讲清楚事务和权限的同学另一类是准备 SpringBoot 面试、需要拿真实业务场景串起 JWT、乐观锁、索引和 Vue 路由守卫的工程师。下面按后端、前端、数据库三条线把从登录到还车计费这条完整链路拆开讲。2. SpringBoot 后端实现REST API、JWT 鉴权与租还车事务2.1 工程结构与角色权限划分常见做法是先按 controller、service、mapper 三层把工程切开而不是把业务逻辑堆在 Controller 里。这个系统的 Controller 只做参数校验和响应包装Service 层承载租车、还车、充值和报修的状态流转Mapper 层用 MyBatis-Plus 的 BaseMapper 处理单表操作自定义 SQL 只留给带条件更新的场景。src/main/java/com/campus/bike/ ├── controller/ # AuthController、BikeController、OrderController ├── service/ # 租还车事务、计费规则、钱包流水 ├── mapper/ # MyBatis-Plus 接口 自定义 XML ├── interceptor/ # JWT 拦截器 ├── config/ # 跨域、拦截器注册、JSON 序列化 └── entity/ # 与五张核心表对应的实体接口层面需要先明确两个角色学生和管理员。管理员负责投放单车、设置计费价格、处理报修和查看统计报表学生只能浏览车辆、租车、还车、充值和查看自己的订单。接口与角色的对应关系如下表。接口角色说明POST /api/auth/login游客登录换取 JWTGET /api/bike/list学生、管理员按站点和状态查车POST /api/bike/rent学生扫码租车热点更新POST /api/bike/return学生还车并计算费用PUT /api/admin/bike管理员新增或下架单车POST /api/admin/report管理员按天统计营收2.2 JWT 鉴权拦截器里做什么这个系统的登录态不建议用 Session前后端分离部署时 Session 会遇到跨域和集群同步问题。用 JWT 把 userId 和 role 放进 Token后端用拦截器统一校验签名和过期时间再把当前用户放进 ThreadLocal 供 Controller 使用。Token 签发时设置 2 小时过期前端在过期前用 401 响应触发重新登录不额外做刷新接口也能跑完整个毕业设计流程。public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String header request.getHeader(Authorization); if (header null || !header.startsWith(Bearer )) { response.setStatus(401); return false; } // 只校验签名、过期时间不在拦截器里查库 Claims claims Jwts.parser() .verifyWith(secretKey) .build() .parseSignedClaims(header.substring(7)) .getPayload(); UserContext.set( Long.parseLong(claims.get(userId).toString()), claims.get(role).toString() ); return true; } }这段代码的关键是parseSignedClaims会同时校验签名和exp任何一步失败都会抛异常所以 Controller 里不用再关心 Token 是否过期。secretKey 建议放到 application.yml 里用环境变量覆盖不要写成常量前端传过来的 header 固定用Bearer前缀前后端都要对齐这个约定。UserContext 是一个 ThreadLocal 包装类请求结束时必须在 afterCompletion 里调用 remove否则线程池复用时会串用户。2.3 租车用乐观锁还车用悲观锁事务边界怎么切租车是整个系统并发压力最大的操作所有学生都在同一批热门车上抢。我一般不用「先 select 再 update」因为两个请求都读到 status1又都走到 update第二个人就能把已经租掉的车再租一次。正确做法是用一条带条件的 update 直接完成状态流转配合 version 字段做乐观锁。Transactional(rollbackFor Exception.class) public RentResult rentBike(Long bikeId, Long userId, Integer version) { // 条件里同时带上 fromStatus 和 version保证是当前页面读取到的那一版 int row bikeMapper.updateStatusByVersion( bikeId, 1, 2, version ); if (row 0) { throw new BizException(车辆已被租走请刷新后重试); } RentOrder order new RentOrder(); order.setBikeId(bikeId); order.setUserId(userId); order.setStatus(1); orderMapper.insert(order); return RentResult.of(order); }-- Mapper XML 中的自定义更新语句 update bike set status #{targetStatus}, version version 1 where id #{id} and status #{fromStatus} and version #{version}updateStatusByVersion返回的row就是影响行数。只有条件全部满足时才会更新成功第二个请求因为 version 或 status 不匹配得到 0 行直接被事务回滚。前端在查询单车列表时拿到 version点击租车时把它传回来这样即使用户在旧页面上重复点提交第二次也一定失败。单车表是典型的单行热点悲观锁会把行锁一直持有到事务提交而乐观锁让失败的请求立刻返回吞吐量更高。还车是低频操作但必须保证费用只计算一次。还车接口里先selectByIdForUpdate锁住订单行再计算分钟数、扣钱包、更新订单状态。这里有三个容易踩的坑一是计费规则不要写在 Controller 里要单独抽一个calcFee(minutes)方法方便改价格二是钱包扣款用带条件的update wallet set balance balance - #{fee} where user_id #{userId} and balance #{fee}防止余额扣成负数三是Transactional的 rollbackFor 指定为 Exception.class否则运行时异常以外的错误不会回滚。事务边界就画在「锁单车 插订单」和「锁订单 扣余额 更新状态」这两个方法上中间不要夹杂远程调用。3. Vue 前端实现路由守卫、axios 封装与单车状态刷新3.1 路由守卫按 meta.roles 控制页面权限前端这套系统一般用 Vue 3 Vite Pinia Element Plus 搭建页面分学生端和管理端登录页、单车列表页、租车详情页、钱包页、订单页、管理后台。权限控制并不复杂路由表里给每个页面配 meta.roles导航守卫里先看有没有 Token再看角色是否命中。router.beforeEach((to, from, next) { const token localStorage.getItem(token); if (to.meta.public) { return next(); } if (!token) { return next({ path: /login, query: { redirect: to.fullPath } }); } const role useUserStore().role; if (to.meta.roles !to.meta.roles.includes(role)) { return next({ path: /403 }); } next(); });这个守卫最大的问题是页面刷新后 Pinia 里的 role 会被清空。解决方式有两个要么在登录时把 role 也存进 localStorage刷新后从 localStorage 恢复要么在守卫里发现 store 中没有 role 时先调/api/user/info拉一次用户信息再放行。第二种更安全因为角色以服务端返回为准不信任前端存储。注意/login一定要标记meta.public否则会陷入「未登录跳登录页跳登录页又被守卫拦下来」的死循环。3.2 axios 拦截器统一携带 Token 和处理 401项目中每个请求都要带Authorizationheader响应里只要出现 401 就得清 Token 跳登录页。把这些逻辑写死在每个页面里会非常散正确做法是封装一个 request 实例把两件事收敛到拦截器里。const service axios.create({ baseURL: /api, timeout: 10000 }); // 请求拦截从 localStorage 拿 Token 拼 header service.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer ${token}; } return config; }); // 响应拦截401 统一清登录态 service.interceptors.response.use( res res.data, err { if (err.response err.response.status 401) { localStorage.removeItem(token); router.push(/login); } return Promise.reject(err); } );这里有个容易被忽略的细节后端响应的原始结构是{ code, message, data }所以响应拦截器直接返回res.data业务代码里拿到的就是整个包裹体再自己解构 data 字段。如果后端把 code 字段设计成「非 0 也是业务错误」建议在响应拦截器里统一判断 code而不是让每个页面重复写相同的错误提示。跨域配置在 Vite 里用 server.proxy 把/api代理到 SpringBoot 的 8080 端口这样开发环境没有 CORS 问题生产环境用 Nginx 转发同一个前缀。3.3 单车状态刷新轮询的间隔怎么选单车列表页需要不停刷新车辆状态有人刚租走一台车、管理员刚把故障车改成维修状态、某辆车被还回站点。很多同学一上来就写 WebSocket其实校园场景下车辆状态变化的实时性要求只有秒级用轮询反而更简单可靠。服务端不需要维持长连接前端也不需要在断线后做重连逻辑。onMounted(() { loadBikeList(); timer setInterval(loadBikeList, 10000); }); onBeforeUnmount(() { clearInterval(timer); });轮询间隔需要根据页面类型区分单车列表用 10 秒因为车辆被租走后到列表刷新之间少几秒不影响用户体验租车页上显示的倒计时用 1 秒因为用户能直观看到剩余时长的变化已经进入扫码开锁流程的页面不要做轮询避免在开锁过程中刷新列表把当前车辆状态改掉。轮询的坑在组件卸载Vue 3 的onMounted里启动了setInterval却忘了在onBeforeUnmount里清理页面切走后定时器仍然在发请求控制台会一直报 canceled 请求。实际开发中我习惯在 4.2 封装一个usePolling组合式函数把setInterval、clearInterval、错误重试都收进去避免每个页面复制粘贴这段代码。场景轮询间隔要注意的点单车列表状态10s请求失败时保留上一次数据不要清空列表租车倒计时1s时间从本地算还车费用以后端为准扫码开锁中不轮询轮询会干扰开锁状态机4. 校园单车租赁数据库设计表结构、索引与统计 SQL4.1 五张核心表别把计费规则写死在字段里这个系统的数据库一般由五张表构成用户表、单车表、租车订单表、钱包流水表、报修表。单车站点如果要做筛选单独拆一张 station 表更清晰不做也没问题。重点是租车订单表要同时存 user_id 和 bike_id方便按两个维度查历史记录钱包流水表和订单表分开原因是充值、消费、退款三种流水对应不同的业务来源强行塞进订单表会让查询逻辑变得混乱。create table bike ( id bigint primary key auto_increment, bike_no varchar(32) not null unique, status tinyint not null default 1 comment 1可租 2已租 3维修, station_id bigint not null, version int not null default 0, create_time datetime default current_timestamp, key idx_station_status (station_id, status) ) engineinnodb default charsetutf8mb4; create table rent_order ( id bigint primary key auto_increment, user_id bigint not null, bike_id bigint not null, start_time datetime not null, end_time datetime default null, amount decimal(10,2) not null default 0, status tinyint not null default 1 comment 1租用中 2已还 3已取消, key idx_user_status (user_id, status), key idx_bike_status (bike_id, status) ) engineinnodb default charsetutf8mb4;bike 表的idx_station_status是一个典型的联合索引查询条件几乎总是「某个站点 可租状态」把 station_id 放前面、status 放后面这个查询就能走索引。单独给 status 建索引意义不大因为 status 只有 1、2、3 三个值区分度太低。rent_order 表的 idx_user_status 用于「某个用户的所有进行中订单」这种高频查询课程设计里经常出现的问题是「查询所有进行中订单」不看用户这种场景下联合索引的第二个字段失效但这不是索引设计错而是业务设计上就应该按用户维度查。订单金额amount不要设计成not null还车之前它没有值用 null 表示「尚未计费」比用 0 更准确。计费规则比如首小时内 1 元、超出部分每分钟 0.05 元不要写进数据库字段把它放在后端配置里否则改了单价还要写数据迁移脚本。这正好是数据库课程设计答辩时老师最喜欢问的问题「如果计费规则变了你的表结构要不要改」答案是只改后端常量不动表结构。4.2 索引与事务隔离为什么租车不能用 select for update第 2 章说过租车用乐观锁这里从数据库视角看为什么。select ... for update会在事务提交前一直持有行锁而「租车」这个事务里还做了订单插入、前端响应整个过程至少要几百毫秒。热门单车在这一刻变成串行入口后面的请求全部堵在锁等待上数据库的等待线程一多连接池也会被耗尽。乐观锁把冲突检测放到 update 语句里锁持有时间只有这一条 update 的执行时间这是两种方案最本质的差别。事务隔离级别上这个系统用 MySQL 默认的 REPEATABLE READ 没问题但要注意快照读和当前读的区别。租车接口里的条件 update 是当前读每次执行都读最新已提交数据而订单列表查询走的是快照读在一个事务里多次查询结果一致。课程设计里常见的演示场景是「先开一个事务查询订单再开另一个事务还车第一个事务查询不到新状态」这不是系统 bug是隔离级别导致的预期行为。还车接口里我用select ... for update锁订单行也是当前读所以它能看到最新状态。线上排查慢查询时一般先看explain的 type 列至少要到 range 或 ref出现 ALL 就要考虑加索引。结合这个系统来说统计报表的查询最容易出现全表扫描。4.3 按天统计的报表 SQL范围条件比函数包裹更友好管理后台要展示「当天营收」「近 7 天订单数」「每站点的车利用率」。最简单且正确的写法是对 rent_order 做分组聚合select date_format(start_time, %Y-%m-%d) as day, count(*) as order_count, coalesce(sum(amount), 0) as revenue from rent_order where start_time #{begin} and start_time #{end} group by day order by day;这里有两个细节。一是where里用start_time ? and start_time ?这种半开区间能保证边界值只算一次二是不要在where里写date_format(start_time, %Y-%m-%d) 2025-01-01因为函数包裹列会使索引失效。group by 出来的 revenue 如果当天没有订单sum 会返回 null所以必须coalesce(sum(amount), 0)否则前端拿到 null 渲染会报错。如果租车订单表的开始时间不是当天生成而是「预约租车」还要额外过滤一个状态条件保证统计的订单都是真实完成的不要把取消状态的订单算进营收。报表查询在数据量小的时候无所谓等订单到几十万条可以按月分表也可以建一张日汇总表定时从订单表聚合管理后台直接查汇总表避免每次报表请求都扫全表。很多毕业设计做到这一步就够了五张表、两三个联合索引、一两条聚合 SQL把数据库设计的完整度撑起来。5. SpringBoot Vue 联调排错401、跨域与死锁排查顺序联调阶段最容易出问题的三个点登录后所有请求都 401、页面加载了但接口跨域、并发还车时偶发死锁。下面按现象列出排查顺序。现象检查内容常见原因登录成功但业务接口 401前端 header 是否带上了Bearer前缀Token 拼接格式不一致请求发出但前端控制台报跨域Vite 代理是否配置了/api后端是否 allowCredentials代理没走通或没配置了跨域来源还车时偶发 500日志里有 deadlock扣钱和更新订单的锁顺序是否一致两个事务锁表顺序相反租车失败但没有任何报错update 影响行数为 0 是否被代码忽略了乐观锁版本不匹配没有抛出业务提示排查 401 时先在后端拦截器里打印请求的完整 header确认 Token 是否真的传过来了。Spring Security 和自定义拦截器同时存在时会互相影响常见坑是 SpringBoot 版本太高时 Spring Security 6 的过滤器链写法变了直接套旧博客的代码会导致所有接口都被拦在登录页。我的习惯是这个系统不引入 Spring Security只保留自定义 JwtInterceptor功能完全够用还少一层排错的复杂度。排死锁有一个必须遵守的纪律锁的顺序要全局一致。比如还车事务里先锁钱包再更新订单那么充值和退款的事务也必须先锁钱包后更新订单如果某个事务反过来先更新订单再锁钱包两个事务就可能互相等对方持有的锁。用下面的方式可以快速抓到现场。# 打开 MySQL 通用日志复现一次必现场景后关闭 mysql set global general_log on; mysql set global general_log_file /tmp/mysql_general.log; mysql set global general_log off;# 用 Arthas 观察还车方法的实际耗时和锁定过程 java -jar arthas-boot.jar watch com.campus.bike.service.impl.OrderServiceImpl returnBike {params, returnObj} -x 2如果几分钟内复现不了死锁用脚本并发调用还车接口模拟两个用户同时提交日志里出现Deadlock found后就去看两个事务的 SQL 顺序。最后落到优化上我会用explain检查租车和订单查询的执行计划重点看 type 不是 ALL、key 不是 null。这套检查做完这个项目的后端、前端和数据库才算真正闭环。本文还有配套的精品资源点击获取
返回列表