前阵子帮一个做本地出行的客户从零搭了一套同城顺风车拼车约车系统,技术栈就是标题里说的那套:后端Java(Spring Boot),前端Vue。项目从需求对接到上线跑了两个多月,中间踩了不少坑,也沉淀了一些比较实用的设计和实现方案。今天把这套系统的架构思路、核心流程、数据库设计以及开发过程中绕不过去的坑都整理出来,给准备做类似出行类项目的朋友一个参考。
先说清楚这套系统到底是干什么的:用户发布从A点到B点的出行需求,系统根据起终点、出发时间、人数等条件,把合适的乘客和顺路的司机(或车主)匹配到一起,完成拼车、约车、叫车整个流程。它区别于滴滴这种专业网约车平台,更偏向“顺路带人”和“预约拼车”的场景,所以业务模型里“行程匹配”和“路线相似度计算”是最核心的部分。
如果你正准备做类似的项目,或者正在做Java和Vue的前后端分离开发,这篇文章的内容应该是可以直接拿来用的,尤其数据库设计和订单状态机那部分,我写得比较细。项目整体属于中等偏上复杂度,适合有一定Java和Vue基础、想挑战完整业务闭环的开发者。
1. 项目全貌与需求拆解
1.1 系统定位与解决痛点
同城顺风车拼车系统,解决的痛点其实很明确:城市里私家车空驶率太高,而公交地铁又覆盖不了“门到门”的需求。顺风车拼车就是把这部分闲置座位利用起来,乘客花比快车便宜的钱,车主分担一点油费,平台抽一点服务费,三方都不亏。
这个定位决定了系统不能照着滴滴的模型去做。传统网约车是“平台派单—司机抢单—实时接送”的模式,顺风车则是“车主发布行程—乘客搜索匹配—双方双向选择”的模型。车主有明确的出发地和目的地、出发时间,乘客在平台上搜索有没有符合自己时间路线的车主,有就申请搭乘,车主同意后拼车成功。
用开发的话来说,这个系统的核心不是“派单算法”,而是“路线匹配”,这是需求和实现方案之间的关键转换点。很多新手做顺风车系统,一上来就想着做实时派单、做高并发抢单,方向就偏了。
1.2 核心功能模块划分
我把整个系统按角色和业务链路拆成几个大模块:
| 模块 | 核心功能 | 涉及角色 |
|---|---|---|
| 用户中心 | 注册登录、实名认证、车辆认证 | 乘客、车主 |
| 行程管理 | 发布行程、搜索行程、路线匹配 | 车主、乘客 |
| 订单中心 | 生成订单、状态流转、取消/退款 | 乘客、车主 |
| 支付结算 | 下单支付、平台抽成、车主提现 | 乘客、车主、平台 |
| 消息通知 | 订单状态推送、IM聊天、短信提醒 | 乘客、车主 |
| 后台管理 | 用户审核、订单监管、数据统计 | 平台运营 |
这些模块单独拎出来都不复杂,难的是把它们串成一个闭环:乘客下单之后,支付流程怎么走?订单状态怎么流转?车主到达后怎么确认?如果乘客放鸽子了,钱怎么退?这里面每一步都涉及前后端的联动和实时通信,不是简单的CRUD能搞定的。
1.3 技术选型背后的考虑
技术栈看似是标配,但每个组件的选型背后都有实际业务考量,不是“因为流行所以用”。
后端选Java + Spring Boot,主要看中三点:生态成熟、招人容易、对支付这类涉及资金的业务有足够稳定的框架支持。Spring Boot的starter机制让项目起步很快,Spring Security做认证授权、MyBatis-Plus操作数据库、Redis做缓存和分布式锁,都是成熟方案,遇到问题网上一搜一大片解决方案。
前端选Vue,是因为这个项目有大量的动态交互场景:地图选点、行程状态实时更新、消息推送提醒,Vue的响应式数据绑定和组件化开发在这类场景下开发效率很高。考虑到移动端适配,我选择了Vant组件库配合Vue 3使用,这样H5页面在手机上体验接近原生App,同时又能嵌入到微信或者独立App的WebView里。热搜词里有人搜“Android iOS WebView加载本地Vue打包项目”,说明很多团队确实考虑用WebView套壳的方案,这个后面在打包部署章节我会细说。
数据库方面用MySQL,Redis主要承担定位缓存、行程搜索缓存和订单状态分布式锁,WebSocket做司机乘客之间的实时位置推送。这套组合在中小规模出行项目里是够用的,等体量上来再引入消息队列和分库分表也不迟。
2. 数据库设计:顺风车系统怎么建模才靠谱
2.1 核心表结构与E-R关系
数据库设计是出行类系统里最见功力的地方。字段设计不合理,后面写业务逻辑的时候会各种别扭。我总结下来,顺风车系统核心就五张主表:用户表、车辆表、行程表、订单表、支付流水表,外加一些辅助表。
用户表里除了基础信息,我特意加了user_type字段用来区分乘客和车主身份,因为一个人可能既是乘客又是车主,不能让他注册两个账号。考虑到顺风车平台后期要做身份审核,还加了real_name_status和driver_license_status两个审核状态字段。
行程表和订单表是最核心的两张表,我详细说一下字段设计。
行程表(trip)记录的是车主的出行计划:
CREATE TABLE `trip` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` bigint(20) NOT NULL COMMENT '车主用户ID', `origin_lng` decimal(10,6) NOT NULL COMMENT '起点经度', `origin_lat` decimal(10,6) NOT NULL COMMENT '起点纬度', `origin_address` varchar(255) NOT NULL COMMENT '起点文字地址', `dest_lng` decimal(10,6) NOT NULL COMMENT '终点经度', `dest_lat` decimal(10,6) NOT NULL COMMENT '终点纬度', `dest_address` varchar(255) NOT NULL COMMENT '终点文字地址', `departure_time` datetime NOT NULL COMMENT '出发时间', `total_seats` tinyint(4) NOT NULL DEFAULT '3' COMMENT '可拼座位数', `remaining_seats` tinyint(4) NOT NULL DEFAULT '3' COMMENT '剩余座位数', `price` decimal(10,2) NOT NULL COMMENT '每人分摊费用', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '行程状态:0待出发 1已满员 2已完成 3已取消', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_departure_time` (`departure_time`), KEY `idx_origin_lng_lat` (`origin_lng`, `origin_lat`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有个字段设计的细节:remaining_seats(剩余座位数)为什么不通过已下单人数实时计算,而是单独存一个字段?因为拼车订单有确认和取消的过程,乘客下单了但车主没确认,这个座位其实还是“锁定但未占用”的状态。单独的字段配合上面的订单状态,可以在业务层更灵活地控制座位释放逻辑,不用每次都去count订单表。当然这个字段需要加乐观锁来保证并发下的数据一致性,后面在订单流程里会细说。
订单表(orders)记录乘客和行程之间的关系:
CREATE TABLE `orders` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '订单号', `trip_id` bigint(20) NOT NULL COMMENT '行程ID', `passenger_id` bigint(20) NOT NULL COMMENT '乘客用户ID', `driver_id` bigint(20) NOT NULL COMMENT '车主用户ID', `seats` tinyint(4) NOT NULL DEFAULT '1' COMMENT '预订座位数', `total_amount` decimal(10,2) NOT NULL COMMENT '订单金额', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '订单状态', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `pay_time` datetime DEFAULT NULL, `start_time` datetime DEFAULT NULL, `end_time` datetime DEFAULT NULL, `cancel_time` datetime DEFAULT NULL, `cancel_reason` varchar(255) DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_passenger_id` (`passenger_id`), KEY `idx_trip_id` (`trip_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;订单状态字段status是整个系统状态流转的核心,我专门设计了订单状态机来管理,不能随便乱写,否则容易出现两个用户同时申请同一个行程、座位数超卖这类并发问题。
2.2 订单状态机的设计思路
订单状态我用了整型字段存储,通过常量类统一管理,不直接存字符串,方便后续扩展和数据库索引优化:
public class OrderStatus { public static final int PENDING_PAY = 0; // 待支付 public static final int PENDING_CONFIRM = 1; // 已支付待车主确认 public static final int CONFIRMED = 2; // 车主已确认 public static final int TRAVELING = 3; // 行程中 public static final int COMPLETED = 4; // 已完成 public static final int CANCELLED = 5; // 已取消 public static final int REFUNDING = 6; // 退款中 public static final int REFUNDED = 7; // 已退款 }状态流是:乘客下单生成订单,状态是待支付(PENDING_PAY),支付成功后变成待车主确认(PENDING_CONFIRM),车主在App里确认接单后变成已确认(CONFIRMED),乘客上车后变成行程中(TRAVELING),到达目的地双方确认后变成已完成(COMPLETED)。取消操作不是随便什么时候都能做的:待支付状态下乘客可以随便取消,订单直接取消;已确认之后要取消,就得看距离出发时间了,太临近出发时间取消需要扣一部分违约金,这个规则是在业务层做的判断。
这里有一个踩坑点:订单状态不能让客户端随便传,所有状态变更都必须走后端接口,由后端校验当前状态是否允许流转到目标状态。我在后端写了一个状态机的校验方法:
private static final Map<Integer, List<Integer>> STATUS_TRANSITIONS = new HashMap<>(); static { STATUS_TRANSITIONS.put(OrderStatus.PENDING_PAY, Arrays.asList(OrderStatus.PENDING_CONFIRM, OrderStatus.CANCELLED)); STATUS_TRANSITIONS.put(OrderStatus.PENDING_CONFIRM, Arrays.asList(OrderStatus.CONFIRMED, OrderStatus.CANCELLED)); STATUS_TRANSITIONS.put(OrderStatus.CONFIRMED, Arrays.asList(OrderStatus.TRAVELING, OrderStatus.CANCELLED)); STATUS_TRANSITIONS.put(OrderStatus.TRAVELING, Arrays.asList(OrderStatus.COMPLETED)); } public static boolean canTransition(int from, int to) { return STATUS_TRANSITIONS.getOrDefault(from, Collections.emptyList()).contains(to); }这样配置的好处是,接口层拿到的每个状态变更请求,都要先过这个方法,不合法直接抛业务异常。后续要加新状态,改这个Map就行,不用动业务流程代码。
2.3 Redis在数据读写中的核心价值
这套系统里Redis不只是用来做缓存的,它在三个场景里起了决定性作用。
第一个是行程搜索。同城行程搜索的核心是“根据起终点经纬度找附近符合条件的行程”,如果每次都去MySQL里做复杂的空间范围查询,数据库压力很大。我的方案是:车主的行程发布后,把行程的关键信息(行程ID、起终点坐标、出发时间、剩余座位)同步到Redis里,用Geo数据结构存储起点坐标,搜索的时候基于起点坐标的地理半径查询就是毫秒级响应。
redisTemplate.opsForGeo().add("trip:geo:origin", new Point(originLng, originLat), tripId.toString());搜索的时候先用Geo的radius方法圈出附近N公里内的行程ID列表,再根据出发时间、终点相近程度做二次过滤。实测下来,就算Redis里存了几万条活跃行程,一次搜索也就几十毫秒,比直接查数据库快了一个数量级。
第二个是防止座位超卖。多个乘客同时申请同一个行程的最后两个座位,如果不做并发控制,很容易把remaining_seats减成负数。我用Redis的分布式锁来解决这个问题,锁的key就是tripId,获取到锁的线程才允许操作座位数:
String lockKey = "lock:trip:seats:" + tripId; boolean locked = false; try { locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", Duration.ofSeconds(5)); if (!locked) { throw new BizException("系统繁忙,请稍后重试"); } // 查询剩余座位、校验、扣减座位、创建订单的逻辑 } finally { if (locked) { redisTemplate.delete(lockKey); } }第三个是乘客和司机的位置共享。司机开始行程后,每隔几秒上报一次GPS坐标,这个高频写入操作直接写MySQL会把数据库搞崩,我的方案是先把坐标写入Redis的Geo结构,行程结束后再批量回写到数据库。热搜词里有人问“Java使用RedisTemplate的increment()报错不是integer or out of range”,这大概率是key对应的value本身已经被其他操作覆盖成非数字类型了。我在做行程计数的时候遇到过,排查了半天最后发现是测试环境里同一个key被别的模块写了字符串值,解决办法就是在代码里加上类型检查,同时key的命名加上业务前缀,避免跨模块冲突。
3. 后端核心业务:Java如何实现完整的拼车闭环
3.1 从发布行程到生成订单的完整链路
后端最核心的一条业务链路是:车主发布行程、乘客搜索行程、乘客下单、车主确认、乘客上车、到达确认、支付结算。
先说发布行程。车主选择起点终点(地图选点),选择出发时间,填写可拼座位数和每人分摊费用,后端把这些信息落到trip表,同时同步到Redis的Geo结构。这里有个细节:出发时间的校验必须放在后端,不能只靠前端校验。我遇到过有人直接调接口把出发时间改成过去的时间,如果不校验,后面所有的定时任务都会出问题。
乘客搜索行程的接口设计比较讲究,参数包含起点经纬度、终点经纬度、出发时间范围、排序规则。搜索逻辑有两层:先用Redis Geo按起点坐标圈出半径N公里内的行程,再在内存里用Haversine公式计算这些行程的终点与乘客目的地的实际距离,按距离排序。候选行程还会关联查出车主信息、车辆信息、评价数据,拼装成前端需要的列表结构。
乘客选定行程后点击“申请拼车”按钮,后端执行上面说的分布式锁逻辑:锁住行程的座位数,校验剩余座位是否足够,创建订单,然后异步通知车主有新订单待确认。订单初始状态是待支付,这里有个业务选择:乘客先支付还是车主先确认?我最终的设计是先支付、后确认,因为如果不先支付,用户随便下单占着座位,车主确认了乘客又跑单,整个平台的成单率会非常低。先支付可以过滤掉大部分无效订单,乘客支付后如果车主不确认,平台自动全额退款。
车主确认订单的接口要判断行程状态:行程还没出发且剩余座位够,才能确认。确认之后订单状态从待车主确认变成已确认,座位数正式扣减。这时候给乘客推送一条“车主已确认,请按时到达上车点”的站内信和短信通知。
到了出发时间,乘客上车后车主在App上点“开始行程”,订单状态变成行程中,系统开始记录车辆实时轨迹。到达目的地后,乘客或者车主任一方点击“确认到达”,订单状态变成已完成,之前乘客支付的钱从平台的中间账户结算给车主,平台按比例抽取服务费。
这条链路里最容易出Bug的是状态流转和并发冲突,比如乘客取消了订单但车主恰好同时点了确认。解决办法是在所有关键操作上利用Redis锁保证串行化,同时状态更新用乐观锁:
int rows = orderMapper.updateStatusIfCurrentStatus( orderId, OrderStatus.PENDING_CONFIRM, OrderStatus.CONFIRMED, expectedVersion); if (rows == 0) { throw new BizException("订单状态已变化,请刷新后重试"); }3.2 路线匹配的核心算法思路
顺风车最关键的匹配逻辑不是“谁离得近”,而是“路线顺不顺路”。同样是从市中心出发,一个去东边一个去西边,起点再近也没用。
我的匹配策略分三步:第一步用起点Geo圈出地理位置接近的行程;第二步计算行程终点和搜索终点之间的距离,设定一个阈值(比如3公里),超过的直接淘汰;第三步用出发时间做过滤,前后误差超过30分钟的也淘汰。筛选出来的行程按“路线相似度”倒序排列。
路线相似度的计算不能用简单的直线距离,因为城市道路是网状的,直线距离近不代表真正顺路。我的做法是用腾讯地图或高德的路线规划API,分别计算车主的计划路线(从起点到终点)以及“先到乘客上车点、再到乘客下车点、最后到车主终点”的绕行路线,两条路线的实际行驶距离曲线比就是路线顺路率。
public double calculateDetourRate(Trip trip, double pLng, double pLat, double dLng, double dLat) { // 调用地图API获取车主原始路线距离 double originalDistance = getDrivingDistance( trip.getOriginLng(), trip.getOriginLat(), trip.getDestLng(), trip.getDestLat()); // 调用地图API获取绕行路线距离 double detourDistance = getDrivingDistance( trip.getOriginLng(), trip.getOriginLat(), pLng, pLat, dLng, dLat, trip.getDestLng(), trip.getDestLat()); return detourDistance / originalDistance; }绕行率低于1.3的过滤掉,再按绕行率从小到大的顺序排列给乘客展示。这里调用地图API要注意频控,我加了本地缓存,同一个起终点组合24小时内不重复请求地图API。为什么这个方案可行?因为顺风车本身就是“低频、计划性”的出行,用户愿意为了便宜多等几分钟、多走一小段路,只要别太离谱就行。
3.3 支付与结算的设计细节
支付是整个系统里合规要求最高的环节。国内做这类平台,资金不能直接进自己的账户再转给车主,必须有第三方支付机构做资金存管。我接入的是微信支付商家版,乘客支付的钱先进微信支付的“分账”体系,平台确认订单完成后,再通过分账接口把车主的份额结算出去,平台抽成也走分账功能,不碰资金池。
支付流程是:用户点击支付,后端创建预支付订单,返回支付参数给前端调起微信支付,支付完成之后微信支付服务器会回调后端的通知接口,通知接口里校验签名、检查订单状态(防止重复回调)、更新订单状态为待车主确认、给车主推送新订单提醒。这里有个很重要的经验:回调处理逻辑必须支持幂等。微信支付可能会因为网络问题重试多次回调,如果不校验订单当前状态,就可能出现同一笔订单被支付两次或者状态被覆盖的情况。
退款也踩过坑。用户支付后申请取消,按规则全额退款或者部分退款。微信支付的退款接口要求传商户退款单号,并且退款金额不能超过实付金额(以分为单位)。我遇到过退款金额算错导致微信接口报错,排查了一下是数据库里金额字段用的BigDecimal,传到微信SDK时需要转成以分为单位的int,舍入方向弄错了。建议在统一的地方封装金额转换方法,避免每个地方单独处理:
public static int toCents(BigDecimal yuan) { return yuan.setScale(2, RoundingMode.HALF_UP) .multiply(BigDecimal.valueOf(100)) .intValue(); }3.4 Spring Boot后端项目的模块划分
实际工程里,我按业务边界做了模块化,没有把所有代码堆在同一个包里:
- gateway模块:Spring Cloud Gateway做路由转发、鉴权、限流
- system模块:用户、角色、权限、实名认证
- trip模块:行程发布、搜索、匹配算法
- order模块:订单、支付、退款、结算
- message模块:站内信、App推送、短信通知
- admin模块:后台管理接口
每个模块内部就是标准的Controller-Service-Mapper三层结构。这里要提醒一个点,模块划分别一头扎进微服务的坑里。这个项目规模,单机部署加模块化分包完全够用,强行拆微服务只会让部署和调试变得异常痛苦。我在第4章“常见问题”里也会聊到这个选择。
另外说一句热词里搜得很火的“Java面试八股文”和“动态代理”,确实在Spring Boot里用到了不少。比如AOP做接口日志和统一异常处理,底层就是动态代理;做权限控制时,Spring Security的@PreAuthorize注解也是基于代理实现的。学习这些面试题不只是为了面试,理解了原理对排查问题确实有帮助。
4. 前端Vue实现与地图交互:从页面到交互的完整打通
4.1 项目搭建与技术栈组合
前端用的是Vue 3 + Vite + Pinia + Vue Router + Axios + Vant + ECharts这套组合。为什么不用Webpack而用Vite?开发时热更新是真的快,这个项目页面多,组件多,Webpack冷启动要十几秒,Vite三秒内搞定。打包产物用Rollup处理,生产环境的构建体积控制得也不错。
初始化项目的命令比较简单:
npm create vite@latest shuttle-frontend -- --template vueVite在热更新时做了浏览器原生ESM的按需加载,只用加载当前页面需要的模块,所以速度优势非常明显。有人搜“vue安装依赖报错”,绝大多数情况是node版本和package.json里声明的版本不匹配。Vue 3项目建议Node版本在18以上,不然Vite和部分依赖装不上,装上了启动也会报各种奇怪的错。
我建议给几个核心依赖锁定版本:
{ "vue": "^3.4.21", "vant": "^4.8.0", "vite": "^5.2.0", "pinia": "^2.1.7", "vue-router": "^4.3.0", "axios": "^1.6.8" }4.2 地图组件的二次封装
同城出行项目里地图是逃不掉的刚需。我选了腾讯地图的JavaScript API,因为它在国内定位精度高,而且对Vue有官方适配,文档也比较友好。注册开发者账号、创建应用拿到密钥之后,在index.html引入SDK:
<script charset="utf-8" src="https://map.qq.com/api/gljs?v=1.exp&key=YOUR_KEY"></script>注意一个很容易忽略的点:这里的key有域名白名单限制。如果你开发环境下用的是localhost,测试环境又是另一个域名,需要在腾讯地图控制台把两个域名都加入白名单,不然页面会报“KEY无效”或者被拦截。我遇到过部署上去别人电脑上打开页面,地图白屏报校验失败,结果就是忘了配线上域名。
地图是不能直接在每个页面都写一遍初始化逻辑的,那样代码会爆炸。我封装了一个Vue组件,把地图初始化、标记点添加、路线绘制这些能力统一暴露出去,页面里只要传经纬度和地址就行。
我封装的地图组件简化版是这样的思路:
<template> <div ref="mapContainer" class="map-container"></div> </template> <script setup> import { onMounted, ref, watch } from 'vue' const mapContainer = ref(null) let mapInstance = null const props = defineProps({ center: { type: Object, default: () => ({ lat: 39.908, lng: 116.397 }) }, markers: { type: Array, default: () => [] } }) onMounted(() => { mapInstance = new TMap.Map(mapContainer.value, { center: new TMap.LatLng(props.center.lat, props.center.lng), zoom: 13 }) }) </script>这里面有几个细节对体验影响很大:
一是地图组件在页面切换时要做销毁重建,不然会内存泄漏。组件卸载时调用mapInstance.destroy()释放资源。
二是marker过多时要开启聚合功能,一次展示二三十个车主位置,不聚合的化密密麻麻全叠在一起,用户根本看不清。
三是地图初始化要等到DOM渲染完成之后执行,把初始化放在onMounted里是必须的。如果发现初始化时拿不到容器宽高,多半是外层div高度没设置,地图容器必须有明确的高度,用百分比的话父级也要有高度。
4.3 实时位置推送与WebSocket通信
乘客下单后最关心的是“车主到哪了”,这需要前端和服务器保持长连接,实时接收车主的GPS坐标。实现方案是WebSocket,后端用Spring的WebSocket模块。
前端封装了一个socket服务模块:
// socket.js let socket = null export function connectSocket(userId, handlers) { const protocol = location.protocol === 'https:' ? 'wss' : 'ws' socket = new WebSocket(`${protocol}://${location.host}/ws/order?userId=${userId}`) socket.onmessage = (event) => { const data = JSON.parse(event.data) if (data.type === 'driver_location') { handlers.onDriverLocation && handlers.onDriverLocation(data) } else if (data.type === 'order_status') { handlers.onOrderStatus && handlers.onOrderStatus(data) } } socket.onclose = () => { // 断线重连,指数退避 setTimeout(() => connectSocket(userId), 3000) } } export function closeSocket() { if (socket) { socket.close() socket = null } }断线重连是最容易被忽略的,移动端网络本身不稳定,WebSocket随时可能断开。我在前端做了指数退避重连:第一次3秒、第二次6秒、第三次12秒,最大间隔不超过30秒。重连成功之后前端要主动向后端拉一次当前订单状态,确保WebSocket断线期间漏掉的消息通过HTTP接口补回来。
后端推送的WebSocket消息使用消息模板统一封装:
public class WsMessage { private String type; // 消息类型:driver_location/order_status/message/notification private String orderNo; // 关联订单号 private Object payload; // 消息体 private Long timestamp; // 时间戳 }4.4 Vue路由与登录权限的那些坑
项目里的路由分两种:需要登录才能访问的页面(下单、订单详情、个人中心)和不需要登录的页面(首页搜索、行程列表)。我用了Vue Router的全局前置守卫做登录校验:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next({ path: '/login', query: { redirect: to.fullPath } }) } else { next() } })登录成功之后把token存localStorage,Axios请求拦截器里统一加上Authorization头:
axios.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config })这里有个热搜词里出现频率很高的问题:Vue Router用history模式部署后,刷新页面就404。这不是代码问题,是服务器没配置。history模式的路由在浏览器里是真实的URL路径,比如/order/detail/123,刷新时浏览器会向服务器请求这个路径对应的文件,而服务器根本没有这个物理文件,所以返回404。解决办法是让服务器把所有请求都重写到index.html,交给前端路由处理。Nginx配置是:
location / { try_files $uri $uri/ /index.html; }如果用的是hash模式,URL会带个#,不会有刷新404的问题,但观感上不如history模式干净,也不利于SEO。我建议做这种业务系统直接上history模式,server配置好也没多复杂。
还有一个实际开发的体验问题:路由懒加载能显著减少首屏加载时间。用动态import的方式引入组件,Vite会自动做代码分割,首屏只加载首页需要的JS,其他页面模块按需加载:
const routes = [ { path: '/order/detail/:id', component: () => import('../views/order/OrderDetail.vue') } ]热词里有人问“Vue devtools插件下载”和“Vue调试工具使用”,这里补充下经验:用Vite启动的项目,devtools直接装Chrome扩展商店的Vue.js devtools就行,它会自动识别Vue 3项目。调试的时候Event Timeline能清楚看到父子组件的事件冒泡顺序,这在排查“点击按钮没反应”这类问题时特别有用,能快速定位是事件没触发、触发了没传参、还是传了参数但父组件处理函数写错了。另外“vue播放m3u8”也有同事问过我,如果你做的后续版本要加司乘视频通话或者行车记录回放,m3u8流媒体播放可以用video.js的videojs-contrib-hls插件,这个在移动端跑起来很流畅。
5. 部署与工程化:从开发机到线上的完整交付
5.1 环境配置与前后端联调
这个部分是新手最容易卡住的地方。热搜词里一堆关于“Java环境变量配置”、“Vue安装及环境配置”、“Java安装教程详细”的搜索,说明环境问题确实是拦路虎。
Java环境这块,JDK 1.8和JDK 17我都用过,Spring Boot 2.x系列建议用JDK 8,Spring Boot 3.x强制要求JDK 17。这个项目用的Spring Boot 2.7,所以我按JDK 8来配。Windows上配置环境变量,JAVA_HOME指向JDK安装目录,PATH里加%JAVA_HOME%\bin,CLASSPATH可以不用配。特别注意:配完环境变量一定要新开命令行窗口再执行java -version,当前已打开的命令行窗口不会刷新环境变量,这一点能卡住不少人。
Maven仓库在国内访问Maven中央仓库经常超时,我把settings.xml里的镜像源换成了阿里云镜像,依赖下载速度从动不动几分钟提升到几十秒。
前后端联调阶段,必踩的坑是跨域。前端开发服务器默认跑在5173端口,后端接口在8080端口,浏览器会因为同源策略拦截请求。我用Vite的proxy配置解决开发环境的跨域:
// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })生产环境里前端页面和后端接口用同一个域名部署,Nginx做好请求转发,就不存在跨域问题了。这也是为什么我不建议在生产环境用后端CORS配置来放行前端——那是临时方案,不如直接用同域部署干净。
5.2 WebView加载Vue项目的实战配置
热词里搜“Android iOS Webview加载本地Vue打包文件”的需求很典型——很多团队不打算上架应用商店,就想把H5项目套壳成App,方便用户从桌面直接打开。
在Android WebView里加载本地Vue打包文件,核心步骤是:
- 在Android项目的assets目录下放入打包后的dist文件夹内容
- WebView启用JavaScript:
webView.getSettings().setJavaScriptEnabled(true); webView.getSettings().setDomStorageEnabled(true);- 加载本地文件:
webView.loadUrl("file:///android_asset/dist/index.html");但直接用file协议加载会遇到两个问题:一是跨域限制,二是一部分浏览器特性(比如history路由)在file协议下不能正常工作。所以建议走本地HTTP服务器方案:把dist文件放到Assets目录,用一个轻量级的本地HTTP服务器(比如Android的WebViewLocalServer或者iOS的GCDWebServer)启动服务,让WebView通过http://localhost访问页面。
iOS的WKWebView默认禁止了file协议访问本地文件,如果用XHR请求本地JSON数据都会失败,需要在Info.plist里配置NSAllowsArbitraryLoads或者用WKURLSchemeHandler自定义协议。选型的时候一定要想清楚:如果后续要放大的视频、频繁调用原生能力(比如调起摄像头扫码),跨端方案比套壳WebView体验好得多,套壳WebView适合轻量级展示场景。
5.3 容器化部署与Nginx配置
打包部署是我比较推荐的一套组合:前端打镜像、后端打镜像,用Docker Compose编排,前面挂Nginx做HTTPS和反向代理。
前端构建阶段有个注意点:Vite打包时会把环境变量内嵌到JS里。如果你有API地址这类配置,不要写死在代码里,用环境变量区分:
# .env.development VITE_API_BASE_URL=/api # .env.production VITE_API_BASE_URL=https://yourdomain.com/api拿"vue项目启动后network不可用"这个搜索词来说,很多人是本地起项目后想用手机通过局域网访问调试,结果发现页面打不开。这个大概率就是Vite默认只绑定了localhost,需要配置host为true:
server: { host: true, // 监听所有网卡 port: 5173 }然后手机和电脑连同一个WiFi,用电脑的局域网IP加端口号访问。但要注意,Vite的proxy代理配置在局域网访问时依然有效,代理请求会从电脑发出,手机不用做额外配置。
生产环境Nginx配置我需要写完整一点:
server { listen 443 ssl; server_name yourdomain.com; ssl_certificate /etc/nginx/cert/yourdomain.pem; ssl_certificate_key /etc/nginx/cert/yourdomain.key; # 前端静态文件 location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } # 后端API反向代理 location /api/ { proxy_pass http://backend-server: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; } # WebSocket反向代理 location /ws/ { proxy_pass http://backend-server:8080/ws/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } }WebSocket的代理配置是很多人容易漏掉的,不配置Upgrade头,WebSocket连接会失败,前端就会不断重连,看起来像服务挂了,实际只是代理层没把协议升级包传过去。
6. 开发中躲不开的坑与排查技巧
6.1 后端高频报错:从Lombok到Redis的排查实录
先说说热词里那些Java相关的报错是怎么一回事。
“java: you aren't using a compiler supported by lombok, so lombok will not work”这个报错,是Lombok版本和JDK版本不匹配导致的。项目用的JDK 8的话,Lombok版本1.18.20以上的都没问题;如果升级到JDK 17,就必须用Lombok 1.18.30以上的版本,而且要在Maven配置里显式指定注解处理器路径:
<dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.30</version> <scope>provided</scope> </dependency>“uncaught exception java.lang.NoClassDefFoundError: java/applet/Applet”这个报错,一般是项目中依赖了已经移除的JDK内部类。JDK 9之后Applet API被标记废弃,JDK 11直接移除了,如果你在JDK 11以上环境运行旧项目,就会报这个。解决办法是找到哪个依赖引用了老API把它排除掉,或者把JDK版本降回8。
“RedisTemplate的increment()报错不是integer or out of range”,这个我在前面提到过,最常见的原因是同一个key被写入了非整数类型的值,或者value超过了Long的最大值。用opsForValue().increment()之前确认key没有在其他地方被当作其他数据类型用过,最好的方式就是对key做规范命名和统一管理。
“Lambda函数Java”和“冒泡排序Java”这两个热词,其实是在面试题里经常出现的。实际项目中Lambda用在Stream流处理上非常顺手,比如计算订单总额:
BigDecimal total = orderItems.stream() .map(OrderItem::getAmount) .reduce(BigDecimal.ZERO, BigDecimal::add);排序算法实现这块我建议有基础的理解就好,实际业务里直接用Collections.sort或者Stream的sorted方法,算法细节是数据结构课的内容,面试可能会问,但不代表业务代码里要手写。
6.2 前端高频报错:从路由到部署的排查实录
Vue项目开发中会遇到各种奇奇怪怪的问题,我整理几个最常见的场景和排查思路:
“vue安装及环境配置”这一步最容易出问题的就是node-sass的年代遗留问题。现在新项目直接用sass(Dart Sass),不再用node-sass,node-sass在Node版本大升级后经常编译失败,浪费时间还没有意义。
“vue路由参数”很多人会在组件里拿不到路由参数。注意区分:Path模式传参用this.$route.params或者route.params(Vue 3的组合式API里用useRoute),Query模式传参用this.$route.query。取不到参数的时候先看URL到底长什么样,再决定用什么 API,这是最基础的排查链路。
“vue样式”问题里面,scoped样式不生效也是老生常谈。scoped给组件里的元素加了data属性选择器,但如果子组件根元素想从父组件改样式,要么去掉scoped,要么用 :deep() 选择器穿透:
.parent :deep(.child-class) { color: red; }还有一种特别容易忽略的情况:引入了第三方组件库的样式,但组件内部是通过挂载到body下面的方式渲染弹层,这种时候scoped完全不生效,因为在DOM结构上弹层已经不在当前组件节点内部了。
“vite打包后的history路由部署404”的情况上面已经写了Nginx配置,这里再补充一个排查方向:如果已经配置了try_files但页面刷新还是404,检查一下Nginx的配置文件有没有生效,很多人改了配置忘记nginx -s reload,看起来配置是对的,实际跑的还是旧的。
6.3 并发与数据一致性问题的排查方法
出行类系统最怕的就是并发场景下数据出问题。订同一辆车、抢最后一个座位、司机和乘客同时操作订单,这些都是典型的并发冲突场景。
我排查这类问题的固定思路是分三步:先看日志找异常,再分析逻辑找竞态,最后用压测工具模拟并发复现。后端日志必须打好关键节点的信息,包括入参、出参、耗时、当前状态,不然真出问题的时候两眼一抹黑。逻辑上重点关注“先查后改”的代码模式,比如先查座位数再更新座位数,这种非原子的操作天然存在竞态条件。修复方式前面说过,要么用Redis分布式锁,要么用数据库乐观锁:
UPDATE trip SET remaining_seats = remaining_seats - 1 WHERE id = #{tripId} AND remaining_seats >= #{seats}这个SQL本身就带了条件,如果受影响行数为0,说明座位不够,就不用再走业务判断了。这种底层原子操作配合业务层锁,双保险基本能覆盖绝大多数的并发问题。这里我也想过引入消息队列把请求排队处理,但一个小项目引入MQ带来的运维成本高于收益,自己用锁加原子更新够用了。
6.4 一些实用的经验总结
做完这个项目,我整理了一些不一定写在文档里、但对实际开发很有用的经验:
- 金额计算一律用BigDecimal,不要用double,客户那边差一分钱都可能来投诉。
- 所有后端接口统一返回结构,我用的Result对象包含code、message、data三个字段,前端Axios响应拦截器统一处理错误提示,不用每个页面重复写错误处理逻辑。
- 日志要打关键节点。我要求团队在订单状态变更、支付回调、退款操作这些核心地方,必须打业务日志,包含操作人、操作时间、前后状态、请求参数,方便出问题后追溯。
- 前端请求超时时间要区分场景。普通接口10秒,上传图片30秒,WebSocket不设超时但要用心跳机制保活,30秒发一次心跳,90秒没收到心跳就主动重连。
- 测试环境数据和生产环境数据严格隔离。运营后台必须有一个醒目的环境标识,防止误操作把测试环境的数据弄到生产库。
7. 后续可扩展的方向思考
项目上线运行平稳之后,如果业务量上来了,有几个方向是我明确能看到扩展空间的:
一个是消息推送服务。目前用的是轮询加WebSocket,后续如果要支持Push通知、短信通知、微信模板消息等多渠道触达,可以把这部分抽成独立的消息中心服务,引入消息队列削峰填谷。
另一个是走顺风车业务必然要做的信用体系。目前只做了简单的实名认证,后续可以加用户信用分:按时乘车加分,恶意取消扣分,信用分低的乘客在匹配时权重降低,这样能一定程度筛掉不良用户。这块需要运营规则和算法模型的配合,不是纯开发能搞定的。
还有一个是地图服务的高可用。目前接的是腾讯地图一家,如果遇到服务商限流或者说服务出故障,会影响整个匹配功能。后续可以做双地图服务商的切换方案:高德和腾讯都接入,平时默认用一家,出故障自动切换备用的。API接口两边类似,封装一层适配器,切换成本不算太高。
这些方向要不要做、什么时候做,取决于业务跑出来的真实数据,前期不用铺太开,先把核心链路做稳比什么都强。我个人觉得,出行类项目最重要的就是稳定性和信任感,技术选型宁可保守也不要花哨,用熟知的方案把核心链路打磨扎实,比追新技术潮更有价值。