
简介面向微信小程序初学者与校园交通应用开发者这份原创项目以“校园摆渡车”为场景提供从界面交互到服务端接口的完整实践可用于查询车辆位置、上下车点、时刻表与座位预订等需求。压缩包共79个文件大小约1.01MB包含Java后端源码与Servlet、DAO、Bean等分层代码配套jar依赖、JSP页面及JSON处理库前端则有WXML、WXSS、JavaScript页面逻辑和图片资源目录分后端、前端两部分便于对照学习。后端围绕车辆信息、时刻表和用户数据组织数据库与接口前端通过地图组件展示位置并完成查询、预约等交互。已有416人学习。读者可据此理解小程序生命周期与组件体系、响应式UI布局、RESTful接口设计、数据库增删改查、前后端JSON通信、地图API集成和实时定位更新也能参考服务器部署思路完成上线演练适合作为课程设计、毕业项目或入门练手案例。1. 校园摆渡车微信小程序把「等车靠猜」变成「看板上车」早八课前十分钟宿舍楼下站了三十多个人谁也不知道下一班摆渡车在三分钟后的路口还是刚出车库——这是我拆这套校园摆渡车微信小程序时最直接的动机。它是一个自创的、不依赖任何第三方打车 SDK 的完整工程小程序端负责线路列表、站点时序、地图上的车辆图标和扫码乘车云函数负责查线路、收车辆坐标、生成订单云数据库存五张核心集合。适合两类人想拿一个真实业务练微信小程序 云开发完整链路的在校开发者以及要给某个园区、厂区做内部通勤工具的团队。它不复杂但把「实时位置 扫码核销」这两件最容易翻车的事做全了代码量刚好够读懂也刚好够改。2. 架构与目录结构小程序端、云函数、云数据库三层怎么切这套工程最值得先看的是分层边界而不是某个页面的样式。整个项目只有三层且三层之间的耦合点被压到很少小程序端只做「渲染 触发」所有写库动作都走云函数数据库只被云函数访问。这样做的直接好处是小程序端的代码可以整包替换比如你以后想改成 uni-app 发多端只要云函数的入参出参契约不变后端一行不用动。2.1 技术栈定调原生小程序加云开发还是 uni-app 加自建服务项目走的是微信原生框架WXML/WXSS/JS 微信云开发。这不是偷懒是场景决定的校园摆渡车的用户在微信里不需要 App、不需要 H5、不需要支付宝端原生框架的包体最小、地图组件最稳、审核路径最短。云开发则省掉了服务器、域名备案、HTTPS 证书这一整套运维动作对一个由几个人维护的校内工具来说这是能长期活下去的关键。我一般会按这张表来判断要不要沿用这套选型维度原生 云开发本项目uni-app 自建 Node 服务首屏包体约 12MB冷启动快多端适配层会额外增加体积地图能力直接用 map 组件marker 渲染稳定需要评估各端地图差异后端运维无服务器云函数按量计费需要服务器、域名、证书、日志复杂事务云数据库支持事务但受单文档限制关系型数据库更自由迁移成本换云厂商要重写数据访问层相对可控落到代码上入口只有两个文件在管全局。app.js负责初始化云环境app.json负责声明页面、窗口样式和定位权限——后者是新手最容易漏的地方requiredPrivateInfos不写真机上wx.getLocation直接失败模拟器却一点问题没有这种「模拟器好好的、真机全挂」的玄学问题后面第 5 章还会展开。// miniprogram/app.js —— 全局只做一件事把云环境初始化好 App({ globalData: { envId: shuttle-prod-xxxx, // 换成你自己的云开发环境 ID campusId: main // 多校区时用它做数据隔离 }, onLaunch() { if (!wx.cloud) { console.error(基础库版本过低请升级到 2.2.3 以上) return } wx.cloud.init({ env: this.globalData.envId, traceUser: true // 让云函数能拿到用户访问记录便于排查 }) } }){ pages: [ pages/home/index, pages/route/index, pages/scan/index, pages/orders/index, pages/driver/index ], window: { navigationBarTitleText: 校园摆渡车, navigationBarBackgroundColor: #1f6feb, navigationBarTextStyle: white }, permission: { scope.userLocation: { desc: 用于在地图上显示摆渡车实时位置 } }, requiredPrivateInfos: [getLocation, onLocationChange, startLocationUpdate], sitemapLocation: sitemap.json }envId抽到globalData里而不是散落在各个页面是为了后面切测试环境和生产环境时不至于满地找字符串。campusId则是给多校区预留的隔离字段每条线路、每辆车都带它查的时候带上就天然分开了。requiredPrivateInfos里三个接口要一起声明只写getLocation的话司机端持续定位那部分照样会被拒。2.2 目录结构pages 管界面cloudfunctions 管写库工程的目录不深但每个目录的职责划得很干净。下面是我拉下来的实际结构注释标了每个位置该放什么campus-shuttle/ ├── miniprogram/ │ ├── app.js / app.json / app.wxss 全局入口与主题变量 │ ├── pages/ │ │ ├── home/ 首页线路卡片列表 地图车辆图标 │ │ ├── route/ 线路详情站点时序、当前到站、预计等待 │ │ ├── scan/ 扫码乘车拉起相机、核销、出订单 │ │ ├── orders/ 我的行程订单列表与状态 │ │ └── driver/ 司机端持续定位上报开关 │ ├── components/ │ │ ├── route-card/ 线路卡片首页复用 │ │ └── stop-timeline/ 站点时间轴详情页复用 │ ├── utils/ │ │ ├── request.js callFunction 的统一封装 │ │ └── format.js 时间、距离格式化 │ └── images/ 车辆图标、空状态插图 ├── cloudfunctions/ │ ├── login/ 换取 openid落 users 集合 │ ├── getRoutes/ 线路列表与详情查询 │ ├── getVehiclePos/ 按线路拉最新车辆坐标 │ ├── reportLocation/ 司机端坐标写入 │ ├── createOrder/ 扫码核销幂等 事务 │ └── initDb/ 一次性建集合仅开发环境用 ├── project.config.json └── README.md两个约定值得抄走。第一pages下一个目录只放一个页面的四个文件不跨页面共享逻辑共享的东西一律进components或utils——否则半年后你会看到三个页面里复制了三份一样的距离计算公式。第二云函数按「一个动作一个函数」拆不做一个万能api函数靠event.action分发。拆开的代价是文件多好处是每个函数的权限、超时、日志都是独立的出问题时云开发控制台里一眼能定位到是谁挂了。utils/request.js这层封装很薄但很值// miniprogram/utils/request.js —— 统一处理云函数调用失败 export async function call(name, data {}, options {}) { const { loading false, loadingText 加载中 } options if (loading) wx.showLoading({ title: loadingText, mask: true }) try { const res await wx.cloud.callFunction({ name, data }) const payload res.result || {} if (payload.code ! 0) { wx.showToast({ title: payload.msg || 服务异常, icon: none }) return null } return payload.data } catch (e) { // 网络断开、云函数超时、环境 ID 写错都走这里 console.error([callFunction:${name}], e) wx.showToast({ title: 网络异常请重试, icon: none }) return null } finally { if (loading) wx.hideLoading() } }参数name是云函数名data是透传给云函数的事件对象options.loading控制要不要盖一层 loading。约定了「云函数一律返回{ code, msg, data }」之后页面里就再也不用写if (res.result.success)这种每处都不一样的判断了调用方只需要关心拿到的data是不是null。2.3 数据模型五个集合撑起整条业务线整个业务只有五个集合字段不多但索引必须建对否则数据量一上来查询直接超时。核心字段我整理成下面这张表索引列是后面第 4 章要在控制台里手动加的集合关键字段建议索引routescampusId、name、status、sort、stops[]、firstTime、lastTimecampusId status sort 复合索引stopscampusId、name、latitude、longitudecampusId 单字段vehicleslineId、driverOpenid、latitude、longitude、seats、updatedAtlineId 单字段updatedAt 单字段ordersopenid、qrToken、lineId、status、amount、createdAtopenid createdAt 复合索引qrToken 单字段users_openid、nickName、role、lastLoginAt_openid 单字段默认已有stops里的经纬度存的是原始数值routes.stops只存站点 id 数组和顺序。这样改一个站点的坐标不用去动所有线路文档是个很实在的取舍。vehicles里updatedAt存时间戳而不是 Date 对象是因为前端要拿它算「数据新鲜度」——超过 60 秒没更新的车地图上要画成灰色不然用户会以为车真的停在那不动。安全规则也得同步配订单集合绝不能开放写权限// orders 集合的安全规则 { read: doc.openid auth.openid, write: false }读规则限定只能读自己的单写直接关死所有写入走云函数。routes、stops这类公共数据可以放开读、关死写vehicles建议也关写司机上报走云函数校验身份避免有人伪造坐标把车「开」到湖里。3. 核心链路代码线路查询、位置刷新、扫码核销、司机上报四个场景串起来就是完整业务闭环用户进来查线路 → 看车在哪 → 扫码上车 → 司机端持续上报。每个场景都只挑最关键的那段代码看边角料在工程里都有。3.1 线路查询一个云函数同时服务列表和详情首页要的是线路摘要详情页要的是带站点的完整对象没必要写两个云函数。用一个getRoutes靠event里有没有routeId分流// cloudfunctions/getRoutes/index.js const cloud require(wx-server-sdk) cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db cloud.database() exports.main async (event) { const { campusId main, routeId , keyword } event // 详情按 id 精确取一条 if (routeId) { const res await db.collection(routes).doc(routeId).get() if (!res.data) return { code: 404, msg: 线路不存在 } // 站点 id 数组换成站点对象前端直接用 const stopIds res.data.stops || [] const stopsRes await db.collection(stops) .where({ _id: db.command.in(stopIds) }) .get() const map {} stopsRes.data.forEach(s { map[s._id] s }) const stops stopIds.map(id map[id]).filter(Boolean) return { code: 0, data: { ...res.data, stops } } } // 列表只返回启用中的线路按 sort 升序 const where { campusId, status: online } if (keyword) { where.name db.RegExp({ regexp: keyword, options: i }) } const listRes await db.collection(routes) .where(where) .orderBy(sort, asc) .limit(50) .field({ name: true, firstTime: true, lastTime: true, stops: true, color: true }) .get() return { code: 0, data: listRes.data } }campusId是隔离字段不传默认mainrouteId一旦传了就走详情分支忽略keywordkeyword走的是正则模糊匹配线上数据量大时建议换成前缀匹配或者干脆交给后台搜索服务正则全表扫在几千条以内还行再多就慢了。field()那行是刻意只取列表需要的字段stops在这里只用于算站点数量不在首页渲染完整时序。前端拿到之后直接渲染线路卡片组件接收一个route对象// miniprogram/pages/home/index.js片段 import { call } from ../../utils/request Page({ data: { routes: [], loading: true }, async onLoad() { const list await call(getRoutes, { campusId: main }, { loading: true }) this.setData({ routes: list || [], loading: false }) }, goDetail(e) { const { id } e.currentTarget.dataset wx.navigateTo({ url: /pages/route/index?id${id} }) } })call返回null时说明已经弹过 toast 了页面直接把routes置空显示空状态即可不用再判断一次错误码。这是把错误处理收口到一处的直接收益。3.2 车辆位置刷新8 秒一轮询地图 marker 的 id 必须是数字实时位置这块没有用 WebSocket用的是定时轮询。原因很实际云开发的长连接方案对小车队来说成本不划算而摆渡车 8 秒刷一次完全够用——车在校园里跑8 秒最多挪几十米视觉上足够连续。// miniprogram/pages/home/index.js位置部分 Page({ data: { markers: [], latitude: 30.2741, longitude: 120.1551, scale: 16 }, onShow() { this.fetchVehiclePos() this.timer setInterval(() this.fetchVehiclePos(), 8000) }, onHide() { this.clearTimer() }, onUnload() { this.clearTimer() }, // 少这一行定时器会一直跑 clearTimer() { if (this.timer) { clearInterval(this.timer); this.timer null } }, async fetchVehiclePos() { const list await call(getVehiclePos, { lineId: this.data.lineId }) if (!list) return const now Date.now() const markers list.map((v, i) { const stale now - v.updatedAt 60000 // 超过 60 秒没更新算失联 return { id: i 1, // 必须是数字传字符串 id 不渲染 latitude: v.latitude, longitude: v.longitude, iconPath: stale ? /images/bus-gray.png : /images/bus.png, width: 32, height: 32, callout: { content: ${v.lineName} · 余${v.seats}座, display: BYCLICK, padding: 6, borderRadius: 4 } } }) this.setData({ markers }) } })onShow里起定时器、onHide和onUnload里都清是因为小程序页面在navigateTo之后只是onHide并不会销毁用户来回切几次首页就会叠出好几个定时器请求量翻倍、地图图标闪——这个坑我在别的项目里吃过一次查了半天才发现是定时器没清。markers的id用数组下标而不是数据库的_id是因为微信地图组件只认数字 id塞字符串进去不报错但图标就是不显示属于典型的黑匣子问题。3.3 扫码核销幂等 token 加事务扣座扫码乘车是整个项目里唯一需要保证数据一致的环节写坏了就是重复扣费或者超卖。云函数这边做了两层保护一是同一用户对同一二维码 60 秒内只生成一单二是扣座位放在事务里。// cloudfunctions/createOrder/index.js const cloud require(wx-server-sdk) cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db cloud.database() const _ db.command exports.main async (event) { const { OPENID } cloud.getWXContext() const { qrToken, vehicleId } event if (!qrToken) return { code: 400, msg: 二维码无效 } // 第一层幂等防用户连点两次 const dup await db.collection(orders).where({ openid: OPENID, qrToken, createdAt: _.gt(Date.now() - 60000) }).limit(1).get() if (dup.data.length) { return { code: 0, data: dup.data[0], msg: 已生成订单 } } // 第二层事务里扣座位 写订单 const transaction await db.startTransaction() try { const vRes await transaction.collection(vehicles).doc(vehicleId).get() if (!vRes.data || vRes.data.seats 0) { await transaction.rollback() return { code: 4001, msg: 车辆已满座 } } await transaction.collection(vehicles).doc(vehicleId).update({ data: { seats: _.inc(-1) } }) const order { openid: OPENID, qrToken, lineId: vRes.data.lineId, vehicleId, amount: 2, // 单位元实际按线路票价取 status: paid, createdAt: Date.now() } await transaction.collection(orders).add({ data: order }) await transaction.commit() return { code: 0, data: order } } catch (e) { await transaction.rollback() console.error([createOrder], e) return { code: 500, msg: 下单失败请重试 } } }OPENID从getWXContext()拿前端传什么用户身份都不作数这是防伪造的底线。qrToken是贴在车门上的二维码内容一个车一个不随时间变——如果要防截图转发可以改成带时间戳的动态码云函数里校验有效期。_.inc(-1)是原子自减比先读再写安全。事务里先读seats再判断读到 0 就回滚避免并发时两个人都读到最后一张票。前端调用要顺手把按钮锁住双重保险// miniprogram/pages/scan/index.js片段 onScan() { if (this.data.submitting) return // 防手抖连点 wx.scanCode({ scanType: [qrCode], success: async (res) { this.setData({ submitting: true }) try { const data await call(createOrder, { qrToken: res.result, vehicleId: this.data.vehicleId }) if (data) wx.redirectTo({ url: /pages/orders/index }) } finally { this.setData({ submitting: false }) } } }) }3.4 司机端上报5 秒节流加前后台切换的收尾司机端只有一个开关打开就开始把坐标往云函数推。真正要做好的是节流和页面生命周期。// miniprogram/pages/driver/index.js片段 Page({ data: { lineId: , reporting: false }, startReport() { wx.startLocationUpdate({ success: () { this.setData({ reporting: true }) this.listener wx.onLocationChange((loc) this.push(loc)) }, fail: () wx.showToast({ title: 请先开启定位权限, icon: none }) }) }, push(loc) { const now Date.now() if (now - (this.lastPush || 0) 5000) return // 5 秒节流别把云函数打爆 this.lastPush now wx.cloud.callFunction({ name: reportLocation, data: { lineId: this.data.lineId, latitude: loc.latitude, longitude: loc.longitude, speed: loc.speed } }) }, stopReport() { if (this.listener) { wx.offLocationChange(this.listener); this.listener null } wx.stopLocationUpdate() this.setData({ reporting: false }) }, onHide() { if (this.data.reporting) this.stopReport() }, // 切后台就停省电也省费用 onUnload() { this.stopReport() } })onLocationChange的回调频率由系统决定可能一秒好几次直接上报会让云函数调用量飙升5 秒节流是必须的。onHide里主动停掉是因为小程序进后台后定位会持续耗电司机会骂人。云函数侧再补一道身份校验只允许users.role driver的账号写vehicles不然谁都能改坐标。4. 跑起来环境准备、集合初始化、真机预览三件事代码看着顺真跑起来还是要按顺序来。这一章按我实际导入的顺序写卡在哪一步就看对应小节。4.1 环境准备AppID、开发者工具、云开发开通先去微信公众平台注册一个小程序账号拿到 AppID个人主体也能开通云开发够用。装好微信开发者工具后选择「导入项目」目录选到含project.config.json的那一层AppID 填自己的。导入完成后点顶部工具栏的「云开发」按引导开通环境把环境 ID 复制下来填进app.js的envId。这一步有两个容易忽略的点。一是开发者工具的基础库版本要选 2.2.3 以上低版本没有wx.cloud对象一进来就白屏。二是一个账号下可以有多个云环境测试用一个、上线用一个别在同一个环境里既跑测试数据又跑真实订单不然orders集合会被测试单污染后面算账对不上。4.2 集合初始化先建集合再建索引集合可以手动在云开发控制台的数据库里点但五个集合点下来容易漏。工程里放了一个initDb云函数只在开发阶段跑一次// cloudfunctions/initDb/index.js —— 只跑一次上线前建议删掉 const cloud require(wx-server-sdk) cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db cloud.database() const COLLECTIONS [routes, stops, vehicles, orders, users] exports.main async () { const created [] for (const name of COLLECTIONS) { try { await db.createCollection(name) created.push(name) } catch (e) { // -501001 表示集合已存在重复跑不报错 if (e.errCode ! -501001) throw e } } return { code: 0, data: { created } } }在开发者工具里右键initDb选「上传并部署云端安装依赖」然后在云开发控制台点「测试」直接跑一次返回created里就是新建成功的集合名。集合建完立刻去「索引管理」把第 2 章表里的索引加上尤其是routes的campusId status sort复合索引和orders的openid createdAt——这两个查询在首页和「我的行程」里每次都会跑没索引的时候一百条数据就开始转圈。数据可以用控制台的「导入」功能塞一份 JSON格式是每行一个对象的 JSON Lines。routes里先塞两三条真实线路stops塞对应的站点坐标vehicles可以先手动造一辆车updatedAt填当前时间戳这样首页地图上就能看到一个图标。4.3 真机预览定位、地图、扫码三个权限模拟器上地图能显示、定位能返回不代表真机没问题。点「预览」用手机扫码打开之前先确认三件事app.json里requiredPrivateInfos三个接口都声明了permission.scope.userLocation的desc文案写得让人愿意点同意写「需要获取位置」容易被拒写具体用途通过率高小程序后台的「开发管理 - 接口设置」里位置接口和扫码接口都已经申请开通。提示地图组件在真机上首次渲染会有一两秒白屏属于正常现象不要急着判断是代码问题。扫码核销在真机上测试时得先准备一张二维码。用草料之类的工具把qrToken生成二维码打印出来贴在桌子上或者直接把二维码图片存到手机相册用wx.scanCode的相册选图入口也能测。测完一笔订单记得去控制台把对应车辆的seats手动加回去否则下一轮测试会一直提示满座。5. 避坑排查上线前真机上翻过的 5 个车下面这五条都是我在真机阶段真实撞过的按「现象 → 原因 → 解决」写遇到相似症状可以直接对号。5.1 首页越点越卡云函数调用量莫名其妙翻倍现象在首页和详情页之间来回切几次之后地图上的车辆图标开始闪烁云开发控制台里getVehiclePos的调用量是正常值的三倍以上。原因onShow里每次都setInterval但页面navigateTo之后只是onHide不销毁定时器一直在跑回来再叠一个新的。解决把定时器句柄存在this.timer上onShow里先clearTimer()再起新的onHide和onUnload各清一次。改完再切十次调用量稳定。5.2 车辆图标在模拟器上有真机上就是不显示现象开发者工具里地图上能看到车真机预览一片空白控制台也没有任何报错。原因markers数组里的id用了数据库的_id是字符串。地图组件要求id为数字类型不对时不报错直接静默不渲染。解决渲染前map一遍id改用数组下标i 1。如果页面上有多组 marker 混用给每组留一个数值区段比如车辆从 1 开始、站点从 1001 开始避免撞 id。5.3 真机上点「开始上报」直接弹「定位未授权」现象司机端页面点开关toast 提示授权失败fail回调里拿到auth deny但模拟器一切正常。原因app.json只声明了permission.scope.userLocation没有在requiredPrivateInfos里列出startLocationUpdate和onLocationChange真机上这两个接口被直接拦掉。解决三个接口一起声明。已经拒绝过的用户不会二次弹窗得引导去wx.openSetting手动打开或者在小程序后台的「用户隐私保护指引」里补齐位置信息用途说明。5.4 用户连点两次扫码生成了两笔订单现象扫码页按钮响应稍慢用户手抖点两下订单列表里出现两条一模一样的记录座位被扣了两次。原因前端只做了按钮loading样式没有真正的防重入云函数侧也没有幂等判断。解决前端加submitting标志位进入就置 true、finally里置回云函数按openid qrToken 60 秒窗口查一次已有订单命中就直接返回原单。两层都加之后即使有人绕过前端直接调接口也不会重复扣。5.5 换了个云环境所有页面集体报「环境不存在」现象把测试代码发到另一个环境里跑首页直接提示错误云函数日志一条都没有。原因环境 ID 硬编码在app.js里打包上传时没跟着改cloudfunctions目录下的函数也没重新部署到新环境。解决envId只保留在app.js一处云函数统一用cloud.DYNAMIC_CURRENT_ENV自动跟随调用方环境不要写死。切换环境时用开发者工具的多环境配置或者干脆做一份config.dev.js/config.prod.js构建时替换。6. 二次开发加线路不改代码换主题只动变量真正上手改的时候会发现这个工程的改造点分布得很不均匀业务数据类的改动几乎不用碰代码视觉和接入层的改动才需要动文件。下面这张表是我整理的常见改造需求与对应动作可以直接照着做。改造需求改哪里是否需要动代码新增一条线路routes集合加文档 stops补站点否改票价routes加price字段createOrder读取是一处换主题色app.wxss里的 CSS 变量否接入自有后端重写utils/request.js的call是一处多校区隔离全局campusId换成登录后下发是少量动态二维码防截图createOrder里校验 token 有效期是一处换主题是最简单的工程里的颜色都收在app.wxss的变量里/* miniprogram/app.wxss */ page { --brand: #1f6feb; /* 主色导航栏、按钮、选中态都用它 */ --brand-light: #e8f0fe; /* 浅色背景线路卡片高亮 */ --text-main: #1a1a1a; --text-sub: #8a8f99; --danger: #e5484d; /* 满座、失联车辆 */ }把--brand换个值导航栏颜色在app.json里同步改一下整套视觉就换了不用去每个 wxml 里找色值。接自有后端这件事值得单独说一句。这个工程所有数据访问都收口在utils/request.js的call函数只要你实现的后端接口也返回{ code, msg, data }这个结构把wx.cloud.callFunction换成wx.request就完事了页面一行都不用改。我一般会先在本地起一个 mock 服务把getRoutes、getVehiclePos、createOrder三个接口按同样结构返回假数据前端跑通之后再逐条切真接口这样迁移过程里随时能回滚。验证改造成果有个笨但有效的办法改完之后用一个全新的微信号走一遍「进首页 → 看地图 → 进详情 → 扫码 → 看订单」的完整链路中间不刷新、不重启。只要这条链路能一口气走完说明数据模型、权限、云函数部署这三块都是对的任何一环卡住问题一定在你刚改的那个文件附近不用满项目找。从那以后我每次动这类小程序项目都会先把utils/request.js和app.js这两个文件读一遍再动手因为改动一旦绕开它们问题就会散到十几个页面里查起来毫无性价比。希望帮到你。本文还有配套的精品资源点击获取