基于Vue3与Node.js微服务的校内电动车租赁系统实践总结

📅 发布时间:2026/9/19 18:23:00
基于Vue3与Node.js微服务的校内电动车租赁系统实践总结
大学校园动辄两三平方公里从宿舍楼到工训楼步行二十多分钟是常事。校园里电动车数量逐年增加可私拉电线充电、乱停乱放的问题也跟着来比起校外共享单车校内定点电动车租赁在管理上要干净得多固定站点取还、统一充电、按时计费。这篇文章是我完成这套 vue3 nodejs 微服务架构的校内电动车租赁系统的全过程总结包含服务拆分逻辑、数据建模、订单主链路、前端 Vue3 状态管理和微服务数据一致性等内容。正在做毕设或课程设计的读者可以直接参考想了解 Node.js 微服务在小规模真实业务里怎么落地的同学也能从中找到不少实际经验。1. 服务拆分决策这不是标题党是真实需要先说个背景。项目刚起步时我最初按一个 Express 应用 前端页面 MySQL 数据库的标准毕设架构来搭开发到订单和支付模块时明显感觉不对劲每个模块都在操作同一批表状态流转耦合在一起。后来产品里加了一条分时段计价规则我只想改计费逻辑结果影响到了车辆管理的接口一个改动要牵动多个文件。这时我才下决心做服务拆分。1.1 五个服务边界是怎么定出来的微服务拆分不能凭感觉拍脑袋。我当时只按三个标准来判断业务域是否独立、数据是否归自己管、变更频率是否不同。按照这个标准最终拆出了五个服务服务名负责内容主要数据api-gateway路由转发、JWT 鉴权、限流、统一响应无纯代理user-service注册、登录、角色管理、个人信息用户表、角色表vehicle-service车辆信息、站点管理、故障上报车辆表、站点表order-service订单生命周期、计费规则、状态机订单表、计费规则表payment-service余额、押金、租金账单、扣款账户表、流水表notification-service站内信、短信提醒、消息推送消息记录表这个划分里最核心的原则是订单服务不直接改车辆表车辆服务不知道订单金额怎么算。各服务之间只通过接口或事件交互。你可以把它理解成餐厅运作前台点单、后厨出菜、收银结账各管各的谁也不用去翻别人的台账。1.2 哪些功能我坚持留在单体侧微服务不等于把所有功能都拆出去。我在这个项目里刻意把三类东西留在了微服务之外的公共服务里文件上传主要是用户头像和故障照片、定时任务超时订单扫描、每日账单汇总、二维码生成工具。原因很简单这些功能没有独立的数据归属或者只是被多个服务调用的工具函数硬拆只会增加调试成本和部署节点。一个两三千行的小系统如果拆出十个微服务那不是在展示架构能力是在给自己上刑。1.3 微服务在这个体量下的真实收益和代价我必须诚实地讲这个项目如果只做给一个学校用用户量撑死几千人微服务带来的性能收益几乎可以忽略。它真正的收益在于开发和维护的秩序感——订单状态机被锁在 order-service 里后面改计费规则时我没有再牵一发动全身fault 上报和车辆状态管理在 vehicle-service 里独立演进了两版都没影响到前端和订单。代价也非常真实联调时要在五六个服务之间来回切日志排查一个还车后没收到扣款通知的问题得同时看 order-service 和 payment-service 两边的日志。所以我的建议是做毕设或小型项目时不要把微服务理解成多个 Node 进程跑起来就完事而是要画出服务依赖图明确每个服务的入口和出口否则后期排错成本会指数上升。2. 数据库与状态机设计撑起整个租赁流程的底子微服务化的一个副作用是数据被分散到不同的库里但为了开发调试方便我这个项目没有做物理分库而是用一个 MySQL 实例、多个逻辑库来模拟。每个服务连接各自的库从代码层面杜绝跨服务直接操作表。这样既保留了微服务的边界纪律又不需要搭分布式数据库环境对中小型项目非常实用。2.1 用户、角色与权限的数据模型用户表是整个系统的地基核心字段包括id、学号/工号、姓名、密码哈希、角色、信用分、账户余额、创建时间。密码必须用 bcrypt 加盐哈希不能明文存储这个没有任何商量余地。角色我设计成单独字段而不是单独的表因为角色只有学生、管理员、维修员三种用整数枚举反而更直观。权限控制在网关层统一做采用 RBAC 思路登录后 JWT 里携带用户 ID 和角色网关拦截器根据路径前缀判断该角色是否有权访问。例如/api/admin/**开头的路由只有 admin 角色能过普通学生访问直接 403。2.2 车辆与站点如何描述一辆可租的车车辆表是整个系统里被操作最频繁的表字段设计要特别注意。我的车辆表核心字段是id、车身二维码编号、站点 ID、状态、电量百分比、当前坐标、版本号。状态分为四种空闲、骑行中、维修中、已下架。其中版本号这个字段是为了后面做乐观锁预留的它是开单和还车时防止并发冲突的关键后面专节讲。站点表则相对简单站点名称、经纬度、容量、当前停放数量。还车时通过对比站点容量来判断能不能停超出容量时前端要做出提示避免用户骑到满员的站点才发现还不了车。2.3 订单状态机与计费快照订单表是整个系统的主动脉字段包括订单号、用户 ID、车辆 ID、状态、开始时间、结束时间、起始站点、结束站点、金额、计费规则版本号。订单状态我设计成六种状态枚举含义可流转到1 待取车已锁车但未开始骑行骑行中、已取消2 骑行中车辆被使用待支付、超时3 待支付已还车金额已结算已完成4 已完成账单已付终态5 已取消用户主动取消或超时未取终态6 超时超过最大骑行时长强制结束待支付金额结算时不能直接查当前最新的价格规则必须把当时的规则版本号记到订单表里。否则管理员后面改了价格所有历史订单重算一遍财务对账直接崩溃。这就是规则版本号存在的意义也是刚开始做系统的人最容易漏掉的设计。3. 核心链路实现扫码租车、还车落锁、分段计费整个系统最关键的业务链路就是开单、骑行、还车、计费这条线。前面建了那么多表和状态最终都要在这条链路上跑通。我挑三个最容易出问题的环节详细讲讲每一个都是我在实际开发中踩过坑之后才想明白的。3.1 扫码开单网关路由、幂等与库存扣减用户在小程序或 H5 页面点扫一扫识别车身二维码里的车辆编号然后前端请求/api/order/start请求先打到 api-gateway网关校验 JWT 通过后把用户 ID 放进请求头再转发给 order-service。order-service 收到请求后做三件事查车辆是否存在且状态为空闲把车辆状态从空闲改成骑行中创建订单。这三件事必须在同一个事务里完成而且扣减车辆状态时要用乐观锁而不是先查再更新。核心 SQL 是UPDATE vehicle SET status 1, version version 1 WHERE id ? AND status 0;执行这条语句后如果影响行数为 0说明车辆已经被别人骑走直接返回车辆已被使用。这种方式比先 SELECT 再 UPDATE安全得多因为它把判断和更新压缩在了一条语句里没有时间窗口。开单接口还要考虑幂等。用户扫了码之后如果网络抖动导致前端重试服务端会重复创建两个订单。我的解决办法是在订单表给 user_id 订单状态加处理逻辑同一个人在短时间内如果有未完成的骑行中订单直接返回已有订单号不再新建。用 Redis 存储最近三秒内已开单的用户 ID来做一个轻量幂等键效果也很好。3.2 还车与并发冲突乐观锁和中间状态缺一不可还车链路比开单要复杂得多因为它同时涉及订单服务、车辆服务和支付服务。用户到达站点、点击还车order-service 要计算费用payment-service 要扣款vehicle-service 要更新车辆状态和站点容量。如果直接一步到位把订单改成已完成一旦扣款失败就会出现车还了但钱没扣的尴尬局面。我的做法是引入一个结算中的中间状态。订单从骑行中改成待支付车辆状态从骑行中改成空闲站点当前数量加一这三件事在 order-service 和 vehicle-service 各自的事务里完成并通过消息队列通知 payment-service 去扣除租金。扣款成功后再把订单状态从待支付改成已完成。这样即使扣款服务挂了订单还停留在待支付用户下次登录时会收到你有待支付账单的提醒不丢单也不乱账。还车时同样要防并发用户不能同时对同一辆车点两次还车。我在 order-service 里判断订单状态必须是骑行中然后才执行更新同样使用乐观锁式的条件更新UPDATE orders SET status 3, end_time NOW() WHERE id ? AND status 2;如果影响行数为 0说明订单已经不是骑行中状态直接拒绝处理。这是一个非常经典的状态机防护写法在几乎所有订单类业务里都通用。3.3 计费引擎分钟与峰谷怎么混算计费规则我设计成一张独立的规则表支持分时段单价配置。基础规则是前 30 分钟 2 元超出部分每 15 分钟 1 元单日封顶 30 元如果配置了高峰时段比如上课前后的 7:30-8:30 和 17:30-18:30则高峰时段单价上浮 50%。结算时根据订单的开始时间和结束时间计算分钟数然后逐段累加金额。核心计费函数长这样function calcAmount(startTime, endTime, rules) { let total 0; const minutes Math.ceil((endTime - startTime) / 60000); if (minutes 30) return rules.basePrice; total rules.basePrice; const extraMinutes minutes - 30; // 超出部分按每15分钟计费向上取整到计价单位 const periods Math.ceil(extraMinutes / 15); total periods * rules.unitPrice; return Math.min(total, rules.dailyCap); }真实场景中计算还涉及跨时段一次骑行跨越了普通时段和高峰时段需要按每个时间段的重合分钟数分别计价。这个逻辑并不复杂但一定要写单元测试尤其是边界时间点比如正好 29 分 59 秒、30 分整、超时前一秒我在这些边界上抓到过至少三个隐藏 bug。3.4 超时未还车的兜底策略总会有用户忘了还车或者故意不还。我的策略是设置最大骑行时长 3 小时由定时任务每分钟扫描一次骑行中订单如果超过 3 小时先把订单标记为超时同时调用 notification-service 给用户发送提醒超过 4 小时还没还系统强制结算并按照全部时段按高峰价计算的规则生成账单并扣减用户信用分。这个策略在真实运营中大大减少了车辆被长时间占用的问题用户一次被扣信用分第二次就会记得主动还车了。4. Vue3 前端落地组合式API、Pinia与动态权限前端是用户每天直接面对的部分我的目标是让它稳定、好用、开发效率高。技术栈选择了 Vue3 Vite Pinia Vue Router Element Plus Axios没有引入太重的基础框架保持足够的可控性。4.1 前端工程与目录组织Vite 创建项目的速度比 Webpack 快一个量级开发体验非常好。目录结构我按业务模块划分而不是按文件类型划分这样每个功能涉及的文件都在一起src/ api/ # 接口请求层按服务拆文件 auth.js vehicle.js order.js payment.js stores/ # Pinia 状态 user.js router/ # 路由配置与守卫 views/ user/ # 用户端页面 admin/ # 管理端页面 components/ utils/ request.js # axios 实例封装这个分层的好处是前端代码结构和后端微服务一一对应一个前端同学和一个后端同学结对开发时接口位置、状态管理位置都能很快对上沟通成本低很多。4.2 登录会话与请求层封装Pinia 的 user store 里保存 token、用户信息、角色。刷新页面后从 localStorage 恢复 token再调用获取用户信息接口拉取最新资料。Axios 实例拦截器里自动附加 Authorization 请求头响应拦截器里遇到 401 时清空 store 并跳转登录页。这里有个容易被忽视的细节token 过期后如果用户正在扫码头页填写内容突然被踢到登录页体验很糟糕。我封装了一个登录弹窗而不是跳转探测到 401 时如果当前路由是需要登录的页面弹出登录框并记录用户原路径登录成功后自动定位到之前想做的操作。这个小功能让实际使用反馈好了很多。4.3 地图、扫码与订单页的实现思路用户端首页是地图视图展示站点分布和车辆状态。实现上用的是地图 SDK 的标记点功能后端 vehicle-service 提供全量站点车辆状态的聚合接口前端每 30 秒轮询一次标记点颜色根据车辆空闲数量动态变化。地图 SDK 在 Vue3 里的集成没有专门的插件但通过一个简单的组合式函数封装加载逻辑就能让地图实例在多个组件间复用。扫码开单我用的是浏览器相机扫码库用户授权摄像头后识别二维码返回车辆编号后调用开单接口。订单页需要在骑行过程中显示实时时长这里我用了定时器每秒更新显示注意组件卸载时一定要清除定时器否则用户切到其他页面后定时器还在跑容易造成内存泄漏。5. 服务间通信与最终一致性微服务最容易翻车的地方服务拆分完之后最现实的问题就是服务之间怎么说话。如果全是同步 HTTP 调用一个链路上任何一个服务慢一点整个请求就被拖死如果全走异步消息开发调试又很麻烦。我的选择标准很简单查询走同步状态变更走异步。5.1 同步HTTP还是异步消息我的选择标准开单前查车辆电量够不够这种读操作走同步 HTTPorder-service 直接调用 vehicle-service 的接口拿到结果立刻返回逻辑清楚。但还车后通知扣款超时后发提醒这类状态变更走异步消息更好上游不用等下游处理完下游挂了也不会阻塞主流程。消息中间件我用了 RabbitMQ原因是它对 Node.js 的支持成熟语义清晰。每个服务建立独立的队列消费失败的消息进入死信队列方便人工排查。如果你不想引入 RabbitMQ 的重量级依赖也可以先用 Redis Stream 顶一版核心思想是一样的订单服务发布事件支付服务订阅事件。5.2 JWT在网关与服务之间的信任传递微服务架构下JWT 校验必须收敛到网关做不能让每个服务都去解析一遍 token。否则一旦 jwt 密钥泄漏或者算法升级要改的地方多得可怕。网关的拦截逻辑是接收到请求后先放行登录和注册接口其他接口统一校验 Authorization 头。校验通过后把解析出的用户 ID 和角色塞进请求头例如x-user-id: 1001然后转发给下游服务。业务服务不解析 token只信任网关注入的这个请求头。开发时如果本地直连业务服务调试还需要配置额外的内部调用密钥防止伪造请求头。5.3 还车结算场景下的本地消息表实践同步调用改异步之后新的问题来了怎么保证订单状态更新和消息发出这两个动作同时成功如果先更新订单再发消息消息发送失败扣款永远不触发如果先发消息再更新订单消费端收到了消息但订单状态还是骑行中就会算出错误金额。我采用的方案是本地消息表。在 order-service 的数据库里建一张 outbox 事件表还车结算时更新订单状态和插入一条事件记录在同一个事务里完成。事务提交后后台 worker 扫描这张表把未发送的事件投递到 RabbitMQ投递成功就标记为已发送。故障场景恢复方式消息投递失败worker 重试超过最大次数进入死信队列人工处理消费端扣款失败消息幂等消费 人工账单补齐服务重启未发送消息从 outbox 表自动恢复投递这个方案不复杂但非常稳。它牺牲了一点实时性换来了数据一致性的大幅提升。6. 从环境配置到压测一些实测中必须讲的坑最后这部分是比较容易被忽略、但实际开发中耗费我最多时间的部分。Node.js 微服务项目本身不算难写麻烦全在环境和联调细节上。6.1 Node、MySQL、Redis 的配合细节Node.js 版本一定要选 LTS不要追最新版很多依赖在最新版上会有原生模块编译问题。MySQL 用的是 8.x注意它的默认认证插件是 caching_sha2_password早期版本的 mysql2 驱动不兼容最好在安装时就指定 mysql_native_password或者把驱动升级到最新。Node 服务器和数据库服务器之间的时区问题也容易踩连接串里最好明确指定timezone: 08:00否则订单的开始时间和结束时间会差 8 小时计费直接错。Redis 在这个项目里的作用很集中存登录验证码、做接口幂等键、存车辆热点数据缓存。Redis 客户端要启用连接池配置否则高并发下会报连接数不够这是压测阶段才暴露出来的问题。6.2 联调阶段最隐蔽的跨域与代理问题前后端分离开发时跨域是最常遇到的拦路虎。我推荐的做法是网关层统一开启 CORS允许的 Header 里必须带上 Authorization 和 Content-Type。Vite 开发环境下再用代理把/api转发到网关地址配置很简单// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://localhost:3000, changeOrigin: true } } } })这个配置有个容易踩的坑如果前端项目里同时用了 WebSocket比如后面要做订单状态实时推送必须在 proxy 里显式声明ws: true否则 WebSocket 连接会被代理吞掉。我在项目中一开始没有加这个配置导致 HMR 热更新时断时连排查了很久才醒悟是代理配置的问题。6.3 并发压测结果与优化点系统开发完成之后我用压测工具对开单接口做了简单的 50 并发测试结果发现一个明显的瓶颈没有加 Redis 缓存时开单接口平均响应时间 800ms原因是车辆状态查询每次都打数据库MySQL 连接池被打满后请求开始排队。优化方案是给车辆状态加了 Redis 缓存车辆空闲时直接读缓存只有开单和还车时才更新缓存和数据库。优化后平均响应时间降到了 200ms 以内50 并发下没有出现接口超时。这个结果对一个校园规模的系统已经完全够用了。我还顺手做了订单号生成方案不依赖数据库自增而是用 Redis INCR 配合日期前缀生成避免从订单号上泄露平台实际单量。结尾的几句真心话做完整套系统我最大的感受是微服务架构最有价值的部分不是分布式高并发这些标签而是它逼着你在写第一行代码前想清楚业务边界。订单和车辆的边界、计费和支付的边界想清楚之后再开发后面每一步都会很顺。Vue3 和 Node.js 这套组合在中等体量业务里配合得足够舒服一个组合式函数、一个 axios 实例就能把前后端串得明明白白。如果再扩展我会优先给系统加 WebSocket 订单状态推送让用户不用手动刷新页面就能看到还车结算结果。希望这篇总结能帮你少踩几个我在深夜排查过的坑。