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

发布时间:2026/10/24 10:08:19
网约车打车小程序:实时调度、状态机与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 集合、在线状态、订单临时态长连接wsWebSocket司机位置上报、乘客端接收、派单推送定时任务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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 司机表位置不放这里放 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 ) ENGINEInnoDB; -- 计价规则表一份配置驱动全部价格计算 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) ) ENGINEInnoDB;三张表里最关键的是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 米时取消乘客可能要付违约金。超时未接单由定时任务处理每分钟扫一遍statusCREATED 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 PORT3000 MYSQL_HOST127.0.0.1 MYSQL_USERroot MYSQL_PASSWORDyour_password MYSQL_DATABASEride_hailing REDIS_HOST127.0.0.1 REDIS_PORT6379 JWT_SECRETchange_this_in_prod APPIDyour_miniprogram_appid APP_SECRETyour_miniprogram_secret MAP_KEYyour_location_service_key PAY_MCH_IDyour_merchant_id PAY_KEYyour_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一上真机立刻暴露。解决办法是全程统一gcj02wx.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 分钟扫一遍statusCOMPLETED且创建时间超过 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、司机端接单页12 天多城市运营配置新建后台页、fare_rules缓存层3 天行程轨迹回放新增轨迹表、行程详情页加地图1 天接入自有支付渠道controllers/pay.js、services/pay.js看渠道文档2 天起改的时候有个习惯我一直坚持任何动到订单状态的改动先在STATUS_FLOW里加一条合法迁移再写业务代码。刚做这行的时候图快直接写UPDATE orders SET status PAID测试环境跑得飞起上线第二天就出现未开始行程却已支付的脏数据翻了半天日志才发现是定时任务把过期订单误标了。从那以后我每次改状态机都强制先把迁移表改完再写一行代码本地跑一遍全状态流转的用例才敢提交。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询