简介:面向微信小程序初学者与校园交通应用开发者的原创练手项目,围绕摆渡车实时位置查询、上下车点与时刻表展示、座位预订等场景,提供一套前后端示例。项目采用小程序前端与 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 证书、配反向代理、写日志轮转,就能吃掉两周时间,而这部分工作在答辩时几乎不加分。
代价也要讲清楚,选型从来是交换:
| 方案 | 优势 | 代价 | 适用判断 |
|---|---|---|---|
| 原生小程序 + 云开发 | 免运维、免域名备案、上线快 | 云函数冷启动 1~3 秒、单次执行默认 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, stationId | 30 秒轮询 |
reportLocation | 司机端写位置、更新当前站点 | tripId, lat, lng, speed | 5 秒 |
createBooking | 建预约、扣减座位(事务) | tripId, stationId | 用户触发 |
cancelBooking | 取消并归还座位 | bookingId | 用户触发 |
dispatchTrip | 开/停/改班次,改道 | tripId, action | 管理员触发 |
cleanupReport | 定时清历史位置 | 无 | 每日凌晨 |
getEta和reportLocation是整套系统里唯二会被高频调用的函数,其他都是低频。频次差异决定了它们的写法:reportLocation必须做成极简的写操作,不做复杂校验,只做参数合法性判断和一次 upsert;getEta必须走缓存,否则每次轮询都要扫一遍位置表,配额消耗会非常难看。
接口返回统一成{ 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: 'gcj02',map组件也吃 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 分钟或者负数,用户看到直接关掉。裁到 1~30 分钟,最坏情况也只是“显示 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 > 0,update用_.inc(-1),靠数据库单文档操作的原子性。如果能说清楚“先查后改”为什么不行,这个问题就满分了。
这套东西真上线要改什么?这个问题最能体现你有没有想过落地。要改的地方至少三处:位置上报改成车载设备或司机端常驻前台加后台定位权限;历史均速统计要跑批任务沉淀,不能靠现算;管理端要做操作审计,谁改了哪趟班次得留痕。
从那以后我每次带这类项目,都会在开发中期就逼着自己按这张验收表走一遍,而不是等做完再测。测试早两周,返工少一半,这个习惯比任何架构技巧都管用。希望帮到你。
本文还有配套的精品资源,点击获取