简介:面向微信小程序初学者与移动开发入门者的校园摆渡车项目源码包,围绕校园摆渡车实时位置查询、上下车点与运行时刻查看、座位预订等实际需求设计,采用原创前后端分离结构,可作为课程设计、自学练手或毕业设计的参考案例。压缩包共79个文件,约1.01MB,主要包含后端Java类与Servlet代码、dao与bean分层实现、JSP页面、MyBatis与fastjson等jar依赖,以及前端小程序的wxml页面、wxss样式、js逻辑与app.json配置,另有png界面截图和xml工程配置文件,便于按模块阅读与二次修改。已有416人学习下载。项目覆盖小程序组件与生命周期、响应式布局、RESTful接口设计、数据库增删改查、JSON数据交互、地图API集成与实时定位刷新、服务器部署等知识点,前后端代码配套齐全,适合对照梳理请求链路与数据流转过程,快速理解轻量级移动应用从界面到接口再到存储的完整实现思路。
1. 需求切入:一公里的路为什么要做一个小程序
校园摆渡车的痛点从来不是「没有车」,而是「不知道车在哪、几点来、要不要等」。最典型的场景是:宿舍区到教学楼直线一公里多,走路二十分钟,摆渡车十分钟一趟,可没人说得准这十分钟从哪一刻算起,于是大部分人宁愿走路,车空着跑,人走着去。这套校园摆渡车微信小程序要解决的就是把「等车全靠猜」变成「看一眼就知道」——学生端看线路、看车在哪、看到站还有几分钟、上车扫码;司机端开班、上报位置、收班;后台排线路、生成班次、看客流。它适合拿来做毕设或课程设计的同学:业务闭环完整、模块边界清楚、演示时有画面感;也适合想练一套真实调度场景的小团队。下面按需求、架构、数据、实现、避坑、验收六段,把我自己会怎么做写清楚。
2. 总体架构与技术选型:小程序、服务端、调度后台的三层划分
2.1 为什么不做成纯小程序云开发一键梭
很多人第一反应是全套云开发:数据库、云函数、存储一把梭,前端写完就能跑,答辩演示也不容易翻车。这个思路对「表单收集类」小程序没问题,但摆渡车这套东西有个绕不开的点——位置是高频写入、高频读取的。
司机端每三秒上报一次坐标,假设同时在线十辆车,一小时就是一万两千条写入;学生端每五秒轮询一次线路详情,一百个在线用户就是一小时七万两千次读。云开发的按量计费和单集合读写限制在这种「短时间高频」的场景下会很难受,而且到站预估这种计算放在云函数里,冷启动的延迟会直接体现在「点了刷新,两秒后才动」的体验上。
我的做法是拆开:小程序端只管展示和采集,所有涉及时间窗、角色校验、位置计算的逻辑放自建服务端,位置这类「只关心最新值」的数据放内存或缓存,不落库。管理后台做成一个独立的 Web 页面,用现成的表格组件堆就行,不需要多好看,能改线路、能看当天班次即可。
代价是要自己买服务器、配 HTTPS 域名、在小程序后台配 request 合法域名。这一步是很多人的第一道坎,但配一次就完了,比后期数据量上来再迁移划算得多。血泪经验是:域名和证书一定要在动手写业务之前就搞定,别等到答辩前两天才发现请求全被拦。
2.2 三层结构与模块职责划分
整体是三层:小程序客户端、业务服务端、调度管理后台。小程序端内部再按角色分学生端和司机端,用同一套代码靠登录返回的role字段分流页面入口,不做两个小程序——两个小程序意味着两套审核、两套配置、两套发布节奏,对单人项目来说是纯负担。
目录我一般这么分:
campus-shuttle/ ├── miniprogram/ # 微信小程序端,学生/司机共用一套代码 │ ├── pages/ │ │ ├── index/ # 首页:最近站点、最近一班车、公告 │ │ ├── line/ # 线路详情:站点顺序、车辆当前位置 │ │ ├── scan/ # 扫码乘车,调 wx.scanCode │ │ └── driver/ # 司机端:开班、上报位置、收班 │ ├── components/eta-card/ # 到站时间卡片,多处复用 │ └── utils/request.js # 统一封装 wx.request,自动带 token ├── server/ │ ├── app.js # Express 入口,挂路由和中间件 │ ├── routes/ # line / schedule / vehicle / ride 四个路由文件 │ ├── service/ # schedule.js 排班生成、eta.js 到站预估 │ └── middleware/auth.js # 角色鉴权 └── admin/ # 管理后台(Web,只做线路和班次维护)这个划分的原则只有一条:凡是「需要跨请求保持一致」的东西,都不放在客户端。比如「这趟车还能不能上」这件事,取决于当前时间、班次状态、是否已核销,判断权必须在服务端;客户端只负责把扫码拿到的车码和用户身份传上去。
2.3 关键接口约定与鉴权链路
接口数量控制在十二个以内,多了自己也维护不过来。核心的几个:
| 接口 | 方法 | 调用方 | 说明 |
|---|---|---|---|
/api/login | POST | 小程序 | code 换 openid,返回 token 与 role |
/api/line/list | GET | 学生端 | 全部启用线路及站点顺序 |
/api/vehicle/location | POST | 司机端 | 上报坐标,节流后三秒一次 |
/api/line/vehicles | GET | 学生端 | 某条线路上所有车的最新位置 |
/api/ride/verify | POST | 学生端 | 扫码核销,写乘车记录 |
鉴权不用第三方框架,一个中间件足够:
// server/middleware/auth.js // token 里带 role,角色只有 student / driver / admin 三种 module.exports = function auth(requiredRole) { return async (req, res, next) => { const token = req.headers['x-token']; if (!token) return res.status(401).json({ code: 401, msg: '未登录' }); const payload = verifyToken(token); // 自己签的 JWT,密钥放环境变量,别写进仓库 if (!payload) return res.status(401).json({ code: 401, msg: '登录已过期' }); if (requiredRole && payload.role !== requiredRole) { return res.status(403).json({ code: 403, msg: '角色不匹配' }); } req.user = payload; // 后续路由直接取 req.user.id / req.user.role next(); }; };requiredRole传空表示「登录即可访问」,传'driver'就只放行司机。这样写的好处是权限判断集中在一处,后面加「管理员导出乘车记录」这种接口时,只要在路由上挂auth('admin'),不会出现某个接口忘了校验的情况。token 有效期设七天,过期让小程序静默重新wx.login换一次,用户无感。
3. 数据建模:六张表把线路、班次和乘车记录串起来
3.1 表结构与字段取舍
六张表:station(站点)、line(线路)、line_station(线路与站点的顺序关系)、schedule(班次)、vehicle(车辆与司机绑定)、ride_record(乘车记录)。最容易设计错的是line_station——很多人图省事,把站点顺序存成线路表里的一个 JSON 数组字段,结果想查「经过图书馆的所有线路」时只能全表扫。
正确的做法是关系表加seq字段:
-- 线路主表:一条线路就是一次完整的走向,上行下行拆成两条记录 CREATE TABLE line ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(32) NOT NULL, -- 如「东区宿舍—教学楼」 direction TINYINT NOT NULL DEFAULT 1, -- 1 上行 2 下行 start_time TIME NOT NULL, -- 首班 07:30 end_time TIME NOT NULL, -- 末班 22:00 interval_min INT NOT NULL DEFAULT 10, -- 发车间隔(分钟),排班生成靠它 status TINYINT NOT NULL DEFAULT 1, -- 1 启用 0 停用 version INT NOT NULL DEFAULT 1 -- 站点调整时 +1,历史记录仍指向老版本 ); -- 线路站点关系:seq 决定顺序,eta_offset 是「从起点到本站通常要几分钟」 CREATE TABLE line_station ( id INT PRIMARY KEY AUTO_INCREMENT, line_id INT NOT NULL, station_id INT NOT NULL, seq INT NOT NULL, -- 1,2,3… 越小越靠前 eta_offset INT NOT NULL DEFAULT 0, -- 分钟,到站预估的兜底值 UNIQUE KEY uk_line_seq (line_id, seq) );eta_offset这个字段很多人会省掉,觉得有实时位置就够了。但第一次冷启动、或者某辆车刚开班还没上报位置时,没有历史耗时就只能显示「即将到站」这种糊弄人的文案。留一列兜底值,界面上永远有数可显示,这比什么算法都实在。
version字段是给「线路调整」准备的。站点顺序一变,老班次如果直接跟着改,之前的乘车记录就对不上站了。加版本号之后,老记录指向老版本,新班次用新版本,后台查历史数据不会错乱。
3.2 排班生成:把「每天跑几趟」变成可计算的数据
排班不要手工录。手工录三十趟车,改一次发车间隔就要重录一遍,这是最典型的自找麻烦。用首末班时间加发车间隔直接算:
// server/service/schedule.js // 按线路的首末班时间和发车间隔,批量生成某一天的班次 function buildSchedules(line, dateStr) { const [sh, sm] = line.start_time.split(':').map(Number); const [eh, em] = line.end_time.split(':').map(Number); const startMin = sh * 60 + sm; // 全部换算成「当天第几分钟」,避免跨小时计算 const endMin = eh * 60 + em; const rows = []; for (let m = startMin; m <= endMin; m += line.interval_min) { const hh = String(Math.floor(m / 60)).padStart(2, '0'); const mm = String(m % 60).padStart(2, '0'); rows.push({ line_id: line.id, run_date: dateStr, // 2024-05-20 这种纯日期,不带时区 depart_at: `${dateStr} ${hh}:${mm}:00`, // 计划发车时刻 bus_id: null, // 排班时留空,司机开班时再绑定 status: 0 // 0 待发车 1 运行中 2 已结束 }); } return rows; }几个参数的含义必须清楚:startMin和endMin换算成当天分钟数,是为了绕开「跨零点」这个坑——如果线路末班是 23:50,直接拿时间字符串比较会出错,换成整数分钟就没问题。bus_id留空是有意的,排班是「计划」,司机开班才是「实际」,两者分开,临时换车不影响计划数据。
生成时机我放在每天凌晨定时跑一次,把当天和次日两天的班次都写进去。为什么提前一天?因为早上七点半首班,如果定时任务在七点二十才跑,一旦那天服务器抽风,整个早高峰就没车可坐。提前生成是成本最低的后悔药。
3.3 站点顺序与线路版本管理
站点调整是必然会发生的:某个路口修路、某栋楼封了、临时加一个食堂站点。处理原则是「只增量,不覆盖」。加站就插一条line_station,把后面所有站的seq顺延;删站不物理删除,把status置零,查询时过滤掉。这样做的好处是历史班次和历史乘车记录不会因为站点没了就查不出来。
后台的操作界面只需三个按钮:加站、调序、停用。调序用上下移动箭头就够,别做成拖拽——拖拽在小屏上极容易点错,而且拖拽排序的交互在小程序里体验一般。管理员改完之后,line.version自增,小程序端每次拉线路详情时比对版本号,变了就提示「线路有更新」,避免用户还按老顺序看车。
4. 核心功能落地:位置上报、到站预估与扫码核验的三段实现
4.1 司机端位置上报与节流
位置上报有两个坑:一是用wx.getLocation单次获取,只能拿到一个点,车一动就对不上;二是上报频率不控制,页面一开就疯狂发请求。正确做法是用持续定位接口加节流:
// miniprogram/pages/driver/driver.js —— 司机开班后开启持续定位 Page({ data: { running: false, busId: null }, lastReport: 0, onStart() { wx.startLocationUpdate({ // 持续定位,不能用单次 getLocation 替代 success: () => { wx.onLocationChange(this.reportLocation); this.setData({ running: true }); }, fail: () => wx.showToast({ title: '请打开定位权限', icon: 'none' }) }); }, reportLocation(res) { const now = Date.now(); if (now - this.lastReport < 3000) return; // 三秒节流,避免把服务端打满 this.lastReport = now; wx.request({ url: `${app.globalData.baseUrl}/api/vehicle/location`, method: 'POST', header: { 'x-token': wx.getStorageSync('token') }, data: { busId: this.data.busId, lat: res.latitude, // 微信返回的是 gcj02 坐标系 lng: res.longitude, speed: res.speed || 0, // 米每秒,用于到站预估的实时修正 ts: now } }); } });res.speed这个值很多人在文档里看不到就忽略了,它其实是从定位点位移推算出来的,精度不高但足够判断「车在动还是停着」。服务端收到之后不写历史表,只覆盖「该车最新位置」这一条记录,键是busId。这个设计让存储量从「随时长线性增长」变成「恒定」,一辆车永远只占一条。
再补一个细节:司机点「收班」时一定要调wx.stopLocationUpdate()并置running=false。忘了关的后果是整晚都在发请求,第二天起床看到服务器账单会很难受。
4.2 到站预估:不用玄学算法也能稳住
到站时间是整套系统里最容易被质疑的地方。用机器学习?数据量根本不够。纯按站点数乘固定时间?车堵在路口十分钟,显示的还是「3 分钟」。我的做法是混合估算——剩下的中间站按历史均值打底,当前这一段按实时速度修正:
// server/service/eta.js // 剩余站间历史耗时 + 当前段实时修正,两者相加 function estimateArrival({ stationSeq, currentSeq, avgBetweenMin, remainDistanceM, speedMps }) { const stationGap = Math.max(stationSeq - currentSeq - 1, 0); // 中间还隔着几站 const baseMin = stationGap * avgBetweenMin; // 历史均值打底 const speed = speedMps > 1 ? speedMps : 3.5; // 低于 1m/s 视为停车等灯,用默认 3.5m/s const realMin = remainDistanceM / speed / 60; // 当前段按实时速度算 const eta = baseMin + realMin; return Math.round(Math.min(Math.max(eta, 0.5), 30)); // 夹在 0.5~30 分钟 }avgBetweenMin不要凭感觉填,从乘车记录里反推:每条核销记录都带了时间戳和站点,同一辆车相邻两站的核销时间差取中位数,就是这段路最接近真实的耗时。第一次上线没有数据,就先用手测值填,跑一周再替换。
最后那个Math.min(Math.max(eta, 0.5), 30)的夹取范围,是我踩过坑之后加的。出现过一次司机端 GPS 漂移,remainDistanceM算出了一个三千多米的值,界面直接显示「到站 47 分钟」,学生截图发到群里问是不是车坏了。加上下界和上界之后,异常值至少不会吓到人。这里没有玄学,全是边界处理。
4.3 扫码核验与乘车记录落库
乘车核验的流程是:每辆车贴一张二维码,内容是车辆编号加当天随机串;学生扫到之后,把码和身份发给服务端,服务端判断当前时间是否落在某个班次的时间窗内,写一条ride_record。
// server/routes/ride.js —— 扫码核销的核心判断 router.post('/verify', auth('student'), async (req, res) => { const { busCode } = req.body; const bus = await VehicleModel.findByCode(busCode); if (!bus) return res.json({ code: 404, msg: '车辆无效' }); // 找这辆车当前正在运行的班次,前后各放宽 10 分钟 const schedule = await ScheduleModel.findRunning(bus.id, new Date(), 10); if (!schedule) return res.json({ code: 410, msg: '当前不在发车时间' }); // 同一用户同一班次只记一次,靠唯一索引兜底,不靠先查后插 const inserted = await RideModel.insertIgnore({ user_id: req.user.id, schedule_id: schedule.id, bus_id: bus.id, ride_at: new Date() }); return res.json({ code: 0, data: { duplicated: !inserted } }); });这里的关键是「唯一索引兜底」而不是「先查再插」。用户手快连点两下,两个请求几乎同时到达,先查后插必然插进去两条。正确做法是给(user_id, schedule_id)建唯一索引,插入用INSERT IGNORE,影响行数为 0 就说明重复了,返回一个友好提示即可。
班次时间窗放宽十分钟也是刻意的。司机晚发车两分钟、学生跑着赶上车,这些都是常态;卡死在计划时刻会让大量正常乘车被判成无效,反而制造矛盾。
5. 避坑与排查:上线前后最容易翻车的五个点
5.1 定位权限被拒之后就再也没提示
现象是司机端点了「开班」,定位图标亮了但位置一直不动,学生端看到的车永远停在起点。原因多半是用户第一次进来时随手点了「拒绝」,之后再调startLocationUpdate直接走 fail 分支,而代码里只弹了个 toast,没引导去设置页。
解决方式是在 fail 回调里调wx.showModal,确认后执行wx.openSetting()让用户重新授权。同时司机端页面加一个显式的状态条:绿色表示定位中、黄色表示权限未开、红色表示上报失败,让司机自己看得见。
5.2 坐标系混用导致车标跑到河里
现象是地图上车辆图标位置整体偏了几百米。原因是微信返回的lat/lng是 gcj02,而在后台录入站点时用地图工具取的点可能来自别的坐标系,两者叠在一张地图上就错位了。
解决方式是全链路统一用 gcj02:站点坐标在后台录入时就用同一套工具取点,数据库里加一列coord_type标明来源,服务端返回位置时不做任何转换。如果确实需要转换,转换只能发生在「入库前」这一个环节,不能出现在渲染时。
5.3 轮询把服务端打满
现象是课间高峰小程序卡顿、接口偶发超时。原因通常是学生端每页都在setInterval拉全量车辆位置,一百个人同时在线,每秒几十次查询,而每次查询都扫一遍车辆表。
解决方式分两层:一是轮询间隔按页面可见性控制,onHide里清定时器,onShow再重建;二是服务端把「线路 → 车辆最新位置」的结果缓存五秒,五秒内重复请求直接返回缓存。实测这一层缓存就能把查库次数压掉九成。
5.4 跨零点的班次被判成第二天
现象是晚上十一点半的车扫不上码,提示「不在发车时间」。原因是查询用DATE(depart_at) = CURDATE()过滤,末班车如果排在 23:55,加上十分钟缓冲就跨到了次日。
解决方式是不用日期函数过滤,直接用「当前时间落在depart_at前后 N 分钟内」的范围查询,并给depart_at建索引。班次本身带run_date,跨零点的问题在生成阶段就已经用整数分钟避开,查询阶段就不要再引入新的日期计算。
5.5 测试数据污染当天班次
现象是演示时同一辆车被绑到了两条线路上,地图上出现两个重叠图标。原因是开发阶段为了省事,直接往schedule表里手插了几条bus_id相同的记录,测完没清。
解决方式是给schedule表加UNIQUE KEY uk_bus_time (bus_id, depart_at),让数据库自己拒绝重复绑定;另外准备一个只删测试数据的脚本,每次演示前跑一遍。这个脚本三行 SQL,但能省掉答辩现场找回车标的尴尬。
6. 验收与答辩:用一份可复现的演示脚本证明它真能跑
验收这件事,评委和甲方看的从来不是功能列表有多长,而是「你演示的这条路径能不能一次跑通、出了异常会不会崩」。所以我习惯先写一张验收清单,逐条打钩再上答辩台:
| 验收项 | 通过标准 | 检查方式 |
|---|---|---|
| 线路与站点 | 首页能看到至少两条线路、每站顺序正确 | 学生端逐条点开 |
| 班次生成 | 当天班次数 = 首末班时间差 / 间隔 + 1 | 后台对比条数 |
| 位置上报 | 司机移动车辆时,学生端车标 10 秒内跟动 | 两机同时操作 |
| 到站预估 | 显示值随距离缩短而减小,不出现负数或超过 30 | 沿线路走一段 |
| 扫码核验 | 正常码成功,重复扫提示已核销 | 连扫两次 |
| 权限边界 | 学生身份调司机接口返回 403 | 用接口工具直连 |
答辩演示的脚本我一般写死成六步,五分钟跑完:先打开首页看两条线路,点进线路详情看地图上的车,切到司机端开班并上报,切回学生端看到车动,扫码乘车成功,最后故意用学生账号请求一次司机接口,展示 403。
第六步是很多人会漏的加分项。功能跑通只证明「能work」,而主动演示一次权限拦截、一次断网降级、一次重复扫码,证明的是「你想过它会坏」。我通常会在演示到扫码那一步时,先关掉司机的定位权限,让学生端的到站时间退回到用eta_offset兜底的显示值,再接一句「这时候显示的是历史均值,不是实时算的」——评委的眼睛会亮一下,因为他知道这背后是真的做过。
还有一个小技巧:把服务端的日志打干净一点,每条请求打一行「用户角色 + 接口 + 耗时 + 结果码」。答辩现场如果某个接口卡住,你能立刻从日志里指出是哪一层慢,而不是站在那里刷新页面。这套习惯是我第一次做答辩演示时当众卡了三分钟之后留下来的,从那以后我每次上线前都强制走一遍「关权限、断网络、连点两次」的降级路径,把丑话先自己说一遍,现场就没人能问倒你。希望帮到你。
本文还有配套的精品资源,点击获取