
简介这份小程序源码包以模仿滴滴打车核心流程为目标面向具备基础微信小程序开发能力、希望理解出行类应用实现的学习者。项目围绕叫车、订单、个人中心等场景组织页面涉及地理位置获取、地图展示、路线规划、实时通信、支付与订单状态管理等关键环节可用于课堂演示、课程设计或个人练习。压缩包共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_SphereMySQL 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里真正会影响行为的只有几个其余都是常规连接串PORT3000 # HTTP 端口 WS_PORT3001 # 长连接端口小程序端要单独配 MYSQL_URLmysql://root:123456127.0.0.1:3306/ride_demo JWT_SECRETdev_only_change_me # 换掉否则任何人都能伪造司机身份 MAP_KEYyour_map_key_here # 地图服务商的 Web 服务 key服务端算路用 DISPATCH_RADIUS_M3000 # 派单初筛半径单位米小程序端只剩一处必改miniprogram/app.js里的globalData.baseUrl和wsUrl指向你本机局域网 IP真机调试不要写127.0.0.1手机访问不到。同时在小程序开发者工具里勾上「不校验合法域名」否则ws://和http://全被拦。提示JWT_SECRET用默认值上线等于把司机接单接口公开。哪怕只是交课程设计这个也顺手改掉养成习惯比事后补救便宜。2.3 用一次下单请求验证链路UI 先放一边直接打接口。登录拿 token再下单两步就能判断服务端是否真的活着# 用模拟 code 换 tokenrole 决定后面能调乘客接口还是司机接口 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 应该是 0est_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_price9已取消无任意前置状态必须堵死的迁移有两条。第一条是 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 - Setws 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) -- 司机端行程列表 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 轨迹点只增不改按订单查定期归档 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;索引这块有个血泪经验别给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?roledriverGET双方列表按角色返回不同字段鉴权不做花活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 的WHEREUPDATE 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 会骗人——它总能渲染出点什么让你误以为系统是好的。真正决定这套小程序能不能用的是那条从司机手机到乘客屏幕的三秒链路。希望帮到你。本文还有配套的精品资源点击获取