最近完整落地了一个基于 SpringBoot + Vue 的电动车租赁服务系统,从需求梳理、数据库设计到前后端编码、部署上线全部走了一遍。趁着对这个项目的记忆还热乎,把整个设计和实现过程整理出来,重点放在业务建模、订单状态流转、计费逻辑、地图接入这些“看着简单、做着容易翻车”的环节,给准备做同类项目的人一份能直接参考的实操记录。
这个系统解决的痛点很直接:传统租车要么走线下登记,要么用 Excel 人工管理,车辆停在哪个位置、电量还剩多少、谁正在骑、骑了多久、该扣多少钱,全靠人肉核对,一旦车辆多了,管理成本直线上升。电动车租赁系统把“找车—扫码开锁—骑行—计费—还车—结算”整条链路搬到了线上,用户在 H5 / Web 端完成操作,运营人员在后台统一管理车辆、订单和计费规则。整个项目从业务逻辑上讲,就是一个典型的“低并发但高状态复杂度”的系统,非常适合用来理解前后端分离项目的完整落地过程。
如果你想做 SpringBoot 方向的毕业设计,或者已经会写 CRUD、想进阶到真正能处理业务状态流转和并发问题的开发,再或者公司内部需要搭一套“小程序/App + 后台 + 硬件设备”的租赁类系统,这篇文章里的设计方案、核心代码思路、踩坑记录都有可以直接搬走的部分。
1. 项目整体方案设计拆解
1.1 电动车租赁业务的核心痛点
做这类型系统,第一步不是建表,而是先想清楚“业务到底在管什么”。表面上是管车,本质上是管三样东西:人、车、钱。人是用户和运营人员,车是分布在各个站点的电动车辆,钱是押金、骑行时长费、优惠券和退款。租赁系统的复杂度和价值,全部体现在这三者之间的状态流转上。
一辆车在系统里有哪些状态?空闲、已被预约、骑行中、充电中、故障待维修。一个订单有哪些状态?待支付、进行中、待结算、已完成、已取消、争议处理中。把这些状态先列出来,数据库表结构基本就定型了。很多人一上来就直接写 controller 和 mapper,结果做到一半发现订单状态改不动、车辆状态对不上,原因就是前期没有把状态机设计清楚。
这个项目我最初设计的核心状态流转是这样的:
- 用户登录后在地图上选择空闲车辆,提交租车申请,生成待支付订单;
- 支付押金或完成预授权后,下发开锁指令,订单变为进行中;
- 用户骑行,系统按时间计费,期间可以上报车辆故障;
- 用户还车,系统根据骑行时长和计价规则计算费用,推送账单;
- 用户支付账单后订单完成;未支付则进入待结算状态;
- 超过一定时间未支付的订单,后台可强制关单并记录违约。
这个状态机是后续所有接口设计的基准,宁可前期多花两天画图,也不要后期推倒重来。
1.2 技术选型背后的考量
技术栈没有悬念地选了 SpringBoot + Vue,核心原因有三点:生态成熟、人才多、中小系统落地成本低。
先看后端。SpringBoot 最吸引人的地方是自动装配机制,引入一个 starter 就能快速获得一套可用的能力,比如 spring-boot-starter-web 内置了 Tomcat、Jackson、参数校验等基础组件,spring-boot-starter-data-redis 帮你把 RedisConnectionFactory、StringRedisTemplate 这些 Bean 全部装配好。开发者不需要关心 Tomcat 怎么启动、连接池怎么创建,只需要关注业务代码。这也是为什么 SpringBoot 能做“约定优于配置”:大多数场景下零配置就能跑起来,特殊需求才需要显式覆盖。
有人可能会问,为什么不选 Spring Cloud 微服务?这台电动车租赁系统的业务体量,一台 4 核 8G 的服务器就能扛住,微服务带来的服务注册、配置中心、链路追踪等组件反而会拖慢交付节奏。单体应用 + 模块化分包 + 缓存 + 异步任务,是这个体量下最务实的组合。等到用户量和业务复杂度真的上来,再按订单服务、车辆服务、用户服务拆也不迟。技术选型要匹配业务阶段,这是我一直坚持的原则。
前端选 Vue 的理由同样简单:Vue 的渐进式设计让项目可以按需引入特性,核心库只聚焦视图层,配合 Vue Router 做路由、Pinia 做状态管理、Element Plus 做后台界面,组合起来工作效率很高。而且 Vue 的中文社区非常活跃,遇到问题搜解决方案的成本很低。
其他关键选型:
- 数据库:MySQL 8.x,InnoDB 引擎,存订单、车辆、用户等核心业务数据;
- 缓存:Redis,存 Token、验证码、热点车辆状态,后续还可以用来做分布式锁;
- 地图服务:高德地图 Web 端和 JS API,覆盖车辆定位、逆地理编码、路径规划;
- 文件存储:MinIO,用来存储用户头像、车辆照片、故障上报图片;
- 接口风格:RESTful + JSON,前后端完全分离,后端只提供 API。
1.3 系统角色与功能模块划分
系统按使用者分成三类角色:普通用户、运营人员、系统管理员。我前后台各做了一套界面,用的是同一套后端 API,通过 JWT 中的角色信息做权限控制,没有引入 Spring Security 这类重型安全框架,而是用拦截器 + 角色判断,这样对中小项目来说成本更低、更容易维护。
用户端核心功能:
- 注册登录:手机号 + 验证码登录,同时绑定用户基本信息;
- 车辆地图:地图上展示附近可用车辆,支持按电量、距离筛选;
- 扫码租车:扫描车辆二维码,查看车辆信息并发起租车;
- 骑行中:显示实时计费金额、骑行时长、车辆电量、一键上报故障;
- 订单中心:查看历史订单、当前订单状态、费用明细;
- 钱包:押金缴纳与退还、余额充值、优惠券。
运营端核心功能:
- 车辆管理:录入新车辆、编辑车辆信息、设置车辆状态(空闲/故障/充电);
- 订单管理:查看所有订单、处理异常订单、人工改价、关单;
- 计费规则:配置起步价、按时段设置不同单价、设置免费时长;
- 用户管理:查看用户信息、冻结异常账号;
- 数据统计:日订单量、营收统计、车辆利用率。
这套模块划分基本上把常见租赁系统的业务闭环都覆盖到了,后续哪怕要增加功能,也只需要在对应模块里扩展,不需要大改。
2. 后端核心模块设计与实现
2.1 项目结构设计
后端项目我采用了单一 Maven 工程、按业务分包的结构,没有拆多模块。拆分多模块(common、system、rental 等)适合团队协作或代码量特别大的情况,单人或小团队开发时反而增加构建和调试成本。合理做法是包名按业务边界划分,未来真要拆,也能顺着包边界切分。
com.example.rental ├── config # 配置类:WebMvc、Redis、CORS、MinIO ├── controller # 接口层:只做参数接收与结果封装 ├── service # 业务层:核心逻辑与事务管理 ├── mapper # 数据访问层:MyBatis-Plus 的 Mapper 接口 ├── entity # 数据库实体类 ├── dto # 请求参数对象 ├── vo # 响应视图对象 ├── common # 统一返回结构、异常处理、常量、枚举 └── utils # 工具类:JWT、距离计算、坐标转换Controller 层我只做三件事:接收参数、调用 Service、返回统一结果。所有业务判断、事务控制、数据校验都放在 Service 层。Mapper 层只负责 SQL 和简单数据CRUD,复杂查询通过 MyBatis-Plus 的 LambdaQueryWrapper 完成,不手写 XML 的地方尽量不写。这样代码读起来很干净,出了问题也能很快定位是入参问题、业务逻辑问题还是 SQL 问题。
2.2 用户认证与权限控制
认证方案我直接用了 JWT,没有用 Session。前后端分离之后,后端 API 不再面向浏览器页面,而是面向任意客户端,Session 依赖 Cookie 的机制在独立 App 或 H5 场景非常被动。JWT 是把用户身份信息签名后下发给客户端,客户端每次请求把它放在 Authorization 头里,后端无状态验证,服务天然支持横向扩展。
登录流程是这样的:用户输入手机号和验证码,后端校验验证码后用 userId、role 生成一个有效期为 7 天的 Token,Redis 里存一份 Token 与用户状态的映射,用于主动踢人。前端拿到 Token 后存在本地,每次请求通过 Axios 拦截器自动附加。
JWT 工具类核心代码:
public class JwtUtil { private static final SecretKey KEY = Keys.hmacShaKeyFor("your-secret-key-please-change-me".getBytes()); public static String generateToken(Long userId, String role) { return Jwts.builder() .claim("userId", userId) .claim("role", role) .setExpiration(new Date(System.currentTimeMillis() + 7 * 24 * 3600 * 1000)) .signWith(KEY, SignatureAlgorithm.HS256) .compact(); } public static Claims parseToken(String token) { return Jwts.parserBuilder().setSigningKey(KEY).build() .parseClaimsJws(token).getBody(); } }拦截器部分,我自定义了一个 AuthInterceptor,在 preHandle 中从请求头读取 Token,如果解析失败或过期则直接返回 401,同时把 userId 和 role 写入 request attribute,后面的 Controller 方法直接取用:
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 || !token.startsWith("Bearer ")) { response.setStatus(401); return false; } try { Claims claims = JwtUtil.parseToken(token.replace("Bearer ", "")); request.setAttribute("userId", claims.get("userId", Long.class)); request.setAttribute("role", claims.get("role", String.class)); } catch (Exception e) { response.setStatus(401); return false; } return true; } }这里有几个细节要注意:登录和注册接口必须放行;手机号验证码接口要用 Redis 设置过期时间且限制发送频率;每次请求都查库获取用户当前状态,防止被冻结的用户继续操作。JWT 虽然是无状态的,但该做的服务端校验不能省,只信 Token 里的内容、不信数据库,是一个很容易踩的坑。
2.3 租赁核心业务:订单状态机与计费
订单是整个系统的核心,表结构设计之前,我先把订单状态机的所有流转条件列了出来。订单状态我用一个字符串枚举字段 status 表示,取值包括 PENDING_PAY(待支付)、RIDING(骑行中)、PENDING_BILL(待结算)、COMPLETED(已完成)、CANCELLED(已取消)、DISPUTE(争议中)。
核心订单表字段:
CREATE TABLE rent_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT '订单编号', user_id BIGINT NOT NULL, vehicle_id BIGINT NOT NULL, status VARCHAR(20) NOT NULL, start_time DATETIME, end_time DATETIME, total_amount DECIMAL(10,2) DEFAULT 0.00, pay_amount DECIMAL(10,2) DEFAULT 0.00, deposit DECIMAL(10,2) DEFAULT 0.00, create_time DATETIME, update_time DATETIME, KEY idx_user_id (user_id), KEY idx_vehicle_id (vehicle_id), KEY idx_status (status), UNIQUE KEY uk_order_no (order_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;计费逻辑是这个业务里最容易出 bug 的地方。我用的是阶梯计费规则:起步价 3 元,包含前 10 分钟;超过 10 分钟后,每 5 分钟收取 1 元,不足 5 分钟按 5 分钟计算。计费规则不写死在代码里,而是放在数据库配置表里,运营人员可以随时改。
还车时的费用计算代码:
public BigDecimal calculateAmount(RentOrder order) { // 按分钟计算骑行时长,向上取整 long minutes = Duration.between(order.getStartTime(), order.getEndTime()).toMinutes(); if (minutes <= 0) minutes = 1; BillingRule rule = billingRuleService.getCurrentRule(); if (minutes <= rule.getFreeMinutes()) { return rule.getStartFee(); } long extraMinutes = minutes - rule.getFreeMinutes(); // 每 stepMinutes 分钟为一个计费单位,向上取整 long units = (long) Math.ceil((double) extraMinutes / rule.getStepMinutes()); return rule.getStartFee().add(rule.getUnitFee().multiply(BigDecimal.valueOf(units))); }计费金额一律用 BigDecimal,绝不使用 double。double 在金额计算时有精度丢失问题,涉及钱的数据类型无脑选 Decimal(10,2),这条经验在电商、租赁、支付系统都适用。
状态流转的每一条路径都封装成独立方法,比如 startRide、finishRide、cancelOrder、disputeOrder,每个方法里先校验当前状态是否允许迁移,再更新数据库,不允许直接修改 status 字段。这一步是状态机设计的关键,能避免很多隐性逻辑漏洞。
2.4 地图、定位与骑行轨迹上报
地图部分,前端用高德地图 JS API 初始化地图,展示车辆位置;后端负责存车辆经纬度和计算距离。这里要特别注意坐标系问题:高德地图用的是 GCJ-02 坐标系,也就是俗称的“火星坐标系”,如果直接把从高德地图上采集的坐标当作 GPS 坐标处理,再叠加其他数据源,位置偏差会非常明显,轻则几十米,重则几百米。
所以我的设计原则是:所有坐标以后端存储为准,前端不管采集还是展示都用高德的坐标体系,避免自己去做火星坐标转换。后端只需要把车辆表里的经纬度字段设计为 DECIMAL(10,6),比如 120.153600, 30.287500,精度足够支持到米级定位。
骑行轨迹我用了一个独立表 ride_track,每 10 秒记录一次坐标点:
CREATE TABLE ride_track ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, lng DECIMAL(10,6) NOT NULL, lat DECIMAL(10,6) NOT NULL, speed DECIMAL(5,2) DEFAULT 0, report_time DATETIME NOT NULL, KEY idx_order_id (order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;用户骑行结束后,前端把缓存的轨迹点批量上传,后端做轨迹保存和里程计算。如果实时性要求高,还可以引入 WebSocket 或 MQTT 做实时位置推送,但这个项目里 H5+4G 网络环境下 10 秒上报一次足够,没必要增加复杂度。
3. 前端核心页面与交互实现
3.1 前端工程搭建
前端我用 Vue3 + Vite 构建,没用 Vue CLI。Vite 的开发服务器启动速度比 Webpack 快一个量级,原生 ES Module 的方式让热更新几乎是秒级的,开发体验非常舒服。创建项目就直接用官方脚手架:
npm create vite@latest rental-web -- --template vue cd rental-web npm install依赖我额外装了 Vue Router、Pinia、Axios、Element Plus、高德地图 JS API。目录结构按模块划分:
src ├── api # 接口请求模块,按业务拆分 ├── assets # 静态资源 ├── components # 通用组件 ├── layout # 页面布局 ├── router # 路由配置,含守卫 ├── store # Pinia 状态管理 ├── utils # 请求封装、工具函数 └── views # 页面组件Axios 封装是前端开发里最该认真做的一步。我在 utils/request.js 里统一配置了 baseURL、请求超时、请求拦截器附加 Token、响应拦截器统一处理业务码和 401 跳转登录。这样后面所有页面都不用关心 Token 和错误处理,代码量直线下降:
import axios from 'axios' import router from '../router' const request = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) request.interceptors.response.use( response => { const res = response.data if (res.code !== 0) { // 未登录或 Token 过期 if (res.code === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(new Error(res.message)) } return res.data }, error => { // 网络错误提示 return Promise.reject(error) } )3.2 车辆地图找车与扫码租车
地图找车是用户端最核心的页面。初始化高德地图之后,通过后端接口一次性加载附近可用车辆,在地图上渲染 Marker。这里重点说一下大数据量下的优化:地图上挂几百个 Marker 还不卡,但超过上千就会明显掉帧。我用了高德的 MarkerClusterer 聚合插件,把同一片区域的车辆聚合显示成一个数字角标,放大后自动展开。这个功能在站点密集的场景下效果非常明显。
点开车辆 Marker 会弹出详情面板:车辆编号、当前电量、时租价格、距我的距离,点击“立即租车”会跳转扫码页面。扫码租车这里有个细节:扫码本质上是拿到车辆编号,而不是真的去调摄像头做识别。H5 页面里我用 input type="file" capture 调起原生相机,识别二维码后从解析结果里取出 vehicleId,再调用后端接口。这里要注意手机兼容性,部分安卓机使用 capture 属性会直接打开相机,而 iOS 上部分版本会打开相册,需要做降级处理,比如同时提供手动输入车辆编号入口。
3.3 订单流程与实时计费展示
骑行中的页面,我做了三个模块:顶部显示骑行时长和实时费用、中部显示车辆当前行驶信息、底部一个大按钮是“我要还车”。实时费用最初是用 setInterval 每 10 秒向后端拉取一次当前订单状态,虽然简单,但会产生大量无效请求。后来我改成了“本地预估 + 后端确认”的方式:骑行期间在前端按计费规则定时刷新预估费用,还车时再以后端计算为准。这样既保证了用户体验流畅,也避免了频繁请求打满后端接口。
用户点击还车后,前端先上传骑行轨迹,再调用后端 finishRide 接口。后端完成费用计算和订单状态变更,返回费用明细,前端弹出支付确认框。整套流程的状态变化需要和订单状态机严格对齐:待支付状态不能骑行,骑行状态不能重复还车,这些逻辑后端必须兜底。前端只是展示和引导,真正的业务约束一定放在后端。
4. 关键难点与排查实录
4.1 并发下单与车辆状态防冲突
租车场景里最容易出现的问题是:两个用户同时看到同一辆空闲车,几乎同时发起租车申请,结果都成功了。本质上是并发下的“重复消费”问题。解决思路就是在更新车辆状态时加上条件,只有当前状态为“空闲”的车辆才能被改成“骑行中”。
我用的是数据库乐观锁的思路,直接写一个带条件的 update SQL,而不需要额外加乐观锁版本号字段:
@Update("UPDATE vehicle SET status = 1 WHERE id = #{vehicleId} AND status = 0") int lockVehicle(@Param("vehicleId") Long vehicleId);这条 SQL 执行成功后返回影响行数,如果返回 0,说明车辆状态已经不是空闲,要么被别人租走,要么已经被锁定,直接抛出业务异常提示用户“手慢了,车辆已被租走”。这个方法能覆盖绝大多数并发场景,实现成本低,效果可靠。如果要更严格的防重,可以在 Redis 里再加一把分布式锁,两个用户同时进来时只有一个能拿到锁去执行下单逻辑。
4.2 地图坐标偏移与距离计算
做距离排序功能时踩过一个坐标坑:一开始用了两个不同数据源的位置信息,一个来自高德采集,一个是 GPS 原始坐标,导致同一个地方在地图上显示的位置和相关距离对不上。排查后确认是坐标系不统一导致的偏差。
统一方案是:前端所有采集点都使用高德 JS API 拿到的坐标,走高德的坐标系,后端不做转换。计算两辆车的距离时,用 Haversine 公式:
public static double distance(double lat1, double lng1, double lat2, double lng2) { double earthRadius = 6371.0; double dLat = Math.toRadians(lat2 - lat1); double dLng = Math.toRadians(lng2 - lng1); double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) * Math.sin(dLng / 2) * Math.sin(dLng / 2); return earthRadius * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); }Haversine 公式是球面两点间距离计算的标准做法,在几公里范围内的误差完全可以忽略。如果你的业务需要更高精度,可以考虑用高德的路径规划 API 计算真实骑行距离,而不是直线距离,但这会增加接口调用成本和响应时间,我这边做排序和筛选用直线距离就够。
4.3 前后端联调中的跨域与会话问题
前后端分离后第一个碰到的问题就是跨域。开发环境前端跑在 5173 端口,后端跑在 8080 端口,浏览器直接请求必然跨域。开发环境我用 Vite 的 proxy 把 /api 代理到后端,前端代码里只写相对路径:
// vite.config.js server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }生产环境用 Nginx 做反向代理,同样是只代理 /api 前缀到后端服务,这样前端代码里不用写死接口地址,切换环境非常灵活。生产环境的 CORS 配置我也在后端加了兜底,允许特定域名跨域访问,这样即使前端的接口地址直接指向后端也能通。
联调阶段最典型的报错是 401 和 404。401 问题九成是 Token 没传或者过期,用浏览器 DevTools 看 Network 面板就能发现 Authorization 头缺失。404 问题主要是路径不匹配,后端接口是 POST /api/order/start,前端却写成了 POST /api/order/startRide,这类问题需要在后端接口设计阶段就统一规范命名,并生成接口文档,我这边直接用了 Swagger 自动生成文档,前后端对照着看,省了很多沟通成本。
4.4 充电与电量策略
电动车租赁不能忽略电量管理。系统里车辆表有 battery 字段,用户在骑行过程中电量会变化,但真实电量来自硬件 IoT 设备上报,在没有硬件的情况下,我做了模拟上报:后台根据骑行时长和默认功耗曲线,定时把电量扣减到车辆状态里。车辆电量低于 20% 时,自动把车辆状态置为充电中,不再允许新订单;运营人员设置充满后重新置为空闲。
这里有一个业务细节:用户在骑行中电量从 60% 掉到 5%,不该让用户在半路抛锚,但也不能简单地在电量低于 20% 时强制锁停。我的处理是:起租前严格校验电量门槛,骑行中则只提醒不打断,异常低电量时允许用户上报故障并由运营介入,安全细则属于人身安全层面的要求,系统只能通过默认规则减少风险,真正兜底还得靠运营机制。
5. 部署上线与经验总结
5.1 Docker 与 Nginx 部署
部署我用了 Docker Compose 管理 Nginx、后端容器、MySQL、Redis、MinIO 五个服务。后端 Dockerfile 很简单,用多阶段构建把 Maven 打包和运行分开,让镜像更小:
FROM maven:3.9-eclipse-temurin-17 AS build COPY . /app WORKDIR /app RUN mvn clean package -DskipTests FROM eclipse-temurin:17-jre COPY --from=build /app/target/rental-server.jar /app/rental-server.jar ENV TZ=Asia/Shanghai EXPOSE 8080 ENTRYPOINT ["java", "-jar", "/app/rental-server.jar"]docker-compose.yml 里配置了 MySQL、Redis、MinIO 和后端服务,环境变量通过 env_file 加载,数据库密码和密钥都不写进代码仓库。前端用 Nginx 做静态托管和反向代理:
server { listen 80; server_name your-domain.com; root /usr/share/nginx/html; index index.html; location /api/ { proxy_pass http://backend: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; } location / { try_files $uri $uri/ /index.html; } }Nginx 配置里最重要的一行是 try_files,它把前端路由的所有路径都回退到 index.html,否则刷新页面时就会出现 404。这个坑特别经典,前后端分离部署时十个人至少有八个会碰到。
5.2 个人踩坑与改进方向
整个项目做完后,回顾踩过的坑,最值得写下来的有这几个:
第一,订单号生成不要用自增 ID,也不要直接用时间戳。自增 ID 容易暴露业务量,时间戳在并发下可能重复。我用的是“前缀 + 日期 + 随机串”方式,例如 RC20240520153012 + 4 位随机数,虽然简单,但完全满足订单号唯一性要求。
第二,关键的查询字段一定要加索引。订单表 user_id、vehicle_id、status,车辆表 station_id、status,这些都是高频查询条件。最开始开发阶段数据量小没感觉,测试数据一多,慢查询马上出现了,加上索引后速度立刻上去。
第三,缓存一致性要重视。车辆状态是热点数据,我刚开始用 Redis 缓存车辆状态,结果出现后台修改车辆为故障,前端地图上车辆仍是空闲的情况。后来简化处理:车辆状态永远以数据库为准,Redis 只缓存车辆列表的查询结果并设置 30 秒过期。对一致性要求高的数据不要轻易上缓存,尤其是强一致场景,宁可慢一点也不要脏数据。
第四,超时订单的自动处理。用户下单后一直不支付,车辆就一直被占用。我加了一个定时任务,每 5 分钟扫描一次超过 15 分钟未支付的订单,把车辆状态重置为空闲、订单置为已取消。定时任务本来想用 xxl-job,但为了控制依赖数量,直接用 Spring 自带的 @Scheduled 就满足了需求。
整个项目做完,我最深的体会是:这种业务系统的复杂度不在某个单独的技术点上,而在于状态流转、数据一致性、前后端协作这些“看不见的地方”。正因为如此,它才特别适合用来提升全栈能力。如果后续要在这个基础上继续做扩展,我会优先考虑增加微信小程序端,用 uni-app 复用现有 Vue3 代码;再把骑行轨迹改成 WebSocket 实时推送,接入地图路径规划获得更准确的里程计费;车辆硬件部分接入真实 IoT 上报,把模拟电量替换成真实数据。每一个方向都有明确的业务价值,这种渐进式的迭代方式,也是我在个人项目里最推荐的做法。