校园摆渡车微信小程序:实时定位、ETA估算与预约防超卖
简介面向微信小程序初学者与校园交通应用开发者的原创练手项目围绕摆渡车实时位置查询、上下车点与时刻表展示、座位预订等场景提供一套前后端示例。项目采用小程序前端与 Java Servlet/JSP 后端组合前端涉及 WXML、WXSS、JavaScript 页面交互及地图组件调用后端包含 Servlet、DAO、Bean 与 JSON 接口处理适合理解请求响应、数据存取和前后端联调流程。压缩包共 79 个文件约 1.01MB以 class、xml、java、png、json、js、wxss、jsp、wxml 等类型为主分别对应编译产物、配置与布局、后端源码、界面素材、页面逻辑和样式等目录按前端、后端与 Web 资源组织。已有 416 人学习可作为课程设计或入门实战参考帮助读者梳理小程序页面结构、接口调用、数据库访问与地图展示的实现思路。1. 校园摆渡车小程序把“车在哪”这件事变成可查的数字早八点从宿舍区到教学区那 1.8 公里走路要 22 分钟扫共享单车常遇到车桩空着而校园摆渡车的问题从来不是没有车是等车的人不知道车到哪了。做校园摆渡车微信小程序真正要解决的是把“下一班还有几分钟到站、这个站还上不上得去、要不要换到另一个门去等”变成手机上能查到的数字而不是再画一张静态线路图。这套方案面向两类人要交毕设或课设、需要一条从需求到验收都能自圆其说的完整链路的学生以及真准备把这套东西放到校园里跑起来的开发者。后面按需求选型、总体架构、关键模块、避坑排查、验收答辩推下去表结构和云函数都能直接拿去改。2. 需求拆解与选型三类角色、四条业务流与技术栈取舍2.1 三类角色与四条主干业务流角色划分不清后面所有页面都会打架。这套方案里固定三类角色乘客学生、教职工、司机、调度管理员。三者在同一个 AppID 下用不同入口进入靠登录后落到user集合里的role字段区分权限不给乘客端暴露任何司机端接口。司机端不单独发一个小程序是因为校园场景车辆少、司机换班频繁单独发版反而增加维护成本放进分包按需加载就够了。业务流梳理成四条每条对应一组页面和云函数查询流选线路 → 看站点列表 → 看下一班车预计到站时间。这是使用频次最高的一条必须做到打开即用、首屏 1 秒内出内容。定位流司机端周期上报经纬度 → 云端写入最新位置 → 乘客端在地图上看到车的移动。这条流的难度不在画图在于上报频率和电量、流量的平衡。预约流乘客在某班次某站点预约一个座位 → 生成预约记录并占用名额 → 发车前若未上车则超时释放。校园摆渡车高峰期一车 19 座不控名额就一定会出现“排队 40 人只上去 15 个”的场面。调度流管理员维护线路、站点、班次遇到考试周、下雨天临时加开班次或改道。这条流的使用频率最低但一旦缺失整套系统就退化成一张不会更新的图。四条流之间靠“班次”这一个实体串起来定位流上报的是某个trip_id的位置预约流占用的是某个trip_id的座位调度流改的也是trip_id的状态。把班次抽出来当主干是这套设计里最关键的一次取舍。2.2 技术栈取舍小程序 云开发而不是自建后端选型上前端是原生微信小程序WXML/WXSS/JS后端用云开发云函数 云数据库 云存储 定时触发器地图用小程序内置map组件叠加腾讯位置服务的路径规划与逆地址解析。这个组合不是因为它时髦是因为校园场景的并发量撑不起自建服务的运维成本一所两万人的学校摆渡车小程序日活通常几百到两千峰值集中在早七点半到八点半和傍晚五点云开发的按量计费和自动扩缩容刚好匹配这种“平时很闲、峰值很挤”的曲线。不选跨端框架的原因很直接这套东西只发微信一端跨端框架带来的打包层问题地图组件兼容、分包策略受限、真机与开发者工具差异在毕设周期里是纯负担。不选自建 Node 服务的原因则是运维一台 2 核 4G 的云服务器光是配 HTTPS 证书、配反向代理、写日志轮转就能吃掉两周时间而这部分工作在答辩时几乎不加分。代价也要讲清楚选型从来是交换方案优势代价适用判断原生小程序 云开发免运维、免域名备案、上线快云函数冷启动 13 秒、单次执行默认 20 秒上限、定时触发器最小粒度 1 分钟本项目首选原生小程序 自建服务长连接、复杂事务自由服务器、备案、证书、监控全自理车辆超过 30 台、要接车载硬件时再考虑跨端框架 云开发一套代码多端地图与分包能力受框架限制需要同时发 H5 时再评估地图能力上还有一个容易被忽略的点小程序map组件的markers数量在低端安卓机上超过 30 个就会明显掉帧。所以站点标记做了聚合只在缩放级别大于 15 时逐点渲染否则合并成“本区域 8 个站点”的聚合点。这类细节在需求文档里写不出来只能靠真机试出来。2.3 数据模型把线路、站点、班次、位置拆成六张表数据模型决定了后面云函数写起来顺不顺。云开发是文档型数据库没有强制的表结构但设计阶段我一般还是先用关系型建表语句把字段、类型、索引想清楚再一比一映射成集合字段名完全一致。这样在答辩时讲“数据设计”能讲得清楚迁移到 MySQL 也只改连接层。-- 线路表一条线路对应一组固定站点 CREATE TABLE route ( id VARCHAR(32) PRIMARY KEY, name VARCHAR(64) NOT NULL, -- 如「东区—教学楼环线」 color VARCHAR(16) DEFAULT #1A73E8, first_time TIME NOT NULL, -- 首班发车时间 last_time TIME NOT NULL, -- 末班发车时间 interval_min INT DEFAULT 15, -- 计划发车间隔分钟 status TINYINT DEFAULT 1, -- 1 运营 0 停运 updated_at DATETIME ); -- 站点表seq 决定沿线顺序geofence_radius 用于到站判定 CREATE TABLE station ( id VARCHAR(32) PRIMARY KEY, route_id VARCHAR(32) NOT NULL, name VARCHAR(64) NOT NULL, lat DECIMAL(10,6) NOT NULL, lng DECIMAL(10,6) NOT NULL, seq INT NOT NULL, -- 沿线第几站从 1 开始 geofence_radius INT DEFAULT 60, -- 电子围栏半径米 INDEX idx_route_seq (route_id, seq) ); -- 班次表整套系统的主干一次发车就是一条记录 CREATE TABLE trip ( id VARCHAR(32) PRIMARY KEY, route_id VARCHAR(32) NOT NULL, vehicle_id VARCHAR(32) NOT NULL, driver_openid VARCHAR(64) NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME, status VARCHAR(16) DEFAULT pending, -- pending/running/paused/finished/cancelled seats_total INT DEFAULT 19, seats_left INT DEFAULT 19, INDEX idx_route_start (route_id, start_time) ); -- 位置上报表只保留最近 N 条历史数据定时清理 CREATE TABLE position_report ( id VARCHAR(32) PRIMARY KEY, trip_id VARCHAR(32) NOT NULL, lat DECIMAL(10,6) NOT NULL, lng DECIMAL(10,6) NOT NULL, speed DECIMAL(5,2) DEFAULT 0, -- 米/秒来自 wx.getLocation seq_hint INT, -- 司机端当前所处站点序号用于校准 created_at DATETIME NOT NULL, INDEX idx_trip_time (trip_id, created_at DESC) ); -- 预约表一条记录锁定一个座位expire_at 是超时释放的依据 CREATE TABLE booking ( id VARCHAR(32) PRIMARY KEY, trip_id VARCHAR(32) NOT NULL, station_id VARCHAR(32) NOT NULL, user_openid VARCHAR(64) NOT NULL, status VARCHAR(16) DEFAULT valid, -- valid/used/expired/cancelled created_at DATETIME NOT NULL, expire_at DATETIME NOT NULL, UNIQUE KEY uk_trip_user (trip_id, user_openid) -- 同一班次同一人只能有一条 ); -- 用户表role 决定能进哪些页面 CREATE TABLE user ( openid VARCHAR(64) PRIMARY KEY, nick_name VARCHAR(64), role VARCHAR(16) DEFAULT passenger, -- passenger/driver/admin created_at DATETIME );字段里有两个地方值得单独说明。station.geofence_radius是用来判到站的车进入站点 60 米范围并持续 10 秒就认为已到站比单纯比较直线距离稳得多能过滤掉 GPS 漂移造成的“车在站上反复跳”。booking.expire_at假设提前 8 分钟生成发车前 3 分钟未核销就释放这样既留了赶路时间也不至于座位被占满一整个早高峰。另一个隐式设计是position_report只留最近数据。云数据库按量计费一台车 5 秒报一次一小时就是 720 条四台车跑一整天接近三万条不清就会一直涨。通常的做法是写一个定时触发的清理云函数每天凌晨删掉 24 小时以前的记录同时把每趟班次的轨迹做一次抽稀每 30 秒保留一个点存进归档集合答辩时用来展示行驶轨迹图。3. 总体架构设计三端页面、八个云函数与地图服务边界3.1 页面结构与分包划分页面按角色切分乘客端放主包司机端和管理端各放一个分包这样首屏加载的是乘客最常用的三个页面司机端那些带地图和表单的页面不会拖慢冷启动。pages/ index/ 首页常用线路卡片 距离最近站点 route/detail/ 线路详情站点列表 下一班 ETA live/map/ 实时地图车辆位置 我的位置 booking/confirm/ 预约确认 booking/list/ 我的预约 packageDriver/pages/ home/ 司机端首页开关班 上报状态 packageAdmin/pages/ route/edit/ 线路与站点维护 trip/manage/ 班次排班与临时调度分包边界划在这里有个好处司机端页面会调用wx.startLocationUpdate这类敏感接口权限声明集中在分包内乘客端不会被要求“始终允许获取位置”。小程序审核对位置权限比较敏感把能力收在分包里提审时也更容易解释用途。页面之间的跳转参数只传id不传整条对象。传对象看着省事但页面一多就会出现“从不同入口进来参数结构不一样”的问题后面排查起来是个黑匣子。这条规矩在写第一版时没定好后面返工改过一轮。3.2 云函数职责划分与接口约定云端按“一个动作一个函数”的粒度切避免出现一个api函数里塞二十个 if 分支的写法。八个函数各管一段云函数职责关键入参调用频次login换取 openid、返回角色与权限无每次启动getNearbyStations按经纬度返回 800 米内站点lat, lng首屏getEta计算某班次到指定站点的预计分钟tripId, stationId30 秒轮询reportLocation司机端写位置、更新当前站点tripId, lat, lng, speed5 秒createBooking建预约、扣减座位事务tripId, stationId用户触发cancelBooking取消并归还座位bookingId用户触发dispatchTrip开/停/改班次改道tripId, action管理员触发cleanupReport定时清历史位置无每日凌晨getEta和reportLocation是整套系统里唯二会被高频调用的函数其他都是低频。频次差异决定了它们的写法reportLocation必须做成极简的写操作不做复杂校验只做参数合法性判断和一次 upsertgetEta必须走缓存否则每次轮询都要扫一遍位置表配额消耗会非常难看。接口返回统一成{ code, msg, data }三段式code用 0 表示成功非 0 是业务错误码而不是 HTTP 码。这么定是因为云函数抛异常时前端拿到的错误结构不统一自己包一层后前端只需要判断code不用去猜是网络层挂了还是业务层拒了。3.3 定位上报频率与流量、电量的预算定位是这类小程序里最容易翻车的地方。频率定高了司机手机发烫、流量超标定低了地图上的车像瞬移。折中的做法是车辆移动时 5 秒一次速度低于 0.5 米/秒等红灯或靠站时降到 20 秒一次后台用速度字段自动切换。// 司机端根据速度动态调整上报间隔 let timer null; let lastReportAt 0; function calcInterval(speed) { // speed 单位 m/s低于 0.5 视为静止拉长间隔省电 if (speed 0.5) return 20000; if (speed 3) return 8000; // 校内低速行驶 return 5000; // 正常行驶 } async function tick() { const res await wx.getLocation({ type: gcj02, isHighAccuracy: true }); const now Date.now(); // 距离上次上报不足间隔则跳过避免定时器抖动导致连发 if (now - lastReportAt calcInterval(res.speed || 0)) return; lastReportAt now; await wx.cloud.callFunction({ name: reportLocation, data: { tripId, lat: res.latitude, lng: res.longitude, speed: res.speed || 0 } }); } timer setInterval(tick, 1000); // 1 秒心跳实际发不发由 calcInterval 决定这里的关键不是那段if而是“用 1 秒心跳 内部判断是否上报”的结构。如果直接用setInterval(tick, 5000)两次定时器之间用户的移动距离不可控遇到小程序被切到后台再切回来定时器会被系统节流甚至暂停产生一段长达几分钟的轨迹空洞。用短心跳加内部节流配合wx.onAppShow时补一次立即上报能把这个空洞压到最小。流量预算也要算一次上报的请求体约 200 字节5 秒一次一小时 720 次约 144 KB一天跑 10 小时不到 1.5 MB完全可以接受。真正消耗大的是乘客端的地图瓦片所以实时地图页做了限制只在页面可见时刷新切到后台立刻clearInterval回来时先拉一次全量再恢复轮询。3.4 班次状态机五种状态与四条迁移条件班次状态如果不定死后面会在各种边界上出问题已经结束的班次还能被预约、暂停的班次还在计算 ETA、取消的班次占着座位不放。所以状态机要写进设计文档并且在云函数里做校验任何状态变更必须先读当前状态再判断。当前状态允许动作目标状态触发者pending司机点击发车running司机running到站停靠超时 / 司机手动暂停paused系统 / 司机paused司机恢复running司机running / paused司机结束班次 / 到达末站finished司机 / 系统pending管理员取消cancelled管理员迁移条件里最容易漏的是“到末站自动结束”。不写这条司机会忘记点结束班次一直挂在 running乘客端就会一直看到一辆停在终点站不动的车投诉量立刻上来。这类“人不靠谱”的边界必须在系统里兜住。4. 关键模块实现实时位置、到站估算与预约占座4.1 车辆位置上报与地图渲染上报侧的逻辑上一节已经给了这里看云端怎么落库和乘客端怎么拉。位置不需要每次都插一条新记录当前最新位置用一个单独的集合trip_live存一条用trip_id做主键做 upsert历史轨迹才写进position_report。这样乘客端拉位置只读一条记录成本几乎为零。// 云函数 reportLocation更新实时位置 追加轨迹点 const cloud require(wx-server-sdk); cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }); const db cloud.database(); exports.main async (event) { const { tripId, lat, lng, speed } event; // 参数校验缺一个字段直接拒防止脏数据污染地图 if (!tripId || typeof lat ! number || typeof lng ! number) { return { code: 4001, msg: 参数不完整 }; } const now new Date(); // 1. 实时位置 upsert乘客端只读这一条 await db.collection(trip_live).doc(tripId).set({ data: { tripId, lat, lng, speed: speed || 0, updatedAt: now } }); // 2. 轨迹点抽样写入每 30 秒才落一条避免集合爆炸 const last await db.collection(position_report) .where({ tripId }).orderBy(createdAt, desc).limit(1).get(); const lastTime last.data[0] ? new Date(last.data[0].createdAt).getTime() : 0; if (now.getTime() - lastTime 30000) { await db.collection(position_report).add({ data: { tripId, lat, lng, speed: speed || 0, createdAt: now } }); } return { code: 0, msg: ok }; };db.collection().doc(id).set()在云开发里是“存在则覆盖、不存在则创建”正好当 upsert 用比先查后写少一次读操作。抽样写入的 30 秒阈值是权衡出来的轨迹点太密对答辩演示没帮助反而让集合膨胀30 秒一个点画出来的折线已经足够看出行驶路径。乘客端拿到位置后的渲染要注意两件事。一是坐标系wx.getLocation指定type: gcj02map组件也吃 gcj02两边一致就不会出现车辆偏到湖里的经典画面。二是不要用setData整包替换 markers 数组只更新变化的那一个 marker 的id和latitude/longitude能省掉大量渲染开销。4.2 到站时间估算用分段历史均速代替直线距离到站时间是用户唯一真正在意的数字也是最容易做得不准的地方。最省事的写法是“直线距离 ÷ 平均车速”但校园里直线 300 米可能要绕一个门禁、过两个减速带误差能到两倍以上。我一般用分段历史均速来算公式是ETA Σ(剩余各站间历史平均耗时) × 路况系数 当前路段剩余耗时其中“当前路段”指车已经离开上一站、还没到下一站的那一段按“已行驶比例”折算。路况系数按时间段取早高峰 1.25、平峰 1.0、雨天 1.15这几个值放在配置表里管理员可改。// 云函数 getEta分段均速 当前段插值 function calcEta({ stations, currentSeq, progress, segmentCost, factor, now }) { // stations: 按 seq 排好序的站点数组 // currentSeq: 车辆当前所在或刚离开的站点 seq // progress: 当前段已行驶比例 0~1 // segmentCost: { 1-2: 180, 2-3: 240 } 单位为秒来自历史班次统计 let remain 0; // 1. 当前段剩余部分 const curKey ${currentSeq}-${currentSeq 1}; const curCost segmentCost[curKey] || 180; // 缺数据时用默认值兜底 remain curCost * (1 - progress); // 2. 后续各段累加 for (let s currentSeq 1; s stations.length; s) { remain segmentCost[${s}-${s 1}] || 180; } // 3. 乘路况系数并做 1~30 分钟的边界裁剪 const etaSec remain * factor; const minutes Math.min(30, Math.max(1, Math.round(etaSec / 60))); return minutes; }segmentCost的来源是历史班次每趟车跑完用相邻两次“到站判定成功”的时间差累加到对应段上取最近 20 趟的中位数。用中位数而不是平均值是为了抗住某一次堵在门口二十分钟的异常值。缺数据的新线路用 180 秒兜底同时在管理端标红提示“该段数据不足”让管理员知道这个数字暂时不可信。边界裁剪那条也重要。不做裁剪的话网络卡顿导致的progress计算异常会让 ETA 显示成 300 分钟或者负数用户看到直接关掉。裁到 130 分钟最坏情况也只是“显示 30 分钟”体感上还能接受。4.3 预约占座事务扣减与超时释放预约的核心矛盾是超卖。云开发的文档数据库单个文档操作是原子的但“读座位数 → 判断 → 写回”这三步之间不是两个人同时点预约就可能都读到seats_left 1。解决办法是把扣减写成一个原子自增表达式靠数据库层的原子性保证。// 云函数 createBooking原子扣减 幂等落库 const db cloud.database(); const _ db.command; exports.main async (event, context) { const { tripId, stationId } event; const openid cloud.getWXContext().OPENID; // 1. 幂等同一班次已有有效预约直接返回防止连点 const exist await db.collection(booking) .where({ tripId, userOpenid: openid, status: valid }).count(); if (exist.total 0) return { code: 4002, msg: 已有预约 }; // 2. 原子扣座seats_left 0 才允许 -1 const upd await db.collection(trip) .where({ _id: tripId, seats_left: _.gt(0) }) .update({ data: { seats_left: _.inc(-1) } }); if (upd.stats.updated 0) { return { code: 4003, msg: 座位已满 }; } // 3. 写预约记录expire_at 设为发车前 3 分钟 await db.collection(booking).add({ data: { tripId, stationId, userOpenid: openid, status: valid, createdAt: new Date(), expireAt: new Date(Date.now() 8 * 60 * 1000) } }); return { code: 0, msg: ok }; };where({ _id, seats_left: _.gt(0) })配合_.inc(-1)是这段代码的核心条件判断和减值在同一次数据库操作里完成updated为 0 说明条件不满足直接返回“座位已满”。这比开事务轻也比“先查再改”稳。第 3 步如果失败座位就白扣了所以要么补一个补偿逻辑捕获异常后_.inc(1)加回去要么把这两步放进db.startTransaction()。毕设规模下我倾向于用事务代码多十行但少一类难查的脏数据。超时释放靠定时触发器每分钟扫一次status valid 且 expire_at now的记录逐条改成expired并把对应班次的seats_left加回去。这里要注意先改预约状态再还座位顺序反过来会出现“座位还了但预约还是 valid”的重复计数。扫描时按expire_at建索引否则集合一大每分钟一次的全表扫描很快就顶不住。4.4 到站提醒订阅消息加本地兜底提醒这块属于典型的“看着简单做着烦”。微信的订阅消息是一次性授权用户点一次“允许”你只能发一条。所以策略要设计成在预约成功那一刻申请授权把这一次机会留给“车还有 3 分钟到站”的提醒别浪费在“预约成功”这种本来就能在页面上看到的消息上。// 预约成功后申请订阅授权一次授权只发一条 async function requestNotify() { const res await wx.requestSubscribeMessage({ tmplIds: [TEMPLATE_ID_ARRIVING] // 到站提醒模板 id }); if (res[TEMPLATE_ID_ARRIVING] accept) { // 把授权状态写回预约记录云端发消息前先查这个标记 await wx.cloud.callFunction({ name: createBooking, data: { action: grantNotify, bookingId } }); } }云端判断“还有 3 分钟到站”的时机不能靠云函数自己轮询那样成本太高。做法是在reportLocation里顺手算一次车的位置更新后查这个trip_id下所有valid且已授权的预约若车辆到该站点的 ETA ≤ 3 分钟且尚未发送过就调用subscribeMessage.send发一条并在预约记录上打notified: true。本地兜底也不能省。订阅消息可能因为用户拒收、模板被下架、额度用尽等原因发不出去所以乘客端在实时地图页里自己维护一个倒计时当 ETA 降到 3 分钟且页面在前台时用wx.vibrateShort震一下并在顶部弹一条自定义提示。这套双保险在真机测试时救过一次场测试当天模板消息因为类目问题一直发不出去全靠本地倒计时撑住了演示。4.5 调度端批量排班与临时改道管理端不需要做得多漂亮但两件事必须好用批量生成一周的班次、临时给某条线路加开一趟。批量排班就是按first_time、last_time、interval_min三个参数生成时间点再按日期循环插入trip记录。生成时要做去重同一线路同一发车时间只保留一条否则管理员手滑点两次就会生成双份班次乘客端看到两辆一模一样的车。临时改道更麻烦因为已经发出去的车不能直接改站点顺序。我的做法是给trip加一个route_override字段改道时只写这个字段ETA 计算和地图渲染优先读它线路本身的站点不动。这样一辆车临时绕行不影响其他班次第二天改回来只要清空这个字段。5. 避坑与常见问题定位漂移、并发抢座、消息触达与真机差异5.1 现象地图上的车飘到操场甚至湖里原因有三层。一是坐标系混用wx.getLocation默认可能返回 wgs84而map组件用 gcj02两者差几百米。二是室内或高楼遮挡下 GPS 精度掉到 100 米以上isHighAccuracy: true只是请求高精度不代表一定拿得到。三是没有做异常点过滤一个跳变点直接进了数据库。解决分三步调用时显式写type: gcj02上报前用accuracy字段过滤大于 80 米的点直接丢弃云端对连续两点做速度校验两次上报间隔 5 秒但位移超过 200 米相当于 144 km/h就判为异常点不更新trip_live。第三层最有效加了之后地图上的车再没飘过。5.2 现象高峰期座位数扣成负数原因是“先查后改”的写法在并发下失效两个请求都读到 1都判断“还有座”都写回 0结果多卖了一个。另一种情况是扣座成功但写预约失败座位永久丢失。解决就是 4.3 里的原子条件更新把判断和扣减合成一次操作。同时给booking加(tripId, userOpenid)唯一索引从数据库层堵住重复预约。如果用了事务记得事务里不要做网络请求云开发的事务有超时限制中间夹一个await外部接口很容易超时回滚。5.3 现象订阅消息一条都发不出去日志里也没报错原因通常是授权被提前消耗了。一次性订阅的额度是“授权一次发一条”如果在预约成功时就发了一条“预约成功通知”那么到站提醒就没有额度了。另一种是模板 ID 写错或者模板类目和实际用途不符云端返回错误码但代码里没接try/catch错误被吞掉。解决是严格限定每个模板只用于一个场景授权和发送一一对应云端发送前先查notified标记做幂等所有send调用外层必须包try/catch并把errcode打进日志。调试阶段可以在管理端加一个“测试发送”按钮输入 openid 直接发一条能不能发出去一目了然比翻日志快得多。5.4 现象开发者工具里一切正常真机上地图白屏、定位不弹窗原因有三类。一是没在app.json里声明permission.scope.userLocation和requiredPrivateInfos开发者工具会放宽校验真机直接拒绝。二是真机首次调用getLocation需要用户授权用户点了拒绝之后不会再弹必须引导去wx.openSetting手动打开。三是部分安卓机型在弱网下地图瓦片加载超时map组件显示为灰块。解决是授权流程做成显式引导先wx.getSetting查授权状态未授权时用自定义弹窗说明“需要位置来显示最近站点”用户点了确认再调wx.authorize被拒则跳openSetting。地图灰块的问题加一个bindtap重试和 15 秒超时提示别让用户对着白屏干等。这套流程在开发者工具里永远测不出来必须真机走一遍血泪经验。5.5 现象定时清理任务在该跑的时候没跑云开发的定时触发器用 Cron 表达式时区按 UTC 算写0 0 3 * * *以为是凌晨 3 点实际是北京时间 11 点。另一种是触发器配置在config.json里但没重新部署云函数配置压根没生效。解决是把 Cron 按 UTC 换算好北京时间凌晨 3 点写0 0 19 * * *部署后在云开发控制台的触发器列表里确认存在且状态为启用。清理函数本身要加执行日志记录删除条数跑没跑、删了多少一眼能看出来。这类“静默失败”的任务最坑不写日志等于给自己埋雷。6. 验收与答辩一份能当场演示的验收清单和三个必答问题验收这件事很多人栽在“功能都做了但演示不出来”。答辩现场网络不稳、定位拿不到、订阅消息发不出都是常态。所以验收清单要按“能不能在 5 分钟内完整走一遍”来设计而不是按功能列表打勾。验收项验收方法通过标准首屏加载冷启动进入首页秒表计时3 秒内出现线路卡片弱网下有骨架屏最近站点识别在校园内三个不同位置打开首页推荐站点与实际步行最近站点一致实时位置司机端开班行驶乘客端看地图车辆图标移动连续无跳跃延迟 ≤ 10 秒ETA 准确性记录 5 趟实际到站时间与预估值误差 ≤ 2 分钟的比例不低于 80%预约防超卖用 3 个账号同时抢最后 1 个座位只有 1 个成功其余返回“座位已满”超时释放预约后不核销等待超时座位数在 1 分钟内恢复到站提醒预约后等待 ETA 降到 3 分钟收到订阅消息或前台出现本地提示权限拒绝路径首次进入拒绝定位授权页面给出引导不白屏、不报错表格里“ETA 准确性”那一条是重点也是最容易被问的地方。答辩老师不会问你怎么写的代码会问“你这个 5 分钟准不准”。所以测试阶段一定要老老实实记录五趟真实到站时间做一张误差对比表放在 PPT 里。误差大不可怕说清楚是历史数据不足导致、后续趟次累积会收敛比硬说“很准”可信得多。答辩现场有三个问题几乎必问提前准备好答案为什么不用 WebSocket 做实时推送而用轮询答案要落在场景上校园摆渡车同时在线用户量小车辆位置 5 秒更新一次30 秒轮询一次 ETA 完全够用WebSocket 需要长连接保活小程序切后台就断重连逻辑本身比轮询复杂。真正需要推送的只有到站提醒一条用订阅消息解决不需要为一条消息引入长连接。座位并发怎么保证不超卖直接讲原子条件更新那一段where里带seats_left 0update用_.inc(-1)靠数据库单文档操作的原子性。如果能说清楚“先查后改”为什么不行这个问题就满分了。这套东西真上线要改什么这个问题最能体现你有没有想过落地。要改的地方至少三处位置上报改成车载设备或司机端常驻前台加后台定位权限历史均速统计要跑批任务沉淀不能靠现算管理端要做操作审计谁改了哪趟班次得留痕。从那以后我每次带这类项目都会在开发中期就逼着自己按这张验收表走一遍而不是等做完再测。测试早两周返工少一半这个习惯比任何架构技巧都管用。希望帮到你。本文还有配套的精品资源点击获取