ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

网约车小程序源码闭环:订单状态机与WebSocket位置推送

网约车小程序源码闭环:订单状态机与WebSocket位置推送

简介:这份小程序源码包以模仿滴滴打车核心流程为目标,面向具备基础微信小程序开发能力、希望理解出行类应用实现的学习者。项目围绕叫车、订单、个人中心等场景组织页面,涉及地理位置获取、地图展示、路线规划、实时通信、支付与订单状态管理等关键环节,可用于课堂演示、课程设计或个人练习。压缩包共151个文件,约652KB,主要由80张png图像资源、20个wxss样式表、20个js逻辑脚本、16个json配置文件与15个wxml页面结构组成,分别承担视觉素材、界面样式、业务逻辑、项目配置和页面骨架等用途。已有3813人学习或下载。读者可从中参考全局配置、页面目录划分、工具函数与地图SDK封装方式,梳理乘客与司机之间的订单流转和消息交互思路;需要注意原项目接口需自行替换填写,更适合作为学习演示与二次开发的基础样例。

1. 一份能跑通的打车小程序,值不值得拆

搜「网约车小程序」出来的东西,一大半是首页截图配上「功能齐全」四个字,真拿下来一跑,下单按钮是假的、司机列表是写死的、地图上那个小汽车根本不动。模拟项目X 属于少数能把「下单—派单—司机接单—位置上报—行程结束—计价」这条闭环完整跑下来的小程序源码包:乘客端和司机端两套页面,服务端用 Node.js 出 REST 接口加一条长连接通道,MySQL 存订单、司机、车辆和轨迹点。它不是某网约车平台的复制品,而是一个把核心链路压缩到能读完的骨架。适合三类人:课程设计要交东西的学生、想练实时位置推送的前端、需要一个全栈样例的独立开发者。下面按「怎么跑起来—订单和位置怎么走—表和接口怎么定—哪会翻车—怎么验证交付」拆。

2. 把项目跑起来:目录边界、依赖与第一条下单请求

拿到源码包之后最容易犯的错,是打开小程序开发者工具看到首页渲染出来了,就以为项目通了。这个工程里小程序端和服务端是两套独立依赖,前端能渲染只说明模板没错,真正决定它能不能用的那条链路在后端。所以第一步不是改 UI,是把服务起起来、数据库建好、再拿一条 curl 把下单接口打通。这条链路通了,后面所有页面才有意义。

2.1 目录结构与模块边界

先认清楚哪块该动、哪块别乱动。工程大致是这样切的:

目录里面放什么改动频率
miniprogram/小程序端:乘客页、司机页、公共组件、请求与 socket 封装高
server/Node 服务:路由、订单服务、派单逻辑、WebSocket 入口中
server/db/建表语句、种子数据、迁移脚本低
tools/模拟司机、批量造单、数据清理脚本低
docs/接口约定表与订单状态机说明中

关键分界线是server/services/和server/routes/。路由层只做参数校验和响应包装,业务逻辑全部沉在 services 里,派单、计价、状态流转各一个文件。这个切法对二次开发很友好——想换派单算法只改一个文件,不用在路由里翻。我见过不少人上手就把逻辑往路由里塞,最后改一个规则要动五个文件,那才是真的后悔药没处买。

小程序端这边,utils/request.js统一封装了wx.request的鉴权头、超时和错误码;utils/socket.js负责长连接的建连、重连和心跳。这两处是全项目最值得先读的文件,因为它们决定了后面所有页面能不能拿到数据。

2.2 环境准备与本地启动

服务端跑在 Node 18 上,数据库用 MySQL 8(派单里的距离计算依赖ST_Distance_Sphere,MySQL 5.7 以下没有),Redis 可选,只在做订单超时取消的延迟队列时才需要。整套启动流程就五步:

# 1. 装服务端依赖(小程序端不需要 npm install,除非你用了构建工具) cd server && npm install # 2. 复制环境变量模板,把数据库连接和地图服务 key 填进去 cp .env.example .env # 3. 建表:脚本里已经带好 orders / drivers / users / track_points 四张表 npm run db:init # 4. 灌种子数据:20 个司机 + 20 辆车,坐标散布在城市中心 3 公里内 npm run db:seed # 5. 起服务,HTTP 和 WebSocket 走同一个进程不同端口 npm run dev

.env里真正会影响行为的只有几个,其余都是常规连接串:

PORT=3000 # HTTP 端口 WS_PORT=3001 # 长连接端口,小程序端要单独配 MYSQL_URL=mysql://root:123456@127.0.0.1:3306/ride_demo JWT_SECRET=dev_only_change_me # 换掉,否则任何人都能伪造司机身份 MAP_KEY=your_map_key_here # 地图服务商的 Web 服务 key,服务端算路用 DISPATCH_RADIUS_M=3000 # 派单初筛半径,单位米

小程序端只剩一处必改:miniprogram/app.js里的globalData.baseUrl和wsUrl指向你本机局域网 IP(真机调试不要写127.0.0.1,手机访问不到)。同时在小程序开发者工具里勾上「不校验合法域名」,否则ws://和http://全被拦。

提示:JWT_SECRET用默认值上线,等于把司机接单接口公开。哪怕只是交课程设计,这个也顺手改掉,养成习惯比事后补救便宜。

2.3 用一次下单请求验证链路

UI 先放一边,直接打接口。登录拿 token,再下单,两步就能判断服务端是否真的活着:

# 用模拟 code 换 token,role 决定后面能调乘客接口还是司机接口 curl -s -X POST http://127.0.0.1:3000/api/auth/login \ -H 'Content-Type: application/json' \ -d '{"code":"mock_code_1001","role":"passenger"}' # 拿到 token 后下单,from/to 用真实经纬度,别用 0,0 糊弄 curl -s -X POST http://127.0.0.1:3000/api/orders \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer <上一步返回的 token>' \ -d '{ "from": {"lat": 30.2741, "lng": 120.1551}, "to": {"lat": 30.2875, "lng": 120.1688}, "cityCode": "0100" }'

下单接口内部干了四件事,顺序不能乱:校验坐标合法性(纬度 -90~90、经度 -180~180,很多人只校验了非空)→ 调地图服务算直线距离和预计时长 → 按计价规则算出est_price→ 落库并返回order_no和status: 0。返回体里如果est_price是 0,八成是算路接口没通或者 key 没配,别急着去查数据库。

验证成功不看返回值,看库:

-- 下完单立刻查,status 应该是 0,est_price 大于 0 SELECT order_no, status, distance_m, est_price, created_at FROM orders ORDER BY id DESC LIMIT 1;

这条 SQL 出结果,说明 HTTP、鉴权、算路、计价、落库五段全通。到这里再去动 UI,心里才有底。

3. 订单状态机与实时位置:派单不是「谁近给谁」

打车类程序里最容易写崩的不是地图,是状态。地图画错了顶多难看,状态错了会出现「司机还没接到人就开始计费」这种业务事故。这一章把两件事拆开:订单在什么条件下允许变成什么状态,以及司机的位置怎么从手机走到乘客的屏幕上。前者是规则,后者是通道,两者用一个orderId绑在一起。

3.1 订单状态机:五个状态与两条非法迁移

工程里用的是最朴素的五态模型,够用且好排查:

状态码名称允许的下一状态触发方
0待接单1 / 9司机接单 / 乘客或系统取消
1已接单2 / 9司机到达上车点 / 乘客取消
2行程中3司机确认接到乘客
3已完成无司机结束行程,写actual_price
9已取消无任意前置状态

必须堵死的迁移有两条。第一条是 1 直接跳 3:司机点了结束但没点「接到乘客」,等于空跑一单还扣钱。第二条是 3 回退到 2:已完成的订单再被改成行程中,会重复计价。这两条在服务端用一张常量表拦,而不是在每个接口里写 if:

// server/services/orderState.js —— 状态迁移白名单,任何改动都必须过这张表 const ALLOWED = { 0: [1, 9], 1: [2, 9], 2: [3], 3: [], 9: [], }; function canTransit(from, to) { return (ALLOWED[from] || []).includes(to); } // 真正的更新一定要带上前置状态做条件,靠 SQL 的原子性兜住并发 async function transit(orderId, from, to, extra = {}) { if (!canTransit(from, to)) throw new Error(`illegal transit ${from} -> ${to}`); const [r] = await db.execute( `UPDATE orders SET status = ?, updated_at = NOW() WHERE id = ? AND status = ?`, [to, orderId, from] ); if (r.affectedRows === 0) throw new Error('state changed by others'); return r; }

WHERE id = ? AND status = ?这半句是整段代码的重点。把期望的旧状态写进条件里,等于让数据库帮你做乐观锁:两个司机同时抢一单,只有一个的affectedRows是 1,另一个直接抛错,前端提示「手慢了」。这比在 Node 里加内存锁靠谱得多,多进程部署也不会失效。

3.2 WebSocket 通道:订阅、心跳与重连

位置推送没有用轮询,因为司机 3 秒一个点、乘客端要平滑移动,轮询在弱网下会抖成一团。长连接这边服务端干三件事:验 token、按orderId分组、把司机上报的坐标转发给同组的乘客。心跳是必须的,运营商网络下假死连接很常见,不 ping 就会留下幽灵订阅者。

// server/ws/index.js —— 一条连接只做两件事:订阅订单、转发位置 const WebSocket = require('ws'); const jwt = require('jsonwebtoken'); const rooms = new Map(); // orderId -> Set<ws> function join(orderId, ws) { if (!rooms.has(orderId)) rooms.set(orderId, new Set()); rooms.get(orderId).add(ws); ws.orderId = orderId; } function broadcast(orderId, payload) { const set = rooms.get(orderId); if (!set) return; const data = JSON.stringify(payload); set.forEach((c) => c.readyState === WebSocket.OPEN && c.send(data)); } const wss = new WebSocket.Server({ port: Number(process.env.WS_PORT) }); wss.on('connection', (ws, req) => { const token = new URL(req.url, 'http://local').searchParams.get('token'); try { ws.user = jwt.verify(token, process.env.JWT_SECRET); } catch (e) { return ws.close(4001, 'bad token'); // 鉴权失败直接断,别留半开连接 } ws.isAlive = true; ws.on('pong', () => { ws.isAlive = true; }); ws.on('message', (raw) => { const msg = JSON.parse(raw); if (msg.type === 'subscribe') join(msg.orderId, ws); if (msg.type === 'driver:location') { // 转发优先级高于落库:乘客看到的延迟比历史轨迹重要 broadcast(msg.orderId, { type: 'location', lat: msg.lat, lng: msg.lng, ts: Date.now() }); } }); ws.on('close', () => rooms.get(ws.orderId)?.delete(ws)); }); // 30 秒一轮心跳,没回 pong 的直接 terminate,避免 Set 里堆死连接 setInterval(() => { wss.clients.forEach((ws) => { if (!ws.isAlive) return ws.terminate(); ws.isAlive = false; ws.ping(); }); }, 30000);

小程序端对应的一段,重点在onClose里的重连,这一步省掉,用户切后台再回来就永久收不到位置了:

// miniprogram/utils/socket.js function connect(orderId, onLocation) { const token = wx.getStorageSync('token'); const socket = wx.connectSocket({ url: `${getApp().globalData.wsUrl}?token=${token}` }); socket.onOpen(() => { socket.send({ data: JSON.stringify({ type: 'subscribe', orderId }) }); }); socket.onMessage(({ data }) => { const msg = JSON.parse(data); if (msg.type === 'location') onLocation(msg); }); // 断线重连:指数退避,最多 5 次,别用 while 死循环 let retry = 0; socket.onClose(() => { if (retry++ >= 5) return; setTimeout(() => connect(orderId, onLocation), 1000 * retry); }); return socket; }

3.3 派单匹配:一个可解释的简化算法

「谁近给谁」在 demo 里够用,但一放真实数据就出问题——最近的司机可能刚取消过三单。工程里的做法是两段式:先用数据库粗筛出半径内、空闲、且十分钟内上报过位置的司机,再在内存里打分排序。粗筛放到 SQL 里是为了不把全城司机拉进内存。

// server/services/dispatch.js async function pickDrivers(order) { const R = Number(process.env.DISPATCH_RADIUS_M || 3000); // 1. 粗筛:状态空闲(1) + 位置新鲜(10 分钟内) + 半径内,最多 50 个 const [rows] = await db.execute( `SELECT id, lat, lng, ST_Distance_Sphere(POINT(lng, lat), POINT(?, ?)) AS dist_m, TIMESTAMPDIFF(MINUTE, updated_at, NOW()) AS idle_min FROM drivers WHERE status = 1 AND updated_at > NOW() - INTERVAL 10 MINUTE HAVING dist_m < ? ORDER BY dist_m ASC LIMIT 50`, [order.from_lng, order.from_lat, R] ); // 2. 打分:距离归一化后占七成,空驶时长占三成,封顶 30 分钟防止老司机刷分 return rows .map((d) => ({ driverId: d.id, distM: Math.round(d.dist_m), score: 0.7 * (1 - d.dist_m / R) + 0.3 * (Math.min(d.idle_min, 30) / 30), })) .sort((a, b) => b.score - a.score) .slice(0, 5); // 一次只推 5 个,多了就是骚扰 }

几个参数值得单独说。DISPATCH_RADIUS_M默认 3000,商圈场景可以调到 1500 提高接单速度,郊区得放到 8000,否则永远没人接;idle_min封顶 30 是防止一个挂机两小时的司机分数永远最高;slice(0,5)之后还要配一个 60 秒的超时任务,5 个人都没接就扩大半径重推——这段逻辑在tools/里有现成的定时脚本,直接改参数就能跑。

4. 数据库与接口:把「车在哪、单到哪」落到表结构

小程序项目最容易糊弄的地方就是数据层,反正页面能转起来就行。但打车类业务的查询几乎都带时间和状态条件,索引没设计好,订单量一上来列表页就开始转圈。这一章讲四张核心表怎么切、接口怎么定、计价参数放在哪最不容易被改坏。

4.1 四张核心表与索引取舍

订单、司机、用户、轨迹点,四张表各司其职,不要合并。订单表是主战场,状态流转全落这一张:

-- 订单主表:不拆表,不建外键,靠应用层保证一致性 CREATE TABLE orders ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, order_no CHAR(20) NOT NULL UNIQUE, -- 业务单号,对外只暴露这个 passenger_id BIGINT UNSIGNED NOT NULL, driver_id BIGINT UNSIGNED DEFAULT NULL, -- 未接单时为 NULL status TINYINT NOT NULL DEFAULT 0, from_lat DECIMAL(10,7) NOT NULL, from_lng DECIMAL(10,7) NOT NULL, to_lat DECIMAL(10,7) NOT NULL, to_lng DECIMAL(10,7) NOT NULL, distance_m INT NOT NULL DEFAULT 0, est_price DECIMAL(10,2) NOT NULL DEFAULT 0, actual_price DECIMAL(10,2) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_status_created (status, created_at), -- 待接单池子按时间捞 KEY idx_passenger (passenger_id, created_at), -- 我的订单列表 KEY idx_driver (driver_id, status) -- 司机端行程列表 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 轨迹点:只增不改,按订单查,定期归档 CREATE TABLE track_points ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, order_id BIGINT UNSIGNED NOT NULL, driver_id BIGINT UNSIGNED NOT NULL, lat DECIMAL(10,7) NOT NULL, lng DECIMAL(10,7) NOT NULL, seq INT NOT NULL, -- 客户端上报序号,用于去重排序 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_order_seq (order_id, seq) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

索引这块有个血泪经验:别给status单独建索引。整个库的订单九成集中在 3(已完成),单列索引的选择性极差,优化器基本不走。用(status, created_at)这种组合索引,让「捞 20 条待接单」变成一次范围扫描。经纬度也别急着上空间索引,几百条司机数据用ST_Distance_Sphere配合status过滤已经足够,真到几万司机再考虑加POINT列和SPATIAL INDEX。

4.2 接口约定与登录态怎么传

接口命名统一走/api/{资源}/{动作},返回体固定三段式,前端拦截器只认code:

接口方法鉴权说明
/api/auth/loginPOST否code 换 token,返回 role
/api/ordersPOST乘客下单,返回 order_no
/api/orders/:id/acceptPOST司机接单,带前置状态条件
/api/orders/:id/statusPATCH双方推进状态,走白名单
/api/orders?role=driverGET双方列表,按角色返回不同字段

鉴权不做花活,Bearer token 放头部,中间件解析完挂到req.user:

// server/middleware/auth.js module.exports = function auth(requiredRole) { return (req, res, next) => { const raw = (req.headers.authorization || '').replace('Bearer ', ''); if (!raw) return res.status(401).json({ code: 401, msg: 'no token' }); try { req.user = jwt.verify(raw, process.env.JWT_SECRET); } catch (e) { return res.status(401).json({ code: 401, msg: 'token expired' }); // 前端据此跳登录 } // 司机接口不能被乘客 token 调通,这行不能省 if (requiredRole && req.user.role !== requiredRole) { return res.status(403).json({ code: 403, msg: 'forbidden' }); } next(); }; };

返回体统一{ code, msg, data },业务错误也走 HTTP 200 + 非零 code,别混着用 4xx 和业务码,前端会写两套判断。

4.3 计价参数:写死在代码里还是放配置

计价是最容易被改坏的一块。工程里把它单独抽成pricing.js,参数集中在一个对象里,改价只动这一个文件,测试也只需覆盖这一个函数:

// server/services/pricing.js const RULES = { baseFee: 8, // 起步价,含 baseKm 公里 baseKm: 2, perKm: 2.2, // 超出起步里程后每公里单价 perMin: 0.35, // 时长费,按预计时长算,堵车场景的近似 minFare: 10, // 兜底最低消费,防止短途算出 3 块钱 }; function estimate(distanceM, durationS) { const extraKm = Math.max(distanceM / 1000 - RULES.baseKm, 0); const raw = RULES.baseFee + extraKm * RULES.perKm + (durationS / 60) * RULES.perMin; return Math.max(Math.round(raw * 100) / 100, RULES.minFare); } module.exports = { RULES, estimate };

durationS来自算路接口,服务端必须缓存一次结果,别在下单和结束时各调一次——两次路况不同,价格会对不上,用户一眼就看出来。常见做法是把distance_m和duration_s一起写进订单行,结束时按实际轨迹重算distance_m再套同一个estimate,差异控制在合理范围内。夜间加价这种需求,就在RULES外面套一层时间段判断,别把倍数硬编码进公式。

5. 避坑与排查:五个能把 demo 卡死在现场的问题

这套代码在本地跑通和在真机上跑通,中间隔着一整个数量级的坑。下面五条是我复现时真实撞上的,每条都按现象、原因、解决写,遇到同类问题可以直接对号。

5.1 定位与地图相关的三个坑

现象一:开发者工具里坐标正常,真机上永远是同一个默认点。原因不是代码错,是授权没拿到——位置接口在用户未授权时不会报错,而是返回上一次的缓存值或者默认值,看起来「有数据」。解决分两步:在app.json里补上位置权限声明和requiredPrivateInfos字段,然后在进入打车页之前主动调一次授权,被拒绝时给一个「手动选点」的兜底入口。硬拦着不让用是最差的选择,用户直接卸载。

现象二:地图上的车一卡一卡地跳,不是平滑移动。原因通常是每收到一个位置点就setData整个 markers 数组,几十个标记全量 diff,小程序渲染层扛不住。改成只更新变化的那一项,路径写成markers[0].latitude这种形式,配合includePoints单独调用一次,帧率立刻回来。轨迹点多的时候再加个节流,200 毫秒合并一次渲染。

现象三:地图 key 配好了,控制台还是报鉴权失败。大概率是两个 key 弄混了——前端 SDK 用的 key 和服务端算路用的 Web 服务 key 是两套,白名单校验方式也不一样。前端 key 要绑小程序身份,服务端 key 要配 IP 白名单。排查顺序是先在浏览器里直接请求一次算路接口,能通说明服务端 key 没问题,再去查前端。

5.2 实时通道与订单状态的两个坑

现象四:乘客下单成功,司机端一片安静,日志里也看不到订阅。原因是频道名没对齐:司机端订阅的是自己driverId的频道,服务端推的却是orderId的频道,两边字符串永远不相等。这类问题日志里看不出来,因为连接是活的、消息也发了,只是没人收。解决办法是在subscribe消息里同时带上orderId和role,服务端按 role 决定塞进哪个房间,并且在广播前打一条room size日志,为 0 就说明没人订阅。

现象五:同一个订单被两个司机同时接走,双方都收到了「接单成功」。典型的先查后改:两个请求几乎同时读到status = 0,都判定可以接,然后各自更新。改法只有一个,把判断塞进 SQL 的WHERE:UPDATE orders SET driver_id = ?, status = 1 WHERE id = ? AND status = 0,然后看affectedRows。为 0 就返回「已被抢」,前端弹提示并刷新列表。这个坑不加锁是修不干净的,加锁又会拖慢并发,条件更新是最省事的那条路。

注意:这五条里有四条在开发工具里复现不出来,只在真机或并发下暴露。交付前至少拿两台手机走一遍完整流程,一台当乘客一台当司机,别只靠模拟器。

6. 交付前的验证:三个检查项和一版模拟司机脚本

代码能跑和能交,差的是验证。手工点一遍只能覆盖主干,并发和时间相关的分支根本测不到,所以我在交付前固定跑三个检查。第一个是状态机自检:把所有非法迁移列出来,逐个调接口,确认全部返回错误码而不是默默成功。第二个是索引检查,把订单列表、待接单池、司机行程这三个高频查询各跑一次EXPLAIN,出现type: ALL就说明索引没命中,补组合索引。第三个是位置上报频率压测,用脚本模拟 20 个司机每 3 秒一个点,看服务端 CPU 和消息延迟,超过 500 毫秒就得把落库改成批量写。

第三条用脚本跑最省事,tools/里那版改改就能用:

// tools/simulate-driver.js —— 20 个司机并发上报位置,验证派单与推送 const WebSocket = require('ws'); const ORDER_ID = process.env.ORDER_ID; const TOKEN = process.env.DRIVER_TOKEN; Array.from({ length: 20 }, (_, i) => i + 1).forEach((driverId) => { const ws = new WebSocket(`ws://127.0.0.1:3001?token=${TOKEN}`); ws.on('open', () => { ws.send(JSON.stringify({ type: 'subscribe', orderId: ORDER_ID })); // 3 秒一个点,和真机上报频率对齐;加上微小随机偏移模拟漂移 const timer = setInterval(() => { ws.send(JSON.stringify({ type: 'driver:location', orderId: ORDER_ID, lat: 30.2741 + (Math.random() - 0.5) * 0.01, lng: 120.1551 + (Math.random() - 0.5) * 0.01, seq: Date.now(), })); }, 3000); ws.on('close', () => clearInterval(timer)); }); });

跑起来之后重点看三个数:服务端消息转发延迟、track_points的写入速率、以及乘客端地图的渲染帧率。延迟高但 CPU 不高,问题在落库;CPU 打满,说明广播写成了全量遍历;地图卡但服务端正常,回到 5.1 那条渲染问题。这套脚本还能反过来验证派单:在 20 个司机都在线的情况下下一单,看返回的前 5 个司机是不是按score排序、半径外的有没有被正确排除。

有个习惯从那以后我一直保持着:任何带实时链路的项目,先不写页面,先用脚本把服务端打满,看它在压力下的行为,再回头做 UI。因为 UI 会骗人——它总能渲染出点什么,让你误以为系统是好的。真正决定这套小程序能不能用的,是那条从司机手机到乘客屏幕的三秒链路。希望帮到你。

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

RELATED READING

延伸阅读

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