代驾小程序核心开发:状态机、实时定位与抢单并发控制实战 先说个结论代驾小程序和普通电商小程序完全是两个物种。电商小程序的核心是商品和订单代驾小程序的核心只有一件事——状态。从哪里出发、司机在哪、谁接的单、车开到哪了、钱怎么算全部围绕订单状态在转。我在做代驾小程序开发实战时把大部分精力都花在了状态机、实时位置和并发控制上页面反而是最后才写的。这篇文章就把核心代码实现拿出来拆一遍重点讲清楚用户端怎么下单、司机端怎么抢单、轨迹怎么存、钱怎么算以及我在线上踩过的那些坑。适合谁看准备做代驾、跑腿、同城即时配送类小程序的技术同学或者已经在做但想优化核心链路的人。你不需要懂特别深的地图算法但最好对微信小程序开发、redis 基础操作有一定了解。下面所有代码都是我自己项目里精简后的版本可以直接拿去改。1. 代驾业务的状态机代码里所有“会变”的字段都围着它转1.1 状态定义与流转边界很多新手上来就写接口先写一个 createOrder再写一个 cancelOrder写着写着就乱了。原因是他们没意识到代驾订单的每个状态变更都牵扯到用户端、司机端、后台三方的同步。我第一步就是把状态机定死所有接口只允许做“从状态 A 到状态 B”的迁移其他操作全部拒绝。我常用的订单状态定义如下// orderStatus.js const ORDER_STATUS { // 用户已下单等待司机抢单 WAITING_DRIVER: 10, // 司机已抢单正在前往用户起点 DRIVER_ON_THE_WAY: 20, // 司机已到达起点等待用户上车 DRIVER_ARRIVED: 30, // 代驾中车辆正在行驶 IN_SERVICE: 40, // 司机已到达目的地待用户确认 ARRIVED_DEST: 50, // 用户确认待支付 PENDING_PAYMENT: 60, // 已支付订单完成 COMPLETED: 70, // 用户取消 USER_CANCELLED: 0, // 司机取消 DRIVER_CANCELLED: -1, // 系统超时取消 TIMEOUT_CANCELLED: -2, };状态对应的操作语义当前状态允许的操作目标状态说明WAITING_DRIVER用户取消USER_CANCELLED未接单前用户随时可取消WAITING_DRIVER司机抢单DRIVER_ON_THE_WAY抢单后锁定司机DRIVER_ON_THE_WAY司机确认到达DRIVER_ARRIVED到达起点后通知用户DRIVER_ON_THE_WAY用户取消USER_CANCELLED司机还没到用户可取消DRIVER_ARRIVED开始服务IN_SERVICE用户上车司机点击“开始代驾”IN_SERVICE到达目的地ARRIVED_DEST司机点击结束服务ARRIVED_DEST用户确认支付PENDING_PAYMENT生成支付单PENDING_PAYMENT支付成功COMPLETED支付回调触发这里有个特别容易忽略的点司机抢单后到司机到达之前用户取消订单是有代价的。不同于网约车代驾司机已经动身去你的位置了所以这段取消只允许在“司机尚未出发太久”或“系统判责”的情况下通过。我的做法是在状态机之外加了取消原因和判责逻辑而不是直接允许无条件迁移。1.2 服务端状态流转校验单有状态定义不够容易在代码层面被绕过。比如用户端直接调了“开始服务”这在业务上是不允许的。所以我所有状态变更都统一走一个入口// orderStateMachine.js const stateMachine { [ORDER_STATUS.WAITING_DRIVER]: { userCancel: [ORDER_STATUS.USER_CANCELLED, canCancelByUser], driverGrab: [ORDER_STATUS.DRIVER_ON_THE_WAY, canGrabByDriver], timeoutCancel: [ORDER_STATUS.TIMEOUT_CANCELLED, canTimeout], }, [ORDER_STATUS.DRIVER_ON_THE_WAY]: { userCancel: [ORDER_STATUS.USER_CANCELLED, canCancelBeforeArrive], driverArrive: [ORDER_STATUS.DRIVER_ARRIVED, canConfirmArrive], driverCancel: [ORDER_STATUS.DRIVER_CANCELLED, canCancelByDriver], }, [ORDER_STATUS.DRIVER_ARRIVED]: { startService: [ORDER_STATUS.IN_SERVICE, canStartService], }, [ORDER_STATUS.IN_SERVICE]: { endService: [ORDER_STATUS.ARRIVED_DEST, canEndService], }, [ORDER_STATUS.ARRIVED_DEST]: { confirmPay: [ORDER_STATUS.PENDING_PAYMENT, canCreatePayment], }, [ORDER_STATUS.PENDING_PAYMENT]: { paySuccess: [ORDER_STATUS.COMPLETED, canPaySuccess], }, }; function transition(order, action, operator) { const rule stateMachine[order.status]?.[action]; if (!rule) throw new Error(非法操作当前状态不支持该动作); const checker rule[1]; // 业务校验函数各自实现 if (!businessChecks[checker]?.(order, operator)) { throw new Error(业务校验未通过 checker); } order.status rule[0]; return order; }这样做的好处是所有状态变更逻辑集中在一处排查问题的时候打开这一个文件就知道某个状态能不能做某个动作不需要翻遍整个服务端代码。实际开发里我还加了状态变更流水表order_status_log每次迁移都插入一条记录线上出纠纷时直接以流水为准。2. 用户端叫车链路定位、下单、等待三个环节的代码实现2.1 定位授权与逆地址解析小程序拿位置很简单wx.getLocation一行代码但麻烦的是权限和精度。小程序必须用户在“设置”里授权“位置信息”而且 2022 年以后微信还加了“精确位置”的开关用户不开精确位置你拿到的坐标可能偏差几百米对代驾这种需要司机精准找车的场景几乎是致命的。我的做法是三步进首页先调wx.getSetting检查授权状态未授权就弹引导用户拒绝授权就提示去设置页打开拿到坐标后用微信自带wx.reverseGeocoder或者服务端逆地址解析把经纬度转成可读地址。// 定位模块封装 function ensureLocation() { return new Promise((resolve, reject) { wx.getSetting({ success: (res) { const auth res.authSetting[scope.userLocation]; if (auth false) { wx.showModal({ title: 需要定位权限, content: 请在小程序设置中开启位置权限, confirmText: 去设置, success: (r) { if (r.confirm) wx.openSetting(); }, }); reject(new Error(no location auth)); return; } wx.getLocation({ type: gcj02, isHighAccuracy: true, highAccuracyExpireTime: 3000, success: resolve, fail: reject, }); }, fail: reject, }); }); }这里有一个关键细节微信返回的是 gcj02 坐标不是纯 GPS 坐标。如果你的后端和地图服务用的是其他坐标系一定要做坐标转换。我因为没注意这个第一版上线后司机导航偏离了几百米后来客户端统一用 gcj02后端所有点位也约定 gcj02才彻底解决。2.2 创建订单的幂等策略用户点“立即叫代驾”按钮最怕什么两次请求生成了两笔订单。小程序网络抖动、用户连点、前端重试都会导致重复下单。我在订单表加了一个idempotent_key字段用户端在下单前生成一个一次性 UUID写入按钮状态里。后端收到订单请求先查这个 key存在就直接返回旧订单不存在才创建。// 用户端点击下单 async function submitOrder() { if (this.submitting) return; this.submitting true; const idempotentKey ${user.id}_${Date.now()}_${Math.random().toString(36).slice(2)}; try { const res await request(/order/create, { startAddr: this.startAddr, endAddr: this.endAddr, startLat: this.startLat, startLng: this.startLng, endLat: this.endLat, endLng: this.endLng, carNumber: this.carNumber, carBrand: this.carBrand, idempotentKey, }); if (res.code 0) { this.orderId res.data.orderId; // 跳转等待页 } } finally { this.submitting false; } }// 服务端创建订单 async function createOrder(user, payload) { const exist await OrderModel.findByKey(payload.idempotentKey); if (exist) { return { code: 0, data: { orderId: exist.id, repeat: true } }; } const distance calcDistance(payload.startLat, payload.startLng, payload.endLat, payload.endLng); const order await OrderModel.create({ userId: user.id, ...payload, distance, status: ORDER_STATUS.WAITING_DRIVER, createTime: Date.now(), }); // 推送到司机侧抢单池 await pushToDriverPool(order); return { code: 0, data: { orderId: order.id, repeat: false } }; }顺带说一句pushToDriverPool这里我用了 redis 的有序集合以经纬度计算后的 geohash 为分区把订单推给距离起点 3 公里内的司机。这个小细节后面第 3 节专门讲。2.3 等司机接单轮询还是 WebSocket下单之后用户端要实时看到“司机正在赶来”这就有个消息通道的问题。微信小程序里原生 WebSocket 复杂度略高但毕竟订单状态不像股票行情那么高频我采用的是“WebSocket 推送 兜底轮询”双通道。主链路用 WebSocket// socket.js 简化版 function connectSocket(token) { const ws wx.connectSocket({ url: wss://api.example.com/ws?token${token} }); ws.onMessage((event) { const msg JSON.parse(event.data); if (msg.type order_status) { // 通知页面更新订单状态 emit(order_status, msg.data); } if (msg.type driver_grab) { // 司机抢单成功立刻展示司机信息 emit(driver_grab, msg.data); } }); ws.onClose(() { // 断线重连指数退避 setTimeout(() connectSocket(token), 3000 * Math.pow(1.6, retryCount)); }); }WebSocket 断线在小程序里特别常见杀后台、切网络、长时间挂起都会被系统断掉。所以我在订单详情页同时加了 5 秒一次的轻量轮询只在“等待接单”和“司机前往中”两个状态开启。一旦 WebSocket 先推送了状态轮询会自动停掉。3. 司机端接单链路GPS上报、抢单防重、服务状态推进3.1 司机位置实时上报司机端是整个代驾小程序里最吃性能的端。司机开着自己的车手机一直跟着跑不仅要上报位置还要过滤掉定位漂移。先说上报频率。我最后调成 3 秒一次太快费流量费电太慢抢单围栏判断不准。上报接口是一个独立的轻量接口不走业务鉴权的主链路单独用一张driver_location_log表记录实时位置则直接写 Redis GEO。// 司机端位置上报服务端接收 async function reportDriverLocation(driverId, lat, lng, speed, heading) { const key driver:loc:${driverId}; // 写入实时位置 await redis.geoAdd(key, { longitude: lng, latitude: lat, member: ${lat},${lng} }); // 设置 10 分钟过期防止司机离线后残留脏数据 await redis.expire(key, 600); // 不入库只往 Kafka 里丢一条由消费端异步写日志 await kafka.send(driver-location-log, { driverId, lat, lng, speed, heading, ts: Date.now(), }); }这里为什么入 Redis 而不直接写 MySQL因为高频写入直接打崩数据库。司机位置是“只读当前值”的数据Redis 的 GEO 类型天然适合而且后面查附近司机可以直接用georadius比自己算距离再排序快一个数量级。3.2 基于 Redis 锁的抢单实现抢单是这个项目里并发最集中的场景。一个订单推给 20 个司机20 个人同时点“抢单”数据库层如果不加控制很可能两个人都抢成功。我的实现是 redisSET NX EX拿到锁的司机才能进入后续业务校验// 抢单接口核心逻辑 async function grabOrder(driverId, orderId) { if (!driverId || !orderId) throw new Error(参数错误); const lockKey lock:order:${orderId}; // 抢锁5 秒自动过期 const locked await redis.set(lockKey, driverId, NX, EX, 5); if (!locked) { return { code: 4001, msg: 手慢了订单已被别的师傅抢走 }; } try { const order await OrderModel.findById(orderId); // 二次校验状态防止锁过期后状态已经变化 if (order.status ! ORDER_STATUS.WAITING_DRIVER) { return { code: 4002, msg: 订单状态已变化 }; } // 更新订单状态 司机ID事务保护 await OrderModel.transaction(async (tx) { await tx(orders).where({ id: orderId }).update({ status: ORDER_STATUS.DRIVER_ON_THE_WAY, driver_id: driverId, grab_time: Date.now(), }); }); // 推送通知用户 await pushToUser(order.userId, driver_grab, { orderId, driverId, carNumber: 沪A12345, driverName: 王师傅, }); return { code: 0, msg: 抢单成功 }; } finally { // 抢单成功后锁删除但这里要考虑业务操作耗时超过锁过期时间的问题 const owner await redis.get(lockKey); if (owner String(driverId)) { await redis.del(lockKey); } } }抢单有个实战坑必须提醒锁的过期时间一定要长于业务操作耗时。最开始我设了 2 秒结果有次数据库慢查询业务逻辑跑了 3 秒锁自动过期了第二个司机又把锁拿走了等第一个司机事务提交完订单状态已经变成第二个司机的了。后来我把锁过期时间设为 5 秒并在释放锁之前先比对持有者 ID只删自己的锁避免误删别人的锁。3.3 司机端的状态推进司机端和用户端操作的是同一笔订单但视角不同。司机端页面里有三个核心动作到达起点、开始代驾、结束代驾。这三个动作全部走第 1 节那个状态机入口。一个需要特别注意的业务点是“开始代驾”和“结束代驾”与用户确认的关系。我的逻辑是司机点“开始代驾”相当于宣告服务开始后续计费从这一秒算司机点“结束代驾”订单进入ARRIVED_DEST此时用户端弹窗显示里程、时长、费用用户点“确认并支付”后状态才切到PENDING_PAYMENT。如果司机点完“结束代驾”用户一直不确认呢这里必须有个兜底用户超过 5 分钟不确认系统自动把订单置为PENDING_PAYMENT并给用户发提醒。否则司机的订单永远卡在已到达状态钱收不回来。// 结束代驾 async function endService(orderId, driverId) { const order await OrderModel.findById(orderId); if (order.driverId ! driverId) throw new Error(无权操作); const updated transition(order, endService, { id: driverId }); await OrderModel.updateStatus(order.id, updated.status); // 计算费用并写入订单金额 const fee await calculateFee(order); await OrderModel.updateFee(order.id, fee); // 发送到用户端确认 await pushToUser(order.userId, end_service, { orderId, fee }); // 设置 5 分钟自动确认定时任务 await scheduleAutoConfirm(order.id, 5 * 60 * 1000); return { code: 0, data: { fee } }; }这里我用了scheduleAutoConfirm底层是 redis 延迟队列。订单 ID 作为 task5 分钟后消费端检查订单状态是否还在ARRIVED_DEST如果是就自动推进到PENDING_PAYMENT。定时任务不是 setInterval 扫表那种做法数据量大了会漏延迟队列才靠谱。4. 轨迹回放与动态计费核心代码里的两个硬骨头4.1 轨迹点采集与存储代驾服务中用户最关心的就是轨迹回放司机也怕用户事后扯皮说“绕路了”。所以服务开始后司机端以 5 秒一个点的频率上传位置服务端只保留服务区间内的点。轨迹点上传接口设计的核心是批量。司机端每 15 秒把 3 个点聚合成一个数组一起传上来。理论上 30 分钟的代驾大概产生 360 个点全天几千单就是百万量级全量查 MySQL 会扛不住。我的方案是轨迹点一张独立表driver_track_log按订单 ID 索引同时每个订单的轨迹点按 5 分钟一个 chunk 写入 Redis用于实时回放历史轨迹回放从 MySQL 读实时轨迹回放从 Redis 读。// 轨迹批量上报 async function uploadTrack(orderId, driverId, points) { const order await OrderModel.findById(orderId); if (!order || order.driverId ! driverId) throw new Error(非法上报); const rows points.map((p) ({ order_id: orderId, lat: p.lat, lng: p.lng, speed: p.speed || 0, ts: p.ts, })); await TrackModel.batchInsert(rows); // 同时写 Redis轨迹点分 60 秒块存储 const chunkKey track:${orderId}:${Math.floor(rows[0].ts / 60000)}; await redis.rpush(chunkKey, rows.map((r) JSON.stringify(r))); await redis.expire(chunkKey, 2 * 3600); }轨迹点的存储上我强烈建议加上ts时间戳索引因为后面做里程回溯、绕路判断都要按时间排序。4.2 距离计算与计费引擎代驾计费不是简单的“每公里多少钱”。我当时做的计费规则有四个维度起步价、里程费、等待费、夜间附加费其中里程费是按实际轨迹计算的不是直线距离。所以calculateFee的逻辑是从轨迹点表读出全部点用 Haversine 公式计算相邻两个轨迹点的球面距离累加出实际代驾里程过滤漂移点速度大于 120km/h 或相邻点位移突变超过 500 米根据里程阶梯计算里程费加上起步价、等待费司机到达起点后等待超过 10 分钟的部分按分钟计费、夜间附加费。// 计算两点球面距离Haversine 公式 function haversine(lat1, lng1, lat2, lng2) { const R 6371000; // 地球半径米 const dLat ((lat2 - lat1) * Math.PI) / 180; const dLng ((lng2 - lng1) * Math.PI) / 180; const a Math.sin(dLat / 2) ** 2 Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLng / 2) ** 2; return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); }// 订单里程计算 漂移点过滤 async function calcOrderMileage(orderId) { const points await TrackModel.getByOrderId(orderId); if (points.length 2) return 0; let total 0; for (let i 1; i points.length; i) { const prev points[i - 1]; const cur points[i]; const dist haversine(prev.lat, prev.lng, cur.lat, cur.lng); const timeDiff (cur.ts - prev.ts) / 1000; // 秒 // 漂移点过滤速度超过 120km/h33.3m/s 或 相邻点位移大于 500m认为是漂移 if (dist 500 || timeDiff 0 dist / timeDiff 33.3) { continue; } total dist; } return Math.round(total); // 返回米 }计费引擎的核心原则是费率和规则尽量配置化不要在代码里写死。我第一版图省事直接把里程单价写死在calculateFee里后来改价时前端已经发版后端又得重新部署非常痛苦。后来我把起步价、里程单价、等待单价、夜间附加费全部改成配置中心管理改价就是改一条配置系统自动生效。这里也提一个容易被坑的点计费要以服务区间为界。司机开着代驾用户的车从起点到终点的轨迹就算计费里程但司机自己开车过来接用户的里程绝对不能算进去。所以我在startService时记录了一个service_start_time轨迹查询只取ts service_start_time的数据以免算错。4.3 轨迹回放接口设计轨迹回放接口要注意性能因为一次请求要返回几百个点点过多前端渲染会卡。我做了降采样轨迹点超过 200 个时按等间隔抽稀到 200 个点既能看清路线路径又不至于卡死页面。// 轨迹回放接口 async function getTrackForReplay(orderId) { const points await TrackModel.getByOrderId(orderId); if (points.length 200) return points; const step Math.ceil(points.length / 200); const sampled []; for (let i 0; i points.length; i step) { sampled.push(points[i]); } // 同时保证最后一个点一定在轨迹里 sampled[points.length - 1] points[points.length - 1]; return sampled; }// 用户端渲染回放简化 function drawTrack(canvas, points) { const ctx canvas.getContext(2d); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.beginPath(); points.forEach((p, i) { const { x, y } projectToScreen(p.lat, p.lng); if (i 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); ctx.stroke(); }前端回放用的projectToScreen是把经纬度投影到平面坐标我用的是简单的等距圆柱投影加上屏幕适配够用即可不需要引入高精度地图投影。当然小程序里更常见的方案是直接在地图组件map的polyline上渲染折线我贴坐标图只是示意。5. 线上踩过的坑状态不一致、定位漂移、支付回调延迟5.1 状态不一致的复现场景状态不一致是代驾系统最大的坑没有之一。最常见的场景是司机端显示“代驾中”用户端显示“已结束”后台显示“待支付”三个端各说各话。原因往往是端上异常操作绕过状态机直接改了本地状态或者服务端接口没有做幂等导致重复提交。我的处理方案有三层服务端所有状态变更必须走状态机不允许接口内部直接update status每次状态变更都写order_status_log流水记录操作人、操作时间、旧状态、新状态、操作来源用户端/司机端/服务端端上不做“乐观状态”统一以服务端推送为准。司机端、用户端收到推送后刷新页面状态本地不缓存状态用来做界面判断。5.2 定位漂移怎么过滤代驾需求对定位精度要求极高但现实中定位漂移不可避免。有一次线上反馈司机明明在路上轨迹却显示绕进了旁边的湖里。我总结了几种常见的漂移形态高楼密集区 GPS 信号反射点位在道路两边横跳地下车库/隧道没有 GPS定位长时间保持不变或突然跳到远处手机省电模式关闭 GPS只靠基站粗略定位。过滤策略我做了两层。第一层是司机端上报前过滤// 司机端上报前过滤漂移 function filterPoint(prev, cur) { if (!prev) return true; const dist haversine(prev.lat, prev.lng, cur.lat, cur.lng); const timeDiff (cur.ts - prev.ts) / 1000; // 平均速度超过 40m/s144km/h 或 位移 300m认为漂移 if (timeDiff 0 dist / timeDiff 40) return false; if (dist 300) return false; return true; }第二层是服务端入库前过滤两套规则独立。即使客户端被人篡改或者异常上报服务端也能挡掉坏数据。过滤掉了不等于丢掉真正的订单轨迹里如果有漂移点在计费时不参与里程累加但在回放里保留用于查看上下文。5.3 支付回调延迟与订单超时最后一个坑微信支付回调不是即时到达的用户在支付成功后可能要等一两秒甚至更久才能收到回调。如果用户端直接以“支付成功”的页面文案为准而服务端订单还是PENDING_PAYMENT用户就以为支付失败了重复付款的投诉就来了。我的处理方案是用户端支付成功跳转前先调一个“查询支付状态”接口服务端主动向微信侧查询支付结果确认完成后才把订单置为COMPLETED。同时后端维护一个延迟队列支付单创建后 15 分钟如果还没有回调主动向微信查询一次兜底完成状态推进。// 服务端主动查询支付结果 async function queryPayAndSettle(orderId) { const order await OrderModel.findById(orderId); if (order.status ! ORDER_STATUS.PENDING_PAYMENT) return; const result await WechatPay.query({ outTradeNo: order.payTradeNo, }); if (result.trade_state SUCCESS) { const updated transition(order, paySuccess, { id: 0, source: pay-query }); await OrderModel.updateStatus(order.id, updated.status); await pushToUser(order.userId, pay_success, { orderId }); } }这套主动查询逻辑救了我很多次因为晚高峰支付量大的时候微信回调偶尔会延迟靠轮询兜底才没让用户端和服务端的状态脱节。再说个超时自动取消的实现也踩过坑。订单在WAITING_DRIVER状态下3 分钟内没司机抢单就自动取消。我当时用setTimeout在进程内存里调结果服务一重启定时任务全部丢失。后来改成 redis 延迟队列zadd一个未来的时间戳消费端循环zrangebyscore取出到期任务处理服务重启也不会丢。// 延迟队列订单超时取消 async function scheduleAutoCancel(orderId, ttlMs) { const score Date.now() ttlMs; await redis.zadd(delay:cancel-order, score, String(orderId)); } // 消费端伪代码 setInterval(async () { const now Date.now(); const expired await redis.zrangebyscore(delay:cancel-order, 0, now); for (const id of expired) { await cancelOrderIfWaiting(id); await redis.zrem(delay:cancel-order, id); } }, 1000);第一次用内存 setTimeout 时线上一次重启导致几十个待接单订单全部卡死用户骂声一片。换到 redis 延迟队列之后这个问题再没出现过。我在实际做代驾项目的最大体会是宁可页面丑一点也不能让状态乱一点。代驾小程序的核心不在 UI 多炫而是订单状态流转的可靠性、实时位置的准确性、费用计算的严谨性这三块代码搞扎实了线上就不会出大问题。如果你也是刚起步做类似项目先把状态机、抢单锁、延迟队列这三样东西吃透后面扩展功能都会顺很多。