校园里电动车越来越多,但真正用起来的人却不多。买一辆三四千,毕业带不走只能低价转让,维修充电又麻烦。如果能按小时、按天租,通勤上课随用随走,这个需求在高校里非常真实。我基于SpringBoot+Vue做了一套校园电动车租赁系统,前后端分离,覆盖了车辆管理、在线租车、计费结算、订单追踪这几个核心环节。这篇文章把整个项目的设计思路、核心代码逻辑和踩坑经历完整梳理一遍,给正在做Java全栈项目或者拿这个选题做毕设的同学一份能直接参考的实战记录。
1. 校园场景下电动车租赁的痛点与业务边界
1.1 为什么这个需求在校园里成立
先聊业务。很多人一听租赁系统就觉得是老生常谈,但校园电动车租赁和城市的共享单车、共享电动车完全是两回事。城市的共享电驴是平台统一投放、统一运维,用户随扫随走;校园场景里,车辆归属可能是学校后勤、可能是车行商家、也可能是毕业学长留下的二手车。运营方需要的是"把闲置车辆盘活",而不是重新造一批车投放。这就决定了系统的核心不是"开锁骑行",而是车辆资产的管理、租赁订单的生命周期和计费规则的可配置。
实际调研下来,校园用户有几个典型诉求:
- 短时需求:去两公里外的实验室、去地铁站接人,用一两个小时。
- 按天需求:周末郊游、外出办事,租一整天。
- 长租需求:考研党、实习党,按月包车上下班通勤。
三种需求对应三种不同的计费模式,而且还会叠加。如果一套系统只支持固定单价,运营方就得天天改后台,不现实。所以我在设计时就明确了业务边界:车辆管理、用户租还、订单计费、后台统计,四个核心域,其他的先不做。
1.2 角色权限怎么划分
这个系统的用户角色比较简单清晰:
| 角色 | 主要操作 | 核心诉求 |
|---|---|---|
| 游客 | 浏览车辆、查看价格 | 低门槛了解信息 |
| 学生用户 | 注册登录、租车、还车、续租、查看订单、在线支付 | 流程顺畅、价格透明 |
| 运营管理员 | 车辆上下架、审核订单、处理异常、查看经营数据 | 高效管理、纠纷可追溯 |
| 超级管理员 | 用户管理、管理员分配、计费规则配置 | 权限收敛、灵活配置 |
权限上我没有做得太复杂,就是基于角色的访问控制,后端用Spring Security的注解做接口拦截,前端配合Vue Router的守卫做页面控制。这里要提醒一点:前端路由权限只是体验层面的控制,真正的权限校验必须落在后端接口上。我见过很多毕设项目只在Vue里判断了个角色就完事,结果接口直接裸奔,别人用Postman照样能调管理接口,这个在答辩的时候被问出来会很尴尬。
1.3 核心业务闭环
整个系统要跑通一条完整的链路:车辆展示 → 用户选车 → 创建订单 → 在线支付 → 车辆出库/锁定 → 骑行使用 → 归还车辆 → 订单结算 → 费用清算。
这里面有个容易忽略的点:租车和还车之间,车辆的物理状态和系统状态必须保持一致。比如一辆车被用户租走了,系统里它的状态就不能还是"可租";还车之后必须触发结算动作而不是仅仅把状态改回"可租"。我的做法是给订单状态和车辆状态分别建模,用状态机约束流转,而不是靠零散的if else去改。这个后面详聊。
2. 技术选型:SpringBoot + Vue的角色分工与架构逻辑
2.1 为什么是这两件套
先说我选型的思考过程。后端用Java生态,候选是SpringBoot、SpringCloud(太重)、SSH老框架(没必要)。SpringBoot的优势不用我多吹,自动配置、内嵌Tomcat、起步依赖,做这种单体全栈业务系统效率最高。校园租赁系统撑死几百个并发,单体完全够用,不需要一上来就微服务。
前端用Vue,我当时主要考虑三点。第一,Vue对渐进式开发很友好,像管理后台这种中后台界面,可以用Vue Router + Vuex(或者现在的Pinia)快速搭出工程化骨架;第二,Vue生态里Element Plus、Ant Design Vue这种组件库非常成熟,表格、表单、弹窗、日期选择器开箱即用,管理端的开发速度能快一倍;第三,市场上Vue的招聘需求和资料丰富,以后扩展维护找人接手也容易。
当然这里不是说其他组合不行。如果你的项目偏实时交互、团队又熟React,选React完全没问题。关键是你得能说清楚为什么选,答辩和工作中都是一个道理。
2.2 前端工程与后端工程的目录设计
我用的是前后端完全分离的结构,两个独立工程,通过RESTful接口通信。后端标准SpringBoot分层:
src/main/java/com/campus/ebike/ ├── controller/ # 接口层,只做参数接收和结果封装 ├── service/ # 业务层,事务、状态流转、计费等核心逻辑 ├── mapper/ # MyBatis-Plus持久层 ├── entity/ # 实体类 ├── dto/ # 前端入参对象和出参对象 ├── config/ # 安全配置、Web配置、CORS配置 ├── common/ # 统一返回结果、异常处理、常量 └── utils/ # JWT、日期、金额处理工具前端Vue工程结构:
src/ ├── api/ # 接口请求模块,按业务域拆分 ├── router/ # 路由配置 + 路由守卫 ├── store/ # 全局状态管理 ├── views/ # 页面组件 │ ├── user/ # 用户端页面 │ ├── admin/ # 管理端页面 │ └── common/ # 通用页面 ├── components/ # 公共组件 └── utils/ # axios封装、格式化工具这个分层的好处是职责清晰,controller薄、service厚,后续出问题好定位。很多同学喜欢把业务逻辑全写在controller里,一个方法几百行,看起来能跑,但后期加需求的时候非常痛苦。我建议从一开始就养成"controller只负责接参数和响应,service负责业务规则"的习惯。
2.3 接口风格与统一返回结构
前后端联调最怕的就是各搞各的返回格式。我在项目第一天就定了统一返回结构:
public class R<T> { private Integer code; // 200成功,其他为业务错误码 private String message; // 提示信息 private T data; // 业务数据 }举一个实际接口的例子,查询车辆列表:
@GetMapping("/vehicle/list") public R<PageResult<VehicleVO>> list(@RequestParam Integer page, @RequestParam Integer size, VehicleQuery query) { return R.ok(vehicleService.pageQuery(page, size, query)); }所有接口都走这个格式,前端统一在axios拦截器里处理code不等于200的情况,弹提示、跳登录页,一套逻辑通吃所有接口。这个习惯能省掉大量联调时间。
3. 数据库设计:订单状态机与核心表结构
3.1 核心表清单
数据库设计我最有心得,因为这个项目的灵魂全在表结构和状态流转上。核心表大概十张左右,我把最关键的列出来:
| 表名 | 用途 | 核心字段 |
|---|---|---|
| user | 用户表 | id, phone, password, nickname, role, status, balance |
| vehicle | 车辆表 | id, name, plate_no, type, status, hourly_price, daily_price, monthly_price, location, battery, image |
| vehicle_type | 车辆类型表 | id, type_name, config_desc |
| rental_order | 租赁订单表 | id, order_no, user_id, vehicle_id, start_time, expect_end_time, actual_end_time, rent_type, deposit_status, order_status, total_amount |
| payment_record | 支付流水表 | id, order_id, user_id, amount, pay_type, pay_status, out_trade_no |
| recharge_record | 充值记录表 | id, user_id, amount, balance_before, balance_after |
| coupon | 优惠券表 | id, user_id, amount, threshold, status, expire_time |
| operation_log | 操作日志表 | id, admin_id, action, target_id, detail, create_time |
车辆状态字段status我用了整数枚举:0表示下架、1表示可租、2表示已租出、3表示维护中、4表示已预约锁定。注意"可租"和"已租出"之间还有一个"锁定"状态,是为了处理用户下单后、还没实际取车的那段时间,防止别人同时下单同一辆车。
3.2 订单状态机的流转设计
订单状态是整个系统最容易出错的地方。我用状态机来约束,订单状态status定义:
- 0:待支付
- 1:待取车(已支付)
- 2:使用中(已取车)
- 3:待归还(用户发起还车,系统等待管理员确认)
- 4:已完成(已结算)
- 5:已取消(支付前或超时未支付自动取消)
- 6:异常关闭(超时未还、损坏、纠纷等)
允许的流转路径:
0 -> 1 (支付成功) 0 -> 5 (超时未支付/主动取消) 1 -> 2 (用户扫码取车/管理员确认出库) 2 -> 3 (用户发起还车) 3 -> 4 (管理员确认归还并结算) 2 -> 6 (恶意不还/超时未还触发) 1 -> 6 (支付后长时间未取车,管理员强制关闭)在代码层面,我会写一个状态流转校验的方法,任何修改订单状态的操作都走这个方法,不合法就抛异常:
public void changeOrderStatus(RentalOrder order, Integer targetStatus) { Set<Integer> allowed = TRANSITION_MAP.get(order.getOrderStatus()); if (allowed == null || !allowed.contains(targetStatus)) { throw new BizException("非法的订单状态流转: " + order.getOrderStatus() + " -> " + targetStatus); } order.setOrderStatus(targetStatus); }这个设计看起来很简单,但实际效果非常好。一次我在测试时想当然地把一个"使用中"的订单直接改成"已完成",被这个校验拦下来,才发现中间漏了"待归还"这个节点。状态机不只是为了安全,更是为了逼你把业务流程想完整。
3.3 我对表设计的几条取舍
第一,订单号和支付流水号都用了独立的业务编号,而不是直接用自增id。自增id容易暴露业务量,又有生成时序问题,我都是用"yyyyMMddHHmmss + 随机数"生成订单号,支付流水用更长的唯一字符串。第二,金额字段用decimal(10,2),绝不用float/double,这个属于Java后端基础中的基础了,涉及钱的计算用浮点数会出大问题。第三,所有表都加了create_time和update_time两个公共字段,MyBatis-Plus的字段自动填充一顿配置,省心且排查问题必用。
4. 计费引擎:时租、日租、月租与超时补差的计算实现
4.1 计费规则的可配置设计
计费是租赁系统的核心利润来源,一定要做得灵活。我在vehicle表里直接放了三个价格字段:hourly_price、daily_price、monthly_price,另外还有deposit押金字段。这样做有一个好处:每种车型可以独立定价,普通小电驴和学生款大功率车的价格不一样。
租赁方式rent_type我分了三种:HOURLY(时租)、DAILY(日租)、MONTHLY(月租)。下单选类型时就必须确定,不允许中途切换。这个约束要在后端校验,前端只是展示层面。
种情况,比如时租从下午2点到5点,就是3个小时;但如果用户中途超了两小时,该怎么算?不能简单地时租单价乘以时长,因为超时涉及占用资源的机会成本。我的规则是:前N小时按时租价,超时部分按"超时单价 = 时租价 × 1.5"累计,不足一小时按一小时算,同时封顶为当日日租价,避免极端费用让用户崩溃。
日租则简单一些,超过一天按天累加,不足一天的按天算(因为日租已经是最细颗粒度,不存在按小时折算)。月租最小单位为月,不足一个月按一个月算,这个规则要在下单时前端就明文展示,减少纠纷。
4.2 超时计费的并发与精度问题
这里有一个很容易踩的坑:租车订单的计费发生在还车那一刻,但超时提醒需要定时扫描。如果用户租的车超时了,系统得自动通知他续费或者还车,这需要定时任务配合。我用Spring的@Scheduled注解实现了一个每5分钟扫描一次的定时任务,找出所有超时且状态仍为"使用中"的订单,异步推送站内通知和短信(短信网关这块我用的是短信服务商的API,预留接口,没有真配置)。
到还车结算的时候,要避免两个动作同时改动同一笔订单。我的做法是用MySQL的行锁配合乐观锁,在结算方法上使用select ... for update锁定订单行:
@Transactional public void settleOrder(Long orderId) { RentalOrder order = rentalOrderMapper.selectForUpdate(orderId); // 行锁 // 计算费用、更新状态、生成支付流水 }为什么加行锁?因为定时任务扫到超时订单时会尝试做提醒甚至强改状态,用户端同时又在点还车,两个事务同时读到status=2的订单,都觉得自己能操作,不加锁就会出现状态错乱和重复结算。这个我在前期联调时真实遇到过,一辆车的订单被结算了两次,支出和收入对不上,排查了半天最后用行锁解决。
4.3 押金与余额的账务处理
押金单独做成deposit_status字段:0未付、1已付、2已退。押金不进入订单金额,而是挂账在用户身上。还车时先判断有没有违章、超时未处理的历史订单,有的话先扣异常费用再退押金。这里我用了一个简单的记账思路:所有涉及用户余额变动的操作,都落一条recharge_record或payment_record,绝对不直接在user表的balance上加减就完事。后面查账、对账全靠流水表。
5. 后端核心实现:JWT鉴权、下单事务、接口安全
5.1 JWT鉴权方案与token刷新
认证这块我选了JWT,用Spring Security + JJWT实现。用户登录成功后,后端生成一个token返回给前端,前端存到本地存储,每次请求在axios拦截器里带上Authorization: Bearer <token>头。
private String generateToken(User user) { return Jwts.builder() .setSubject(user.getId().toString()) .claim("role", user.getRole()) .claim("nickname", user.getNickname()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + EXPIRE_TIME)) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); }token过期处理是重点。我以前不做刷新,用户坐那写个半小时的帖子再提交,发现token过期被踢回登录页,体验很差。后来加了刷新机制:token有效期2小时,后端提供一个/auth/refresh接口,前端在axios响应拦截器里判断401,若当前有refreshToken就静默刷新,重新请求一次,用户在无感知的情况下续期。这个机制在移动端和PC端的体验提升非常明显。
5.2 下单与锁车的原子性设计
下单是整个系统并发压力最大的点。两三个人同时盯着同一辆可租的车,如果都提交订单,最后车辆状态只能有一个成功。我的做法是先锁车、后下单,用数据库的原子更新来实现:
@Transactional public Long createOrder(RentOrderCreateDTO dto) { // 1. 原子更新车辆状态:只有当前状态为1(可租),才允许改成4(锁定) int count = vehicleMapper.lockVehicle(dto.getVehicleId()); if (count == 0) { throw new BizException("车辆已被预订或不可租"); } // 2. 创建订单 // 3. 设置超时自动取消时间 // 4. 返回订单号 }对应的MyBatis更新语句是:
UPDATE vehicle SET status = 4, update_time = NOW() WHERE id = #{vehicleId} AND status = 1这个UPDATE ... WHERE status = 1非常关键,它本身就是一把乐观锁。数据库的行锁保证同一时刻只有一个事务能把这辆车从1改成4,其他并发请求update影响行数为0,直接返回"车辆已被预订"。这样就避免了先查询再更新的竞态条件。
5.3 事务控制的关键点
一个下单流程涉及车辆表、订单表、支付记录表三张表,必须放到同一个事务里。我上面用了@Transactional,但这里要注意几个细节:
- 事务方法不能是private,必须通过Spring代理调用,否则注解失效。
- 异常要被Spring的事务机制感知,所以我自定义的
BizException要继承RuntimeException,这样@Transactional才会默认回滚。如果throws了一个受检异常,默认情况下事务不会回滚。 - 不要在大事务里做远程调用或耗时操作。初期我傻乎乎地在下单事务里加了短信通知逻辑,结果通知服务超时,整个下单事务回滚了,用户看到下单失败其实是短信超时。后来我把通知、日志这些非核心逻辑全部改成事务同步、异步执行,核心业务只操作数据库。
5.4 接口安全的几个底线
除了JWT鉴权,我还做了几层防护。第一,越权校验。比如用户A不能通过直接拼接口修改订单B的订单,所有涉及订单/车辆归属的操作,Service层第一步必须是校验当前登录用户与资源归属是否一致。第二,参数校验。用@Validated配合JSR规范给DTO加约束注解,比如手机号格式、金额上限,避免脏数据入库。第三,操作日志。管理端的车辆上下架、订单强关、价格修改都落operation_log,出现问题能追溯到人。
6. 前端Vue实现要点:权限路由、状态展示与接口联调
6.1 管理后台的权限路由与侧边栏
管理端用Vue Router + 动态路由加载。用户登录后,后端根据角色返回可访问的路由表,前端用router.addRoute动态挂载。以管理员为例,他只能看到"车辆管理、订单管理、用户管理、租还管理"这些菜单,看不到"系统配置、管理员管理"这些超管菜单。
// 路由守卫核心逻辑 router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (!token && to.path !== '/login') { next('/login') } else if (token && store.state.user.role === 'ADMIN' && to.path.startsWith('/user')) { // 管理员访问用户端页面,重定向 next('/admin/index') } else { next() } })前端路由守卫别写得太重,只做一层粗略控制,细粒度权限依赖后端接口返回的403/401来兜底。
6.2 车辆展示与租车流程页面
用户端首页的车辆卡片列表我用了一个简单的VehicleCard组件,展示车辆图片、型号、价格、续航、当前状态。状态颜色做了区分:可租绿色、已租灰色、维护中橙色。点击后可看到车辆详情并跳转下单页。
租车流程是三步:
- 选择租赁方式(时租/日租/月租),页面实时计算预估费用,展示押金金额。
- 选择期望取车时间和预计归还时间,后端会校验这个时间窗内车辆是否可租(如果车辆已被别人锁定到某个时间段,就不能租)。
- 确认订单,支付押金或整单金额(我做了押金与租金分离,押金冻结不划扣,还车后自动解冻)。
这个流程里有一个体验上的细节:订单列表的状态标签。我用了一个状态映射组件,把订单状态码翻译成中文标签和对应的操作按钮,比如状态为"使用中"的订单显示"申请还车"按钮,状态为"待归还"的显示"等待管理员确认"。状态与按钮一一对应,避免用户对着一堆状态字段不知所措。
6.3 axios封装与文件上传的坑
axios封装这一步挺关键的。我在utils文件夹里写了一个request.js,统一配了baseURL、超时时间、请求拦截器(带token)、响应拦截器(统一处理业务异常和401)。顺便说一句,前端请求超时时间一定要设,不然后端接口如果挂了,浏览器默认超时时间很久,用户会一直转圈以为系统卡死。
车辆图片上传我用的是Element Plus的el-upload组件,直接上传到后端,上传时把token放进header。这里我踩过一个特别典型的坑:el-upload组件默认的action属性会自行发起请求,如果请求头没带token,后端返回401,图片怎么传都失败。后来我改成自定义http-request,用自己封装的axios实例上传,问题就解决了。
7. 部署上线与实战避坑:本地跑通到服务器可用
7.1 前端构建与Nginx配置
开发完成后的构建部署,我用的是前端打包静态文件、Java后端打成jar包、Nginx做反向代理的方式。前端npm run build生成dist目录,放到Nginx的html目录下,后端mvn package打成jar,用java -jar启动。
Nginx配置的核心是:
server { listen 80; server_name your-domain.com; # 前端静态资源 location / { root /home/www/ebike-frontend/dist; index index.html; # Vue Router history模式,刷新页面不能404 try_files $uri $uri/ /index.html; } # 后端接口反向代理 location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }try_files这行必须有,否则Vue Router用了history模式后,直接访问/admin/index这种二级路径会404。不少同学部署完发现"点菜单能跳转,一刷新就404",就是漏了这一行。
7.2 我踩过的三个典型问题
第一个:跨域问题搭个代理就能解决。开发环境我是用Vue CLI的devServer配了proxy代理,把前端的/api请求转发到后端localhost:8080,这样前端页面访问的域名和接口域名一致,没有跨域。但上线后如果用Nginx做同域反向代理,前后端都在同一个域名下,也不需要CORS。只要记住一点:能用反向代理解决的跨域问题,尽量不要用后端CORS框架去开放跨域,后者的安全风险更高。
第二个:数据库时区问题导致时间差8小时。我第一次连接MySQL时没在连接串里加serverTimezone=Asia/Shanghai,结果所有时间字段在插入后都比真实时间多了8小时,订单的"预计归还时间"全错了。排查了很久才发现是时区问题。链接串里必须显式指定:
spring.datasource.url=jdbc:mysql://localhost:3306/ebike?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai第三个:MyBatis-Plus的分页插件不生效。这个属于绕不过去的坑。新版MyBatis-Plus需要手动配置PaginationInnerInterceptor,如果忘了配,selectPage查出来的total永远是0。我一开始没配拦截器,以为分页代码写错了,折腾了一晚上。
7.3 上线前后的性能与安全建议
系统上线前我做了几件花不了多少时间但很值得的事:
- MySQL连接池配置合理的最小空闲数和最大连接数,默认值在低配服务器上可能撑不住。
- 接口层做了简单的限流,用拦截器对
/api/order这些写操作接口做了IP维度的频率限制,防止有人恶意刷单。 - 密码存储用了BCrypt哈希,加密后的密码即使数据库泄露也没法直接登录。
- 对上传的图片做了类型和大小校验,防止恶意上传脚本文件。
关于性能,这块其实不用过度设计。校园租赁系统的真实并发不会太高,单体应用 + 关系型数据库 + 适当的索引优化完全够用。索引方面,我给订单表的user_id、order_status、create_time分别建了索引,车辆表的status字段建了索引,常见查询都在索引覆盖范围内。真到了量大的那天,再考虑缓存或读写分离也不迟。
最后再分享一点个人的体会。做这个项目最深的感触是:业务规则的设计远比堆砌技术点重要。一开始我很在意用了什么新框架、什么酷炫功能,后来发现真正让这个系统"能跑起来"的,是那些藏在细节里的状态流转、事务边界和并发控制逻辑。状态机、行锁、事务回滚这些Java基础里的东西,学的时候觉得抽象,放到租赁这个具体场景里一下就理解了。如果你也正在做类似的Java全栈项目,建议先把业务流程捋清楚,画出状态流转图,设计好表结构,再动写接口的念头,这样后面每一步都会顺畅很多。