做一个高校场景下的管理系统,很多同学首先想到的是“这不就是增删改查吗”。但真正动手后才发现,从需求拆分、数据库设计、权限控制,到前后端分离联调,每一步都有隐藏成本。尤其是技术栈选了 SpringBoot3 + Vue3 + MySQL 的组合,光是版本兼容和工程结构就足以让新手卡住好几天。
本文从实际开发视角,拆解一个高校单车租赁管理系统从 0 到 1 的实现过程。不只讲代码,还重点说明为什么这样设计、表结构有哪些坑、租还车流程如何保证数据一致、Vue3 前端如何高效对接后端接口。无论你是做课程设计、毕业设计,还是想通过一个完整项目补齐前后端分离开发经验,这篇文章都值得认真看完。
1. 项目背景与价值判断
高校校园面积普遍较大,宿舍区、教学区、实验楼之间的距离常常超过步行舒适范围。共享单车进入校园后解决了出行问题,但高校场景和商业场景有一个明显差异:校园内的单车使用人群相对固定,车辆数量有限,且需要对学生身份进行核验。因此,一个面向高校内部运营的单车租赁管理系统,核心不是“扫码开锁”这类硬件联动,而是对“谁在什么时间租了哪辆车、骑了多久、费用是多少、车辆目前在哪”进行完整记录和高效管理。
从技术训练角度看,这个选题非常合适,原因有三个。
第一,业务边界清晰。用户可以拆分为学生、管理员两种角色,也可以扩展出运维人员角色。业务流是发车、租车、还车、计费、维护,不需要做复杂推荐算法或高并发抢购,适合用来吃透一套主流技术栈。
第二,前后端分离架构是当下 Java 后端岗位的主流工程模式。SpringBoot3 负责后端接口和业务流转,Vue3 + Element Plus 负责管理界面和用户界面,MySQL 负责持久化。这个项目能完整覆盖“标准企业级 Web 应用”的常见组成。
第三,数据一致性场景真实存在。比如一辆车被并发租用、还车时费用计算、车辆状态流转,这些都需要通过数据库设计和事务控制来保证正确性,而不是靠前端按钮临时拼接逻辑。
这里也给出一个明确建议:如果只是机械地做出页面和接口,这个项目只是“管理系统”;但如果把角色权限、租还车状态机、费用计算规则、车辆维护记录做成一套严谨的闭环,它就足以写进简历的项目经验,并能在面试中展开讲述。
2. 系统需求分析与角色梳理
在写代码之前,需求分析比写代码更重要。高校单车租赁管理系统通常包含三类角色,这里以最小可行版本为例:学生用户、系统管理员、车辆维护员。角色不同,看到的菜单和数据操作范围也不同。
学生用户的核心诉求是注册登录、浏览可用车辆、发起租车、发起还车、查看个人骑行订单和历史费用。管理员的核心诉求是车辆信息管理、用户管理、订单查询与统计、计费规则配置。车辆维护员的核心诉求是维护车辆状态,比如将故障车标记为维修中,将维修完成车辆重新设为可用。
角色权限如果不做系统设计,直接在前端用 v-if 控制,会带来安全隐患。因为前端控制只能决定“用户看不到入口”,并不能阻止用户直接调用后端接口。正确做法是后端接口统一校验角色,前端只是做了体验层的菜单过滤。这一点在课程设计和面试中都是加分项。
2.1 业务状态流转分析
单车对象在系统中不是静态数据,而是有生命周期的。从投入校园开始,单车会经历多种状态:
- 可用:停在校园某区域,等待被租用。
- 骑行中:已被用户租用,尚未归还。
- 维护中:故障或需要保养,禁止被租用。
- 已下架:长期损坏或报废。
租车操作成功的前提是单车状态为“可用”。还车操作则将状态从“骑行中”改回“可用”,同时计算费用。维护员操作的前提是单车当前不是“骑行中”,否则会破坏状态一致性。
另一种常见业务状态是订单状态,订单和单车状态需要联动。一个合理的订单状态包括:进行中、已完成、已取消。订单明细中至少记录租车时间、还车时间、租用时长、费用金额,这类数据是后续做校园单车使用情况统计的基础。
2.2 计费规则设计
计费模块是整个系统的业务核心。最简方案是单一费率,比如每小时 1 元,不足一小时按一小时计算。但更合理的方案是把计费规则做成数据库配置,而不是硬编码在 Java 代码中。这样当管理员调整价格时,不需要重新编译部署系统。
计费规则表可以设计为基础价格、单位时间内免费时长、超出后单价。例如前 30 分钟免费,之后每 30 分钟 1 元。为了演示清楚,本文采用简化规则:按分钟计费,每 30 分钟 1 元,不足 30 分钟按 30 分钟计算。
费用计算可以放在 Java 服务层,在还车时根据租车开始时间和当前时间计算。也可以使用 MySQL 计算时间差。更推荐在服务层计算,因为业务规则变化频率往往高于数据库结构变化频率,集中管理便于测试和修改。
3. 技术选型与项目架构
技术选型要回答“为什么用这套组合”和“这套组合的边界在哪”。先看技术栈清单:
| 层次 | 技术选型 | 主要作用 |
|---|---|---|
| 前端框架 | Vue3 + TypeScript | 构建用户界面与后台管理界面 |
| 前端 UI | Element Plus | 提供表格、表单、弹窗、菜单等组件 |
| 前端构建 | Vite | 开发服务器与生产构建 |
| 后端框架 | SpringBoot3 | 提供 RESTful API 与业务处理 |
| 持久层框架 | Spring Data JPA 或 MyBatis-Plus | 数据库操作 |
| 后端安全 | Spring Security 或 Sa-Token | 登录认证与接口鉴权 |
| 数据库 | MySQL 8.x | 数据持久化存储 |
| 接口调试 | RESTful API + JSON | 前后端数据交互 |
SpringBoot3 对 JDK 版本最低要求是 JDK17。这是和旧版 SpringBoot2 的最大区别。如果你的电脑还是 JDK8,需要先升级 JDK 或使用兼容的旧版框架。选择 SpringBoot3 意味着面向未来,Spring 官方对 2.x 的维护支持已经不再扩展,新项目从 3.x 起步更合理。
前端选择 Vue3 搭配 Vite,开发体验比 Vue2 + Webpack 更流畅,组合式 API 也让组件逻辑复用更自然。Element Plus 是 Vue3 生态中成熟的组件库,表格、表单、弹窗这类管理后台常用组件开箱即用,可以节省大量样式时间。
这里要特别解释一下为什么很多课程设计会写“XX 管理系统”,但面试官依然不认可。原因通常是工程结构太混乱:SQL 写死在 Controller,业务逻辑堆在 Service,前端一个页面几千行,也没有异常处理。技术选型只是起点,真正拉开差距的是工程组织和代码结构。
4. 数据库设计:表结构与关键字段
数据库是整个系统的地基。先别急着写代码,把表结构理清楚,后面后端开发会顺利得多。
4.1 数据表清单
以最小完整闭环为例,核心数据表包括:
用户表(user)、车辆信息表(bike)、订单表(rent_order)、计费规则表(charging_rule)、车辆维护记录表(maintenance_record)。如果系统包含站点概念,还需要站点表(station),但这会增加还车区域校验逻辑。本文以无站点管理模式为例,车辆通过状态标记区分是否可租。
4.2 用户表设计
用户表需要同时容纳学生和管理员,更规范的设计是拆成 sys_user 和 sys_role 等表,用中间表维护用户角色关系。但为了让初学项目不至于过度复杂,可以简化为一张 user 表加 role 字段。
创建用户表的 SQL 可以参考如下结构:
CREATE TABLE `user` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `username` varchar(50) NOT NULL COMMENT '登录账号', `password` varchar(100) NOT NULL COMMENT 'BCrypt加密后的密码', `real_name` varchar(50) DEFAULT NULL COMMENT '真实姓名', `student_no` varchar(30) DEFAULT NULL COMMENT '学号', `phone` varchar(20) DEFAULT NULL COMMENT '手机号', `role` tinyint NOT NULL DEFAULT '2' COMMENT '角色:1-管理员 2-学生', `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态:1-启用 0-禁用', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';密码字段长度设计为 100,是因为存储的并不是明文密码,而是 BCrypt 加密后的哈希串。明文密码在数据库中存储是最常见的安全漏洞,这个细节建议在项目中从一开始就规范起来。
4.3 单车表设计
单车表用于记录车辆静态信息与实时状态。车辆状态字段 status 使用 tinyint,并在代码中定义枚举含义,例如 1-可用、2-骑行中、3-维护中、4-已下架。
创建单车表的 SQL 如下:
CREATE TABLE `bike` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `bike_no` varchar(30) NOT NULL COMMENT '车辆编号', `bike_name` varchar(50) DEFAULT NULL COMMENT '车辆名称/型号', `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态:1-可用 2-骑行中 3-维护中 4-已下架', `location` varchar(100) DEFAULT NULL COMMENT '当前停放位置', `total_ride_count` int DEFAULT '0' COMMENT '累计租用次数', `total_ride_duration` bigint DEFAULT '0' COMMENT '累计骑行时长,单位秒', `remark` varchar(255) DEFAULT NULL COMMENT '备注', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_bike_no` (`bike_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='单车信息表';车辆编号 bike_no 使用唯一索引,避免同一编号的车辆重复录入。日常开发中,唯一性约束应当落在数据库层面,而不是只靠代码判断。
4.4 订单表设计
订单表是租还车操作的中心表。订单创建时记录租车人、车辆、租车时间,还车时回填还车时间和费用。为了避免每次查询订单都关联多张表,可将部分冗余字段直接写入订单表,例如 username、bike_no。这种“空间换时间”的冗余设计在管理系统中是合理的,因为订单查询往往非常频繁。
创建订单表的 SQL 可以参考:
CREATE TABLE `rent_order` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `order_no` varchar(40) NOT NULL COMMENT '订单编号', `user_id` bigint NOT NULL COMMENT '租车用户ID', `username` varchar(50) DEFAULT NULL COMMENT '租车账号,冗余字段', `bike_id` bigint NOT NULL COMMENT '单车ID', `bike_no` varchar(30) DEFAULT NULL COMMENT '车辆编号,冗余字段', `start_time` datetime NOT NULL COMMENT '租车开始时间', `end_time` datetime DEFAULT NULL COMMENT '还车时间', `duration_seconds` bigint DEFAULT NULL COMMENT '骑行时长,单位秒', `amount` decimal(10,2) DEFAULT NULL COMMENT '费用金额', `status` tinyint NOT NULL DEFAULT '1' COMMENT '订单状态:1-骑行中 2-已完成 3-已取消', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`), KEY `idx_bike_id` (`bike_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租车订单表';订单编号 order_no 推荐使用时间戳加随机数生成,或者使用数据库时间函数加序列生成,尽量避免使用自增主键作为业务编号暴露给前端。原因很简单:自增 ID 容易被遍历猜测,也缺乏业务含义。
4.5 计费规则表
计费规则表将价格参数化,避免硬编码。
CREATE TABLE `charging_rule` ( `id` bigint NOT NULL AUTO_INCREMENT, `rule_name` varchar(50) NOT NULL COMMENT '规则名称', `unit_minutes` int NOT NULL DEFAULT '30' COMMENT '计费单位,分钟', `unit_price` decimal(10,2) NOT NULL DEFAULT '1.00' COMMENT '每个计费单位的价格', `free_minutes` int DEFAULT '0' COMMENT '免费时长,分钟', `status` tinyint NOT NULL DEFAULT '1' COMMENT '是否启用', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='计费规则表';计费逻辑可以这样理解:骑行总时长先将免费时长扣除,再除以计费单位向上取整,最后乘以单价。向上取整的目的是处理不足一个计费单位的时间片段。例如骑行了 35 分钟,免费 0,按 30 分钟为一个单位,实际收取 2 个单位费用。
5. SpringBoot3 后端环境搭建
后端环境是整套系统的“大脑”。在开始写代码前,需要准备 JDK17、Maven、IDEA、MySQL 8.x。版本号请以实际安装为准,但 SpringBoot3 配合 JDK17 是最稳妥的组合。
5.1 创建 SpringBoot3 项目
如果使用 IDEA 创建项目,选择 Spring Initializr,语言选择 Java,Spring Boot 版本选择 3.x。项目类型选择 Maven。在依赖中至少添加 Spring Web、Spring Data JPA 或 MyBatis 框架、MySQL Driver、Validation、Lombok。以 MyBatis-Plus 为例,由于 SpringBoot3 的 Jakarta 命名空间变化,需要引入mybatis-plus-spring-boot3-starter,这一点和旧版不同。
下面是一个最小可用的pom.xml关键依赖配置:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.5</version> <relativePath/> </parent> <properties> <java.version>17</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.5</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>5.2 配置 application.yml
连接数据库的配置是后端启动的第一步。实际开发中推荐使用环境变量或配置中心管理账号密码,本地演示可以直接写在配置文件中,但不要把生产环境密码提交到 Git。
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/campus_bike_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0注意allowPublicKeyRetrieval=true这个参数,MySQL 8.x 在未配置 SSL 时可能出现公钥检索问题,本地开发环境建议加上。map-underscore-to-camel-case的作用是让数据库下划线字段自动映射到 Java 驼峰属性。
6. 后端核心业务代码实现
后端代码的组织规律可以总结为 Controller 负责接收请求,Service 负责业务逻辑,Mapper 负责数据库操作,Entity 负责字段映射,DTO 负责接口参数。遵守这种分层,后面对接 Vue 前端会非常轻松。
6.1 统一返回结果封装
前端需要一种统一风格的 JSON 响应结构。最常见的是包含 code、message、data 三个字段的对象。code 为 200 表示成功,非 200 表示业务失败或系统异常。
// 文件路径:src/main/java/com/campus/bike/common/Result.java package com.campus.bike.common; import lombok.Data; @Data public class Result<T> { private Integer code; 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; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.setCode(500); result.setMessage(message); return result; } public static <T> Result<T> error(Integer code, String message) { Result<T> result = new Result<>(); result.setCode(code); result.setMessage(message); return result; } }统一返回结果的好处有两个:前端不用为每个接口单独处理字段结构;后端可以通过拦截器或全局异常处理器统一把异常转成固定格式,减少前端空指针判断。
6.2 租车接口实现
租车操作不是简单执行一条 insert。它需要保证两点:单车状态是“可用”,且该用户当前没有未完成的订单。如果直接 insert 订单而不加状态校验,两台电脑同时请求同一辆车的接口时就会出现“同一辆车被两个人租走”的脏数据。
从数据库层面看,可以用乐观锁或事务内行锁来防止并发问题。最直观的实现是 Mapper 层提供条件更新,只有车辆状态是可用时才更新为骑行中。
先定义订单实体类:
// 文件路径:src/main/java/com/campus/bike/entity/RentOrder.java package com.campus.bike.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.math.BigDecimal; import java.time.LocalDateTime; @Data @TableName("rent_order") public class RentOrder { @TableId(type = IdType.AUTO) private Long id; private String orderNo; private Long userId; private String username; private Long bikeId; private String bikeNo; private LocalDateTime startTime; private LocalDateTime endTime; private Long durationSeconds; private BigDecimal amount; private Integer status; }租车业务 Service 方法如下:
// 文件路径:src/main/java/com/campus/bike/service/OrderService.java package com.campus.bike.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.campus.bike.common.BizException; import com.campus.bike.entity.Bike; import com.campus.bike.entity.RentOrder; import com.campus.bike.entity.User; import com.campus.bike.mapper.BikeMapper; import com.campus.bike.mapper.RentOrderMapper; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.UUID; @Service @RequiredArgsConstructor public class OrderService { private final RentOrderMapper rentOrderMapper; private final BikeMapper bikeMapper; /** * 租车 */ @Transactional(rollbackFor = Exception.class) public RentOrder rentBike(Long userId, Long bikeId) { Bike bike = bikeMapper.selectById(bikeId); if (bike == null) { throw new BizException("车辆不存在"); } if (bike.getStatus() != 1) { throw new BizException("车辆当前不可租用"); } Long activeCount = rentOrderMapper.selectCount(new LambdaQueryWrapper<RentOrder>() .eq(RentOrder::getUserId, userId) .eq(RentOrder::getStatus, 1)); if (activeCount > 0) { throw new BizException("您有未完成的订单,不能继续租车"); } // 条件更新:只有 status=1 时才能更新为 2 int updateRows = bikeMapper.update(null, new LambdaUpdateWrapper<Bike>() .eq(Bike::getId, bikeId) .eq(Bike::getStatus, 1) .set(Bike::getStatus, 2)); if (updateRows == 0) { throw new BizException("车辆已被租用,请刷新后重试"); } RentOrder order = new RentOrder(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setBikeId(bikeId); order.setBikeNo(bike.getBikeNo()); order.setStartTime(LocalDateTime.now()); order.setStatus(1); rentOrderMapper.insert(order); return order; } private String generateOrderNo() { return LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")) + UUID.randomUUID().toString().replace("-", "").substring(0, 6).toUpperCase(); } }这段代码的关键点是先查询车辆状态,再通过条件更新将状态从可用改为骑行中。updateRows返回值如果为 0,说明在查询和更新之间车辆状态已被其他事务修改,此时不能创建订单。这个设计能有效避免并发下超卖,比单纯的先查询再更新更可靠。
事务注解@Transactional(rollbackFor = Exception.class)保证单车状态更新和订单创建要么同时成功,要么同时回滚。如果订单插入失败,也必须把车辆状态恢复成可用,否则会出现车辆被占用但无订单的情况。
6.3 还车接口实现
还车时需要计算费用,并回写订单。费用规则用“按固定时间单位计费并向上取整”的方式演示。假设单位时间为 30 分钟,单价 1 元,不足 30 分钟按 30 分钟计算。还车之后还要将单车数量、累计骑行次数等字段同步更新。
// 文件路径:src/main/java/com/campus/bike/service/OrderService.java 新增方法 @Transactional(rollbackFor = Exception.class) public RentOrder returnBike(Long orderId, String location) { RentOrder order = rentOrderMapper.selectById(orderId); if (order == null) { throw new BizException("订单不存在"); } if (order.getStatus() != 1) { throw new BizException("该订单已结束,请勿重复还车"); } LocalDateTime now = LocalDateTime.now(); long duration = java.time.Duration.between(order.getStartTime(), now).getSeconds(); // 计费规则:30 分钟内 1 元,超过按每 30 分钟 1 元累加,分钟向上取整 long unitMinutes = 30; long billUnits = duration / (unitMinutes * 60); if (duration % (unitMinutes * 60) != 0) { billUnits += 1; } // 不足一个单位也要计费,比如只骑了5分钟也需要按一个单位收费 if (billUnits == 0) { billUnits = 1; } java.math.BigDecimal amount = java.math.BigDecimal.valueOf(billUnits); order.setEndTime(now); order.setDurationSeconds(duration); order.setAmount(amount); order.setStatus(2); rentOrderMapper.updateById(order); // 更新车辆状态 bikeMapper.update(null, new LambdaUpdateWrapper<Bike>() .eq(Bike::getId, order.getBikeId()) .eq(Bike::getStatus, 2) .set(Bike::getStatus, 1) .set(Bike::getLocation, location) .setSql("total_ride_count = total_ride_count + 1") .setSql("total_ride_duration = total_ride_duration + " + duration)); return order; }还车时同样用条件更新,保证单车只有处于“骑行中”状态时才能变回“可用”。如果用户重复提交还车请求,第二次会因为订单状态已是 2 而直接抛出异常,避免订单重复完结和费用被多次计算。
6.4 用户认证与角色权限
本项目如果使用 Spring Security,配置会比较冗长。一个更轻量、也更适合课程设计的方案是使用 Sa-Token 或自研登录拦截器。自研 Token 登录适合理解原理:用户登录成功后生成 UUID Token 存入 Redis 或数据库,前端后续请求在 Header 中携带 Token,后端拦截器解析 Token 获取用户信息。
实际企业开发中更推荐 Spring Security + JWT 或 Sa-Token。Sa-Token 的 API 对新手更友好,文档中文齐全,和 SpringBoot3 的整合也较简单。这里不做框架绑定,只是提醒读者:不要把用户信息直接放在前端 localStorage 就当作登录完成,后端必须在每个需要鉴权的接口上校验身份和角色。
为了兼顾演示简洁和代码可读性,下面展示一个拦截器思路。登录后将 userId 和 role 放入 Token,在 Controller 的 HandlerMethod 上通过自定义注解控制角色访问:
// 文件路径:src/main/java/com/campus/bike/common/RequireRole.java package com.campus.bike.common; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface RequireRole { int value(); }拦截器从请求头中取出 Token,查询 Redis 或数据库得到用户信息。如果用户角色不匹配,直接返回 403。前端即使露出按钮,也无法调用没有权限的接口。
7. Vue3 前端项目搭建与页面实现
前端使用 Vite 创建工程,UI 组件库选择 Element Plus。对管理系统来说,最常见的页面布局是左侧菜单、右侧内容区。Vue Router 配置路由和菜单映射,Pinia 负责保存用户 Token 和用户信息。
7.1 创建项目与安装依赖
npm create vite@latest campus-bike-web -- --template vue-ts cd campus-bike-web npm install npm install vue-router@4 pinia element-plus axios创建完成后,前端目录结构可以进行模块划分:
- src/api:存放所有后端接口请求。
- src/router:路由配置。
- src/store:Pinia 状态管理。
- src/views:页面组件。
- src/layout:整体布局组件。
结合热词“java: outofmemoryerror: insufficient memory”,这里做一个额外提醒:前端项目安装依赖时,如果 Node 进程报出内存不足,通常是因为全局包规模较大,可以执行export NODE_OPTIONS=--max-old-space-size=2048(Linux/macOS)或set NODE_OPTIONS=--max-old-space-size=2048(Windows)后重试,和 Java 常见的堆内存溢出思路类似,都是先看运行时进程的可用内存。
7.2 Axios 请求封装
实际项目中不会在每个组件里重复写 axios 配置。通常会把 baseURL、超时时间、请求拦截器、响应拦截器统一封装在一个文件里。
// 文件路径:src/api/request.ts import axios from 'axios'; import { ElMessage } from 'element-plus'; import router from '../router'; const request = axios.create({ baseURL: 'http://localhost:8080/api', timeout: 10000, }); request.interceptors.request.use((config) => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = token; } return config; }); request.interceptors.response.use( (response) => { const res = response.data; if (res.code !== 200) { ElMessage.error(res.message || '请求失败'); return Promise.reject(new Error(res.message || '请求失败')); } return res.data; }, (error) => { ElMessage.error(error.response?.data?.message || '网络异常'); return Promise.reject(error); } ); export default request;这里将后端返回结构中的 data 直接返回给页面组件,减少了页面里的.data.data解构链,但返回类型不再是 AxiosResponse,因此 TypeScript 使用时需要做类型断言,这属于工程实践层面的取舍。
7.3 单车列表页面实现
单车管理页面会展示表格数据,支持按车辆编号搜索。用户登录后调用/bike/list接口获取数据,并通过 ElTable 渲染。
<!-- 文件路径:src/views/bike/BikeList.vue --> <template> <div> <el-card> <el-form inline> <el-form-item label="车辆编号"> <el-input v-model="query.bikeNo" placeholder="请输入车辆编号" clearable /> </el-form-item> <el-form-item> <el-button type="primary" @click="loadData">查询</el-button> </el-form-item> </el-form> </el-card> <el-table :data="tableData" border stripe> <el-table-column prop="id" label="ID" width="80" /> <el-table-column prop="bikeNo" label="车辆编号" /> <el-table-column prop="bikeName" label="车辆名称" /> <el-table-column label="状态" width="120"> <template #default="{ row }"> <el-tag :type="statusMap[row.status]?.type"> {{ statusMap[row.status]?.text }} </el-tag> </template> </el-table-column> <el-table-column prop="location" label="停放位置" /> <el-table-column prop="totalRideCount" label="累计租用次数" /> </el-table> </div> </template> <script setup lang="ts"> import { onMounted, reactive, ref } from 'vue'; import request from '../../api/request'; const query = reactive({ bikeNo: '' }); const tableData = ref([]); const statusMap: Record<number, { text: string; type: 'success' | 'warning' | 'danger' | 'info' }> = { 1: { text: '可用', type: 'success' }, 2: { text: '骑行中', type: 'warning' }, 3: { text: '维护中', type: 'danger' }, 4: { text: '已下架', type: 'info' }, }; const loadData = async () => { const data = await request.get('/bike/list', { params: query }); tableData.value = data; }; onMounted(loadData); </script>如果读者用的是 Vue3 组合式 API +<script setup>语法,可以省去大量 Options API 的样板代码。Element Plus 的表格组件自带分页能力,建议在后端做分页参数接收,不要一次性把全表数据返回给前端。
8. 核心业务接口实现与前后端交互
租车和还车两个接口是整个系统最重要的交互点。下面完成后端 Controller 的编码,并解释前端如何调用。
8.1 租车 Controller
// 文件路径:src/main/java/com/campus/bike/controller/OrderController.java package com.campus.bike.controller; import com.campus.bike.common.Result; import com.campus.bike.entity.RentOrder; import com.campus.bike.service.OrderService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/order") @RequiredArgsConstructor public class OrderController { private final OrderService orderService; @PostMapping("/rent") public Result<RentOrder> rent(@RequestParam Long bikeId) { // 实际项目中 userId 应从登录态获取,这里通过请求参数或拦截器注入 Long userId = LoginContext.getUserId(); return Result.success(orderService.rentBike(userId, bikeId)); } @PostMapping("/return") public Result<RentOrder> returnBike(@RequestParam Long orderId, @RequestParam String location) { return Result.success(orderService.returnBike(orderId, location)); } }为了简化演示,这里没有写完整的 LoginContext 实现。真实项目中,登录拦截器将 userId 放入 ThreadLocal,Controller 中可以直接获取,避免每个接口都重复解析 Token。
8.2 前端调用租车接口
用户扫码或点击租车按钮后,前端调用后端接口。核心逻辑是成功后提示用户,同时刷新车辆列表。
const rentBike = async (bikeId: number) => { await request.post('/order/rent', null, { params: { bikeId } }); ElMessage.success('租车成功'); await loadData(); };这里使用 POST 请求但通过 params 传递参数,Spring MVC 可以使用@RequestParam接收。如果希望更符合 RESTful 风格,可以把请求体封装成 DTO。两种方式都没问题,关键是前后端约定保持一致。
9. 项目联调与效果验证
前后端代码完成后,需要进行完整联调。建议的启动顺序是:
- 启动 MySQL,执行建库建表 SQL。
- 启动 SpringBoot 后端服务,确认端口 8080 正常。
- 启动 Vue3 前端开发服务器,默认端口 5173。
- 通过浏览器访问前端地址,完成登录注册。
一个值得推荐的验证方式是先通过 Swagger 或 Apifox 调通后端接口,再通过页面验证。Swagger 在 SpringBoot3 中的依赖是springdoc-openapi-starter-webmvc-ui,不会因为 SpringFox 与 SpringBoot3 不兼容而无法启动。
9.1 预期效果
管理员账号登录后可以看到车辆管理、订单查询、用户管理菜单。学生账号登录后可以看到校园车辆列表,点击租车后车辆状态变为骑行中,个人中心生成一条进行中订单。还车时需要填写停放位置或选择预设位置,点击还车后订单状态变为已完成,并显示骑行时长和费用金额。
如果租车时选择了一辆已经被别人租走的车辆,页面会弹出“车辆已被租用,请刷新后重试”,这就是后端条件更新生效的最直观验证结果。
9.2 如何验证并发安全
启动两个浏览器窗口,用不同学生账号登录,同时点击租用同一辆可用单车。理想情况下只有一个请求成功,另一个得到错误提示。如果后端的租车 SQL 仍然是“先查状态,再执行 update”且没有条件判断,两个请求就可能同时成功。
还可以通过查看 MySQL 日志或后端打印的 SQL 来确认两个请求是否分别执行了条件更新语句。update bike set status = 2 where id = ? and status = 1这样的查询在第二个事务提交时返回影响行数为 0,从而触发异常并回滚。
9.3 常见联调问题
前端请求后端接口出现跨域问题时,常见处理方式是在后端添加 CORS 配置类。下面是一个简单配置:
// 文件路径:src/main/java/com/campus/bike/config/CorsConfig.java package com.campus.bike.config; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins("http://localhost:5173") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true); } }生产环境不建议直接开放allowedOrigins("*"),而应限定为具体的前端域名。
10. 常见问题与排查思路
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| SpringBoot3 项目启动失败,提示无效的源发行版 | 本地 JDK 版本低于 17 | 执行java -version查看 JDK 版本,检查 IDEA 项目 SDK 配置 | 安装 JDK17+ 并在 IDEA Project Structure 中切换 SDK |
| 启动时提示数据库连接失败 | MySQL 未启动、密码错误或数据库不存在 | 先通过命令行mysql -u root -p测试连接,再查看后端报错堆栈 | 启动 MySQL,创建数据库,修正 application.yml 中的连接信息 |
| 执行 SQL 创建表时提示字段名冲突 | 字段使用了 MySQL 保留字,如 order、user | 查看 SQL 报错位置 | 将表名改为 rent_order、sys_user,或使用反引号包裹 |
| 前端请求接口跨域 | 后端没有配置 CORS | 打开浏览器 F12 查看 Network 控制台报错 | 添加 CorsConfig,或在前端 Vite 中配置代理 |
| 租车成功后车辆仍显示可用 | 前端未刷新数据 | 检查租车接口响应,查看订单表是否有新增记录 | 租车成功后调用 loadData 重新拉取列表 |
| 还车后金额为负数或为 0 | 时间计算或计费规则有误 | 查看 startTime 和 endTime 是否读取正确 | 统一使用 LocalDateTime,前端的日期格式与后端保持一致 |
| 同一车辆被并发租用 | 没有使用条件更新或锁机制 | 开启两个窗口并发测试 | 租车使用update ... where id=? and status=1方式 |
| 前端启动后页面空白 | Vue Router 或组件引入路径错误 | 查看浏览器控制台报错 | 检查路由配置和 import 路径大小写 |
如果遇到 MySQL 连接时报Can't connect to local MySQL server through socket '/tmp/mysql.sock',在 Linux/macOS 上通常表示 MySQL 服务没有启动或 socket 文件路径不匹配。可以先执行systemctl status mysql查看服务状态,或通过mysql -h 127.0.0.1 -P 3306 -u root -p强制走 TCP 连接,确认服务是否真的存在。
11. 最佳实践与工程建议
11.1 统一异常处理
后端不要只在 Service 层手动 try-catch。推荐使用@RestControllerAdvice做全局异常处理。自定义业务异常BizException时返回 code 500,参数校验异常返回 code 400,系统异常返回 code 500 并记录日志。
11.2 数据库变更要纳入版本管理
开发过程中表结构一定会调整。不要只靠手写的 SQL 文件在多个环境之间传播,建议引入 Flyway 或 Liquibase。对于课程设计,至少也要保证项目仓库中有一份完整的 schema.sql,并记录每次变更内容。
11.3 密码与敏感信息处理
用户密码必须使用 BCrypt 加密存储。数据库账号密码不要直接写死在公开配置中,可以借助环境变量覆盖。在前端不要存储管理员账号密码,也不要把接口详情暴露在打包代码中。
11.4 日志与监控
管理系统的订单操作属于关键业务,应该在 Service 层记录操作日志,包括用户ID、操作类型、订单编号、请求结果。不要依赖 System.out 打印来排查问题。生产环境至少使用 Logback 输出到文件,并配置按天滚动和保留策略。
11.5 前端权限动态化
菜单权限可以写死在前端,但更推荐的方案是后端返回当前用户拥有的菜单列表,Vue Router 通过动态添加路由的方式生成侧边栏。这样当管理员新增菜单时,不需要重新发布前端。
12. 项目亮点总结与面试表达建议
如果把这个项目写进简历,要能从三个层面描述:
第一,业务层面。说清楚系统为高校单车租赁提供了哪些能力,如何管理学生用户、单车状态和订单流程。
第二,技术层面。说清楚技术栈是 SpringBoot3 + Vue3 + MySQL,并说明为什么选择这些版本。强调在租车并发场景下使用了条件更新和事务控制,确保同一车辆不会被重复租用。
第三,工程层面。说清楚自己实现了统一返回结果、全局异常处理、登录拦截器、角色权限校验、代码分层结构。如果做过数据库索引优化、前端路由懒加载甚至 Docker 部署,都可以作为额外加分项。
当面试官问“你这个项目有什么难点”时,不要只说“实现了租车和还车功能”,而要展开描述数据一致性这个切入点。例如:当两个用户同时租同一辆车时,如何保证只有一个人成功?如果还车时计算费用和更新车辆状态不同步会有什么后果?这种能体现数据库设计和并发控制意识的内容,比作品本身更容易给面试官留下印象。
13. 总结与后续优化方向
本文以高校单车租赁管理系统为例,完整演示了 SpringBoot3 + Vue3 + MySQL 前后端分离项目的核心开发链路。梳理了角色权限、单车状态流转、订单状态、计费规则,给出了数据库建表 SQL 和关键租还车业务代码,也解释了并发场景下使用条件更新保证数据一致性的必要性。
如果希望继续深入,可以把项目延伸到下面几个方向:
第一,引入 Redis 缓存车辆热点数据,降低数据库查询压力。第二,用 RabbitMQ 或消息队列做还车后的订单异步通知,模拟真实业务中的消息驱动。第三,加入地图选点和还车区域校验,通过经纬度判断用户是否在指定区域还车。第四,使用 Docker Compose 将 MySQL、Redis、后端服务、前端站点一键编排部署。
这套技术栈足够覆盖课设要求,也足以作为学习 SpringBoot3 和 Vue3 的完整练手项目。如果你正在做类似的管理系统,建议先把本文的数据库表和租还车流程跑通,再逐步增加其他功能,方向比速度更重要。