ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

网约车打车小程序:实时调度、状态机与WebSocket位置服务

网约车打车小程序:实时调度、状态机与WebSocket位置服务

简介:一套面向微信小程序开发学习者的仿滴滴打车出行应用源码,聚焦网约车核心流程的拆解与复现,适合已掌握小程序基础、希望理解地图定位、订单流转与页面组织的中级开发者参考。压缩包为 rar 格式,共 151 个文件、约 652KB,其中 80 个 png 图片资源、20 个 wxss 样式、20 个 js 逻辑脚本、16 个 json 配置与 15 个 wxml 结构文件,覆盖全局配置、页面模块、可复用模板、静态素材与工具函数等层次。项目按首页、叫车、订单、个人中心等场景拆分页面,并预留位置服务、路线规划、实时通信、支付与订单状态管理等关键环节的实现思路;因原接口需自行替换填写,更适合作为学习演示与二次开发底稿。已有 3813 人学习,可帮助读者快速梳理小程序目录结构、对照页面与逻辑组织,并积累地图类应用的排错与改造经验。

1. 打车小程序最容易被低估的地方:它其实是一套实时调度系统

很多人对打车小程序的想象是:一张地图,两个输入框,点一下"呼叫",司机头像顺着路挪过来。真动手做就会发现,地图只是壳子,难啃的是壳子底下那套东西——订单在七个状态之间流转还不能乱序,司机位置每三秒上报一次还要广播给对的人,附近司机检索得在几百毫秒内给出结果,计价规则一改就得全文搜索硬编码的数字。这份网约车打车小程序项目,把乘客端、司机端和一套常驻服务端放在一起,跑起来能看到完整闭环:下单、派单、接单、到达、行程中、支付、评价,每一环都有对应页面和接口。适合三类人看:想拿小程序做真实业务练手的,想搞懂长连接推送与位置服务的,以及手上有个本地出行、跑腿、货运场景想套一层壳的。

2. 技术选型与工程结构:双端小程序怎么拆包,服务端为什么不能只靠云开发

2.1 角色拆分:乘客端、司机端、平台调度

这个项目里一个账号可以在两种身份之间切换,但页面体系是彻底分开的。乘客端负责发单、看司机位置、支付、评价;司机端负责上线接单、上报位置、操作订单节点(到达、开始行程、结束行程);平台调度不在小程序里,是服务端的一个常驻进程,负责把订单推给合适的司机、处理超时未接单的订单。

为什么不做成"一个小程序一个角色"?因为审核和运营成本。两个小程序就要两套资质、两次提审、两个版本节奏,而这个项目的演示属性更强,用同一份代码通过role字段切分包,改造成本低。真上线运营时再拆成两个小程序,前端代码几乎不用改,只是app.json里的入口页不同。

分包策略是这里第一个要做对的决策。主包里只放登录、首页、我的这几个公共页,乘客侧的页面全塞进packagePassenger,司机侧的塞进packageDriver。新手最常见的翻车是把司机端的"接单大厅"设成 tabBar 页面——tabBar 页面必须在主包里,分包页面不能当 tab。这个坑后面第 5 章还会细说。

2.2 技术栈清单

选型上没太多花活,都是这条路线上最稳的组合。列一下,方便你对照自己手上的环境:

层技术在这个项目里干什么
乘客端/司机端小程序原生框架页面、分包、授权、支付调起
地图平台官方地图组件 + 位置服务 SDK选点、逆地址解析、路线规划、marker 渲染
服务端Node.js + Koa订单、计价、用户、支付回调
数据库MySQL订单、用户、车辆、计价规则
缓存/位置Redis司机 GEO 集合、在线状态、订单临时态
长连接ws(WebSocket)司机位置上报、乘客端接收、派单推送
定时任务node-cron派单轮次、超时订单回收、行程异常检测

关于"要不要上云开发",我的态度很明确:做 demo 可以,做打车不行。云开发没有常驻长连接,WebSocket 那条链路就得换成长轮询,司机位置延迟会从三秒掉到十几秒;附近司机检索也没法用 Redis 的 GEO 命令,只能自己在数据库里算经纬度距离,几千个司机就开始卡。所以这个项目的服务端是一套独立部署的 Node 服务,云开发只在早期原型阶段用来快速搭登录。

提示:如果你只是想做课程设计或演示,把 WebSocket 换成 5 秒轮询也能跑通,但别在简历上写"实时",面试官一问就露馅。

2.3 目录结构:前后端放在一个仓库里

项目是单仓结构,小程序源码和服务器代码并排。这种布局对二次开发友好,改一个接口不用在两个仓库之间切来切去:

ride-hailing-miniapp/ ├── miniprogram/ # 小程序端 │ ├── app.js # 全局登录态、WebSocket 单例 │ ├── app.json # 分包配置、权限声明 │ ├── pages/ # 主包:登录、首页、我的 │ ├── packagePassenger/ # 乘客分包 │ │ ├── pages/call/ # 叫车页(选点 + 车型) │ │ ├── pages/trip/ # 行程页(看司机位置) │ │ └── pages/pay/ # 支付 + 评价 │ ├── packageDriver/ # 司机分包 │ │ ├── pages/hall/ # 接单大厅 │ │ ├── pages/workbench/ # 工作台(到达/开始/结束) │ │ └── pages/income/ # 流水分成 │ ├── components/ # 车型卡片、司机卡片、计价明细 │ ├── services/ # 所有 request 封装 │ └── utils/ # 坐标转换、格式化、常量 ├── server/ # 服务端 │ ├── app.js # Koa 入口,挂 HTTP + WS │ ├── routes/ # 路由 │ ├── controllers/ # 参数校验 + 调用 service │ ├── services/ # 订单、派单、计价、支付 │ ├── ws/ # 长连接与心跳 │ ├── jobs/ # 定时任务 │ └── config/ # 数据库、Redis、支付配置 └── sql/ # 建表与初始数据

services和utils这两层要分清:services只管发请求,不做业务判断;坐标转换、金额格式化这类纯函数放utils。我见过不少项目把gcj02转bd09的代码写在页面里,改一次坐标体系要翻二十个文件。

2.4 三张核心表先立住

数据库设计不用一上来就画 ER 图,抓住订单、司机、计价规则三张表就能跑通主流程:

-- 订单表:状态字段是整条链路的核心 CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, passenger_id BIGINT NOT NULL, driver_id BIGINT DEFAULT NULL, from_lng DECIMAL(10,6) NOT NULL, from_lat DECIMAL(10,6) NOT NULL, from_name VARCHAR(128), to_lng DECIMAL(10,6), to_lat DECIMAL(10,6), to_name VARCHAR(128), car_type TINYINT NOT NULL DEFAULT 1, -- 1 快车 2 专车 3 六座 city_code VARCHAR(16) NOT NULL, -- 分城市计价、分城市派单 estimate_km DECIMAL(8,2), estimate_min INT, actual_km DECIMAL(8,2), actual_min INT, amount DECIMAL(10,2) DEFAULT 0, -- 最终乘客应付 status VARCHAR(16) NOT NULL DEFAULT 'CREATED', expire_at DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_status_city (status, city_code), KEY idx_passenger (passenger_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 司机表:位置不放这里,放 Redis,这里只存静态信息 CREATE TABLE drivers ( id BIGINT PRIMARY KEY AUTO_INCREMENT, phone VARCHAR(20) UNIQUE NOT NULL, plate_no VARCHAR(16) NOT NULL, car_type TINYINT NOT NULL, audit_status TINYINT DEFAULT 0, -- 0 待审 1 通过 2 驳回 service_city VARCHAR(16) NOT NULL ) ENGINE=InnoDB; -- 计价规则表:一份配置驱动全部价格计算 CREATE TABLE fare_rules ( id INT PRIMARY KEY AUTO_INCREMENT, city_code VARCHAR(16) NOT NULL, car_type TINYINT NOT NULL, base_price DECIMAL(8,2) DEFAULT 10.00, -- 起步价 base_km DECIMAL(6,2) DEFAULT 3.00, -- 起步含里程 base_minutes INT DEFAULT 8, -- 起步含时长 minute_price DECIMAL(6,2) DEFAULT 0.35, -- 每分钟时长费 km_segments JSON, -- 里程分段:[{"from":0,"to":10,"price":2.2},...] night_ratio DECIMAL(4,2) DEFAULT 1.20, -- 夜间上浮比例 platform_ratio DECIMAL(4,2) DEFAULT 0.20, -- 平台抽成 UNIQUE KEY uk_city_car (city_code, car_type) ) ENGINE=InnoDB;

三张表里最关键的是orders.status和fare_rules.km_segments。前者决定了后面所有接口的合法操作边界,后者决定了改价格不用动一行代码。JSON字段在 MySQL 5.7 以上都支持,如果目标环境更老,把它拆成fare_rule_segments子表也行。

3. 核心链路拆解:一次叫车从点击到司机接单

3.1 订单状态机:七个状态和两条旁路

打车业务的复杂度几乎全压在状态机上。这个项目定义了七个主状态:

// miniprogram/utils/constants.js export const ORDER_STATUS = { CREATED: 'CREATED', // 已下单,等待接单 ACCEPTED: 'ACCEPTED', // 司机已接单,正在赶往上车点 ARRIVED: 'ARRIVED', // 司机已到达上车点 STARTED: 'STARTED', // 行程中 COMPLETED:'COMPLETED', // 行程结束,待支付 PAID: 'PAID', // 已支付,订单完成 CANCELED: 'CANCELED' // 已取消(乘客/司机/系统超时) }; // 允许的状态迁移,服务端每次改状态都要查这张表 export const STATUS_FLOW = { CREATED: ['ACCEPTED', 'CANCELED'], ACCEPTED: ['ARRIVED', 'CANCELED'], ARRIVED: ['STARTED', 'CANCELED'], STARTED: ['COMPLETED'], COMPLETED: ['PAID'], PAID: [], CANCELED: [] };

这张表的写法有讲究:它不是给前端画流程图用的,是服务端updateStatus的准入校验。司机点"开始行程"时,如果当前状态还是ACCEPTED而不是ARRIVED,接口直接拒绝。我见过一个项目为了省事,状态更新就写UPDATE orders SET status=?,结果司机手快点两下"结束行程",订单从STARTED直接跳到PAID的中间态被跳过,账都算不平。血泪经验:状态机这东西,前三天嫌它啰嗦,后面每天都庆幸有它。

两条旁路分别是"乘客取消"和"超时未接单"。取消不是直接置CANCELED,中间还有一段"取消责任判定"——司机已接单且距离上车点小于 500 米时取消,乘客可能要付违约金。超时未接单由定时任务处理:每分钟扫一遍status='CREATED' AND expire_at < NOW()的订单,置为CANCELED并给乘客推一条消息。

3.2 下单接口:事务里只做两件事

下单是整条链路的第一环,它要做的事其实很少:校验、落单、把订单推进派单队列。不要在接口里同步找司机,那会让响应时间从几十毫秒飙到几秒:

// server/controllers/order.js const dayjs = require('dayjs'); async function createOrder(ctx) { const { from, to, carType, cityCode, estimateKm, estimateMin } = ctx.request.body; const passengerId = ctx.state.user.id; // 1. 一个乘客同时只能有一单进行中,避免重复下单刷单 const running = await db.query( `SELECT id FROM orders WHERE passenger_id = ? AND status IN ('CREATED','ACCEPTED','ARRIVED','STARTED')`, [passengerId] ); if (running.length) ctx.throw(400, '您还有未完成的行程'); const orderNo = `T${dayjs().format('YYMMDDHHmmss')}${Math.floor(Math.random() * 900 + 100)}`; const conn = await db.getConnection(); try { await conn.beginTransaction(); // 2. 落订单,给 90 秒派单窗口 const [res] = await conn.execute( `INSERT INTO orders(order_no, passenger_id, from_lng, from_lat, from_name, to_lng, to_lat, to_name, car_type, city_code, estimate_km, estimate_min, status, expire_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,'CREATED', DATE_ADD(NOW(), INTERVAL 90 SECOND))`, [orderNo, passengerId, from.lng, from.lat, from.name, to.lng, to.lat, to.name, carType, cityCode, estimateKm, estimateMin] ); // 3. 只入队,不在这里等派单结果 await redis.lpush('dispatch:queue', JSON.stringify({ orderId: res.insertId, round: 1 })); await conn.commit(); ctx.body = { code: 0, data: { orderId: res.insertId, orderNo, expireIn: 90 } }; } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } }

参数里cityCode必须由前端传或由服务端根据坐标反查,它是后面派单和计价的分桶键。estimateKm和estimateMin是前端调路线规划接口拿到的预估,只用于列表展示,最终结算一定用司机端上报的实际里程。expire_at设 90 秒是行业里比较常见的派单窗口,改这个值要同步改定时任务的扫描间隔,不然后端会有一段时间的订单"卡在中间"。

注意redis.lpush放在事务里这一层:Redis 不受 MySQL 事务保护,如果commit失败而lpush已经执行,队列里会有一条指向不存在订单的消息。派单消费者拿到消息后要再查一次订单表,查不到就丢弃,这是幂等的第一道保险。

3.3 附近司机检索:Redis GEO 怎么用才不慢

派单最核心的问题:这个订单,三公里内有哪些空闲司机?传统做法是SELECT * FROM driver_location WHERE lat BETWEEN ... AND lng BETWEEN ...,几万条数据就开始抖。这个项目用 Redis 的 GEO 集合:

// server/services/dispatch.js async function findNearbyDrivers(order, radius = 3000, limit = 20) { // key 按 城市 + 车型 分桶,避免全国司机挤一个集合 const key = `geo:drivers:${order.city_code}:${order.car_type}`; const list = await redis.geosearch( key, 'FROMLONLAT', order.from_lng, order.from_lat, // 以乘客上车点为圆心 'BYRADIUS', radius, 'm', // 半径 3000 米 'ASC', 'COUNT', limit, // 由近到远取 20 个 'WITHDIST', 'WITHCOORD' ); if (!list.length) return []; // 过滤掉正在服务中的司机(Redis Set 记录忙线) const busyFlags = await redis.smismember('driver:busy', list.map((i) => i[0])); return list.filter((_, idx) => busyFlags[idx] === 0); }

GEOSEARCH是 Redis 6.2 之后的新命令,老版本用GEORADIUS代替,两者性能差不多,新命令的参数顺序更符合直觉。几个参数要盯住:

  • radius不要写死 3000。第一轮派单用 3 公里,第二轮(30 秒后)扩到 5 公里,第三轮扩到 8 公里,这是"扩大范围"的标准做法。半径越大命中率越高,但司机赶来时间也越长,乘客体验会差。
  • limit取 20 是经验值。一次推太多司机,抢单列表刷屏;推太少,热门时段根本没人接。返回结果还要按"司机今日接单数""服务分"二次排序,纯按距离排会让老司机永远抢不到近单。
  • 分桶键里的car_type不能省。快车和六座车的司机池是两拨人,混在一起会推出接不了单的司机。

司机上线时把他的位置写进这个集合:GEOADD geo:drivers:310000:1 121.47 31.23 driverId。司机下线或掉线时ZREM。这里有个细节——司机端切到后台,位置就不再上报了,如果只靠下线事件清理,集合里会堆一堆"幽灵司机"。所以每个司机的位置还要配一个 60 秒过期的driver:loc:{id}键,派单前用MGET校验一遍最近上报时间,超过 60 秒的直接跳过。

3.4 实时位置:长连接的心跳、广播与断线补偿

位置推送是打车体验的核心。乘客看到的那个小车图标,正常情况下延迟应该在一到三秒。这个项目用 WebSocket 双向通信:

// server/ws/index.js wss.on('connection', (ws, req) => { const { token } = qs.parse(url.parse(req.url).query); const user = verifyToken(token); // 复用 HTTP 那套 JWT ws.userId = user.id; ws.role = user.role; // 'passenger' | 'driver' ws.isAlive = true; ws.on('message', async (raw) => { const msg = JSON.parse(raw); if (msg.type === 'PONG') { ws.isAlive = true; return; } if (msg.type === 'LOCATION' && ws.role === 'driver') { const { lng, lat, orderId, cityCode, carType } = msg.data; // 1. 更新 GEO 集合与带 TTL 的最近位置 await redis.geoadd(`geo:drivers:${cityCode}:${carType}`, lng, lat, ws.userId); await redis.set(`driver:loc:${ws.userId}`, `${lng},${lat}`, 'EX', 60); // 2. 手上若有进行中订单,把位置转发给对应乘客 if (orderId) { const passengerId = await redis.get(`order:${orderId}:passenger`); if (passengerId) { pushToUser(passengerId, { type: 'DRIVER_LOCATION', data: { orderId, lng, lat } }); } } } }); ws.on('close', async () => { await redis.srem('driver:online', ws.userId); await redis.del(`driver:loc:${ws.userId}`); }); }); // 30 秒一轮心跳,两轮没回就踢,防止幽灵连接占着派单池 setInterval(() => { wss.clients.forEach((ws) => { if (!ws.isAlive) return ws.terminate(); ws.isAlive = false; ws.send(JSON.stringify({ type: 'PING' })); }); }, 30000);

关键点有三个。第一,连接建立时用 query 带 token 鉴权,不要等到第一条消息才验,否则匿名连接可以白占资源。第二,司机位置同时写 Redis 和转发给乘客,写 Redis 是为了下一次派单能查到,转发是为了当前这一单的乘客能立刻看到。第三,心跳用服务端主动PING,客户端回PONG,比反向检测可靠——小程序端切后台会被挂起,靠客户端自己发心跳根本发不出来。

乘客端接收位置的处理要克制:不要每收到一条就setData整个markers数组,那会触发整页重渲染,地图卡成幻灯片。项目里的做法是只更新markers[0]的经纬度:

// miniprogram/packagePassenger/pages/trip/trip.js onDriverLocation({ lng, lat }) { this.setData({ 'markers[0].longitude': lng, // 路径更新,只重绘这一个 marker 'markers[0].latitude': lat }); }

断线补偿是容易被漏掉的一环。小程序切后台超过 5 秒,socket 会被平台回收,回到前台时已经断开。项目在app.js里挂了重连,同时在行程页的onShow里主动拉一次"当前订单 + 司机最新位置":

// miniprogram/app.js connectSocket() { this.socket = wx.connectSocket({ url: `${WS_HOST}?token=${getToken()}` }); this.socket.onClose(() => { clearTimeout(this.reconnectTimer); this.reconnectTimer = setTimeout(() => this.connectSocket(), 2000); // 2 秒后重连 }); }

重连间隔不要用固定值,网络差的时候 1 秒一次会把服务端打满;也别用指数退避到 30 秒,用户等不了。2 秒固定间隔配合onShow的主动拉取,是对打车场景最合适的组合。

4. 计价、支付与本地跑通:把闭环补上

4.1 计价规则表:把价格从代码里赶出去

计价是二次开发里改得最频繁的部分,涨价、调起步价、加节假日加价,每次改都全量发版必死。这个项目把所有价格参数收进fare_rules表,代码里一个数字都不留:

// server/services/pricing.js function calcFare(rule, trip) { const { km, minutes, startAt } = trip; // 1. 起步价,含 base_km 公里 + base_minutes 分钟 let fare = Number(rule.base_price); // 2. 里程费:超出起步里程的部分按分段单价计 let restKm = Math.max(0, km - Number(rule.base_km)); const segments = typeof rule.km_segments === 'string' ? JSON.parse(rule.km_segments) : rule.km_segments; for (const seg of segments) { if (restKm <= 0) break; const cap = seg.to == null ? restKm : seg.to - seg.from; // 本段可计入的里程上限 const used = Math.min(restKm, cap); fare += used * Number(seg.price); restKm -= used; } // 3. 时长费:超出起步时长的部分按分钟计 fare += Math.max(0, minutes - rule.base_minutes) * Number(rule.minute_price); // 4. 夜间加价(22:00 - 次日 06:00) const hour = new Date(startAt).getHours(); if (hour >= 22 || hour < 6) fare *= Number(rule.night_ratio); // 5. 分账:乘客账单与平台抽成分开返回,别混成一个数 return { total: Math.round(fare * 100) / 100, platformFee: Math.round(fare * Number(rule.platform_ratio) * 100) / 100, driverIncome: Math.round(fare * (1 - Number(rule.platform_ratio)) * 100) / 100 }; }

逐段说明。第 1 段起步价直接取配置,base_km和base_minutes决定免费额度。第 2 段是里程分段,km_segments的结构是[{from:0, to:10, price:2.2}, {from:10, to:null, price:3.1}],意思是前 10 公里每公里 2.2 元,超过 10 公里每公里 3.1 元,to: null表示无上限。这样设计的好处是加"超过 30 公里每公里 4 元"只改 JSON,不动代码。第 3 段时长费按分钟计,堵车时这部分钱就是司机的补偿。第 4 段夜间加价用总价上浮,比按里程加价好算也好解释。

最后一段的Math.round(fare * 100) / 100不能省。浮点数相乘会出现28.900000000000002这种鬼东西,直接入库再取出来展示,乘客会看到一个长得离谱的价格。所有金额在服务端算完就 round 到分,前端只负责展示。

startAt传订单的开始时间而不是当前时间,否则一个跨零点的行程,结算时刚好过了 22 点就按夜间价算了,乘客投诉你没商量。

4.2 支付回调:验签、幂等和状态推进

小程序的支付流程是:服务端统一下单拿到prepay_id→ 前端调起支付 → 平台回调服务端 → 服务端改订单状态。第三步最容易出事,因为平台会重复回调:

// server/controllers/pay.js async function payNotify(ctx) { // 1. 验签,不通过直接返回失败,别往下走 if (!verifySign(ctx.request.body)) { ctx.body = { code: 'FAIL', message: '签名错误' }; return; } const { out_trade_no: orderNo, transaction_id, total_fee } = decodeNotify(ctx.request.body); // 2. 幂等:先查订单当前状态,已是 PAID 直接返回成功 const [order] = await db.query('SELECT id, status, amount FROM orders WHERE order_no = ?', [orderNo]); if (!order) { ctx.body = { code: 'FAIL', message: '订单不存在' }; return; } if (order.status === 'PAID') { ctx.body = { code: 'SUCCESS' }; return; } // 3. 金额校验,防止改价攻击 if (Math.round(order.amount * 100) !== Number(total_fee)) { ctx.body = { code: 'FAIL', message: '金额不匹配' }; return; } // 4. 条件更新,靠 affectedRows 兜住并发重复回调 const [ret] = await db.execute( `UPDATE orders SET status = 'PAID', pay_no = ?, paid_at = NOW() WHERE order_no = ? AND status = 'COMPLETED'`, [transaction_id, orderNo] ); if (ret.affectedRows === 1) { await redis.lpush('notify:queue', JSON.stringify({ type: 'ORDER_PAID', orderNo })); } ctx.body = { code: 'SUCCESS' }; }

第一段验签是硬性要求,不验签等于把改单权限开放给全网。第二段幂等判断解决的是"同一笔支付回调三次"的问题,平台的重试机制很执着,收到非 SUCCESS 会一直重推。第四段用了WHERE status = 'COMPLETED'这个条件,配合affectedRows判断,是为了兜住两次回调几乎同时到达的并发场景——数据库行锁会让其中一条更新失败,affectedRows为 0,就不会重复发通知。

total_fee用的是"分"为单位,而orders.amount存的是元,比较前要乘 100 再取整。这个小坑我第一次做支付时踩过,金额一分不差却一直报"金额不匹配",查了两个小时才发现是19.9 * 100 = 1989.9999999999998。

4.3 本地跑起来:配置、启动、真机调试

服务端启动前要准备三样东西:MySQL 建库导表、Redis 起服务、配置文件填上小程序 AppID 和位置服务的 key。.env大致长这样:

# server/.env PORT=3000 MYSQL_HOST=127.0.0.1 MYSQL_USER=root MYSQL_PASSWORD=your_password MYSQL_DATABASE=ride_hailing REDIS_HOST=127.0.0.1 REDIS_PORT=6379 JWT_SECRET=change_this_in_prod APPID=your_miniprogram_appid APP_SECRET=your_miniprogram_secret MAP_KEY=your_location_service_key PAY_MCH_ID=your_merchant_id PAY_KEY=your_pay_key

启动顺序是:先mysql -u root -p < sql/init.sql导表,再redis-server起缓存,最后进server目录装依赖启动:

cd server npm install npm run dev # nodemon 热重载,改代码自动重启 # 看到 [ws] listening on 3001 和 [http] listening on 3000 就说明两条链路都起来了

小程序端在开发者工具里打开miniprogram目录,先在app.js里把BASE_URL和WS_HOST改成你本机的局域网 IP(不要写localhost,真机访问不到)。然后在工具右上角"详情 → 本地设置"里勾上"不校验合法域名",否则所有请求都会被拦。

真机调试才是真正的分水岭。开发者工具里定位一切正常,扫到手机上可能直接拿不到坐标。这里要动三处:app.json里声明位置权限、requiredPrivateInfos里加上getLocation和chooseLocation、后台的位置接口权限申请通过。前两项是代码层面,第三项是账号层面,缺一个就是fail: 系统错误。

提示:司机端的"模拟行驶"功能只在小程序开发者工具里有效,真机上不会自动移动。测试位置推送时,用两台手机(一台司机、一台乘客)或者一台手机加一个 Postman 模拟 WebSocket 客户端,别指望工具里点两下就能看到车在跑。

5. 避坑与排查:五个把项目卡住半天的坑

这一章列的都是在真机上真实翻过车的问题,现象和原因都写清楚,遇到同款照着查就行。

坑一:真机上定位拿不到坐标,工具里却一切正常

现象是开发者工具能正常显示当前位置,扫码到手机上wx.getLocation直接走fail回调,errMsg写着getLocation:fail system error。

原因是权限声明不完整。小程序从某版本起把位置接口收进了隐私协议管理,光在app.json写permission.scope.userLocation已经不够了,还要在requiredPrivateInfos里显式列出用到的高敏感接口,否则调用会被直接拦截。另一个原因是用户在手机上点了"拒绝"之后再也不会弹窗,后续调用一律失败。

解决办法分三层:先在app.json补齐声明,再在调用前用wx.getSetting查授权状态,用户之前拒过就走wx.openSetting引导重新授权。最后永远准备一个兜底——授权失败时让用户手动在地图上拖一个点,别把整个叫车流程卡死在定位上。

// miniprogram/utils/location.js async function getCurrentLocation() { const setting = await wx.getSetting(); if (setting.authSetting['scope.userLocation'] === false) { // 用户明确拒绝过,只能引导去设置页,不能直接重新调 const { confirm } = await wx.showModal({ title: '需要位置权限', content: '请在设置中开启定位' }); if (!confirm) throw new Error('USER_DENIED'); await wx.openSetting(); } return wx.getLocation({ type: 'gcj02' }); // 一定要 gcj02,与地图组件坐标系对齐 }

坑二:司机位置在地图上飘到几百米外

现象是乘客端看到的小车图标不在路上,而是飘进小区或者河里。

原因是坐标系没对齐。定位接口默认可以返回wgs84,而小程序地图组件用的是gcj02,两套坐标系差几百米。很多人在开发者工具里测试时不明显(工具模拟的位置本身就是 gcj02),一上真机立刻暴露。

解决办法是全程统一gcj02:wx.getLocation的type参数写死gcj02,从其他地图 SDK 拿到的坐标如果是bd09或wgs84,用utils/coord.js里的转换函数转一遍再入库。更隐蔽的一种情况是:后端存的是 gcj02,但司机端上报时用了wgs84,导致同一个司机在库里有两套坐标,派单时算出来的距离永远是错的。建议在数据表字段名上直接标lng_gcj、lat_gcj,看到不带的就知道要转换。

坑三:司机切后台回流后,乘客端司机图标不动了

现象是司机接单后把小程序切到后台导航,乘客端的小车停在原地,直到司机回到小程序才恢复。

原因是小程序切后台后,WebSocket 会被平台在几秒内回收,位置上报也就断了。这不是 bug,是平台的行为约束。

解决办法是两条腿走路。服务端加一层"断线检测":司机超过 30 秒没有位置上报,向乘客端推一条DRIVER_OFFLINE消息,前端显示"司机位置更新中"而不是让图标僵着。客户端在行程页onShow里主动拉一次最新位置做补偿,同时把断线重连的计时器放在app.js全局,不要放在页面里——页面销毁后计时器就没了,回前台根本不会重连。

坑四:支付回调全部成功,但订单有的变成已支付、有的没有

现象是同一批测试订单,有人在支付后正常跳转"行程完成",有人一直停在"待支付",手动点刷新又好了。

原因是支付回调存在并发重复推送,而代码里是"先查状态再更新"的写法,两个请求同时查到COMPLETED,然后都执行更新。更糟的情况是数据库更新成功但后面的发通知失败,订单已支付,乘客却没收到消息。

解决办法是把"判断"和"更新"合成一条 SQL,用WHERE status = 'COMPLETED'作为乐观锁,靠affectedRows决定要不要发通知,这就是 4.2 节那段代码的写法。另外补一个对账定时任务,每 10 分钟扫一遍status='COMPLETED'且创建时间超过 30 分钟的订单,主动去支付平台查一次状态,把回调丢失的单补回来。

坑五:分包里的司机端页面跳不过去,报 "page not found"

现象是乘客端登录后想切到司机身份,点了按钮直接白屏,控制台报Page "packageDriver/pages/hall/hall" is not found。

原因有两种。第一种是app.json里subPackages的root写错,或者页面路径多写了一层目录。第二种更隐蔽——试图把司机端的接单大厅设成 tabBar 页面,而 tabBar 页面必须在主包,分包页面不能出现在tabBar.list里。

解决办法:分包路径严格对齐root + pages/xxx/xxx,改完在开发者工具的"分包"面板里看一眼体积和路径是否对得上。如果司机端确实需要一个常驻入口,就在主包放一个"切换身份"的跳转页,用wx.navigateTo进分包,不要动 tabBar。另外主包体积要控制住,把图片、字体这些静态资源全扔到 CDN 或者分包里,超了 2MB 连预览都过不去。

6. 二次开发:把"仿一个"改成"能干活的"

这套代码拿去做毕业设计够用,但要接到真实业务上,最先要改的是派单策略。项目默认是"广播抢单"——把订单推给附近 20 个司机,谁先点谁得。真实场景里往往需要"平台派单",也就是按服务分、接单率、距离加权算出一个最优司机直接指派。改造点集中在services/dispatch.js里,把findNearbyDrivers的返回值按权重排序取第一个,然后调用assignOrder直接写driver_id,跳过抢单页面。给乘客的感知就是"系统正在为你匹配司机",三十秒内出结果。

第二个改造点是多城市。现在的city_code是全程贯穿的,看起来支持多城市,但计价规则表得手动往里插数据。可以加一个后台管理页,让运营按城市配置fare_rules,配完写 Redis 缓存,calcFare优先读缓存而不是查库。

第三个是行程轨迹回放。司机位置现在是实时转发完就丢,乘客想事后查"司机是不是绕路了"没有任何依据。改法很简单:在order_tracks表里按订单 ID 落点,每 10 秒采一次,行程结束后用小程序地图的polyline画出来。表结构就三个字段order_id、lng、lat、ts,按月分表。

改造方向主要动的地方大概工作量
广播抢单改平台派单services/dispatch.js、司机端接单页1~2 天
多城市运营配置新建后台页、fare_rules缓存层3 天
行程轨迹回放新增轨迹表、行程详情页加地图1 天
接入自有支付渠道controllers/pay.js、services/pay.js看渠道文档,2 天起

改的时候有个习惯我一直坚持:任何动到订单状态的改动,先在STATUS_FLOW里加一条合法迁移,再写业务代码。刚做这行的时候图快,直接写UPDATE orders SET status = 'PAID',测试环境跑得飞起,上线第二天就出现"未开始行程却已支付"的脏数据,翻了半天日志才发现是定时任务把过期订单误标了。从那以后我每次改状态机,都强制先把迁移表改完,再写一行代码,本地跑一遍全状态流转的用例才敢提交。

希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进