ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

保洁预约小程序开发实战:微信云开发、订单状态机与微信支付全解析

保洁预约小程序开发实战:微信云开发、订单状态机与微信支付全解析

简介:面向家政服务行业开发者的一份上门预约家庭保洁小程序源码,整合预约、服务展示、微信支付、评价、消息推送与定位导航等完整链路,可帮助创业者或小程序开发者在现成代码上快速搭建线上保洁服务平台,降低从零开发与试错成本。资源共2005个文件,压缩包约22.28MB。主要包含小程序前端页面所需的wxml、wxss、js及json文件,同时涵盖后台php接口、html管理页面与layui等样式资源,gif与png图片用于界面展示,整体目录覆盖pages、utils、models、services等模块,便于按业务进行二次开发。目前已有211人学习下载。源码提供预约流程、支付对接与服务端处理逻辑示例,可基于需求扩展保洁项目、会员系统或第三方支付,适合作为家政服务类小程序开发的基础模板与功能参考。

1. 为什么“保洁预约小程序”比开发独立 App 更值得投入

“上门预约家庭保洁服务小程序源码”说的是把“打电话约保洁、微信群排期、上门再结账”这一长串原始流程,压成小程序里“授权手机号—选服务—选时间段—支付—等师傅上门”五个动作。对家政公司来说,小程序就是线上门店;对开发者来说,它是一套完整的微信生态工程:手机号授权、下单支付、订单状态机、消息提醒、排期防冲突全在一个工程里闭环。适合三类人:保洁公司想自建接单渠道、家政平台做最小成本验证、以及想真正跑通微信支付和订单流转的开发新手。需要先泼一盆冷水:小程序注册费不贵,但想开通微信支付和“获取手机号”接口,主体必须是企业或个体工商户,个人主体做不了。

2. 先设计订单核心表:服务项、上门地址与状态机怎么落库

很多第一次做预约类小程序的人,一上来就写页面,写到支付那一步发现订单字段缺一堆:没有服务时长、没有地址经纬度、没有状态流转记录。结果是界面返工、数据库反复改。做这类工具型小程序,我一般先把数据模型定死,页面只是数据的陈列。

2.1 五张表的关系:服务表、地址表、订单表、日志表、师傅表

预约核心不是一张订单表,而是五张表配合。服务表services存“日常保洁2小时”“深度保洁4小时”这类可售商品;地址表addresses存上门位置;订单表orders存一次预约;日志表order_log存状态变化;师傅表technicians存保洁师可服务时段。

// services 服务项示例 { "_id": "srv_001", "name": "日常保洁·2小时", "price": 99, "unit": "次", "duration_min": 120, "skill_tags": ["玻璃", "除尘"], "enabled": true }
// addresses 上门地址示例 { "_id": "addr_001", "user_openid": "oXXXX", "contact_name": "张姐", "phone": "13800138000", "city": "上海市", "district": "浦东新区", "detail": "张江路xx弄xx号", "lat": 31.205, "lng": 121.594, "is_default": true }

服务表里price的单位是“元”,但后面支付时金额单位是“分”,这个转换要在云函数里做,不要在前端页面里乘 100,否则改个价格还要重新发版。地址表单独一张而不是塞进订单表,是因为同一个用户可能存了公司、父母家、自己家三个地址,下单时只需要选,不需要反复输入。经纬度字段最好存,派单按距离排序时用得上。

订单表orders里不直接存服务名、地址文本,只存service_id和addr_id的外键。这样做的好处是:服务涨价后,历史订单依然能查到当时的价格快照吗?不能。所以我在订单表里额外冗余存了service_snapshot和amount_at_order,订单创建时把当前服务名称、单价复制进去。这是预约类系统里特别容易踩的坑:不存快照,三个月后查账发现金额对不上,因为服务价格变了。

2.2 微信登录与手机号授权:新版 getPhoneNumber 的完整参数

小程序登录不是传统意义上的账号密码,而是wx.login()换 openid,配合“获取手机号”按钮拿到用户手机号。老版本的做法是前端拿到encryptedData和iv以后,丢给后端用code2Session的 session_key 解密;新版本微信出了动态令牌code,后端拿这个 code 直接换手机号,不再需要解密。如果刚开始做,直接走新版。

<!-- pages/login/login.wxml --> <button open-type="getPhoneNumber" bindgetphonenumber="onGetPhone" loading="{{phoneLoading}}" >微信一键登录</button>
// pages/login/login.js async onGetPhone(e) { if (e.detail.errMsg !== 'getPhoneNumber:ok') { wx.showToast({ title: '需要手机号才能下单', icon: 'none' }); return; } // 新版动态令牌,5分钟内有效且只能消费一次 const { code, cloudID } = e.detail; const res = await wx.cloud.callFunction({ name: 'login', data: { code, cloudID } }); const { openid, phone } = res.result; this.setData({ openid, phone }); wx.setStorageSync('openid', openid); }

逻辑说明:bindgetphonenumber事件里,新版e.detail.code是动态令牌,一次性使用;cloudID是云开发专属能力——手机号数据可以直接在云函数里通过cloud.getOpenData读取,前端根本不碰手机号明文,隐私合规压力小很多。errMsg一旦不是getPhoneNumber:ok,不要做任何提示以外的操作,用户点了取消按钮就会触发这个分支。

参数说明里最容易被忽略的是「基础库版本」。新版动态令牌要求微信基础库2.21.2以上,在app.json里最好声明"libVersion": "2.21.2"或更高。老项目如果一直没升级基础库,这个按钮会在部分安卓机上直接没反应,排查时先看版本。

2.3 订单状态机:为什么不能只改订单字段

订单表里status字段的值我定义为:PENDING_PAY(待支付)、PAID(已支付待派单)、ASSIGNED(已派单)、WORKING(服务中)、DONE(已完成)、CANCELLED(已取消)。

只改订单字段最大的问题是:订单是哪个环节变成 DONE 的?是用户点的还是保洁师点的?中间有没有人误操作?出了纠纷连审计证据都没有。所以必须有一张order_log表,每次状态变化都追加一条日志,订单表和日志表的写入必须在同一事务里完成。

// 状态流转换表(放在代码注释里当契约用) // PENDING_PAY -> PAID (支付成功回调) // PAID -> ASSIGNED (后台派单) // ASSIGNED -> WORKING (保洁师点击开始服务) // WORKING -> DONE (保洁师点击完工) // PENDING_PAY -> CANCELLED (用户取消) // PAID -> CANCELLED (运营取消并退款)

有了这张状态机,后续所有逻辑像多米诺骨牌一样简单:只有PAID状态能派单,只有ASSIGNED能开始服务,页面按钮的显示全由状态驱动。上线后第 6 章我会讲怎么用日志表做时间轴回放来验证这套状态机。

3. 用微信云开发跑通“预约+支付”:初始化环境到云函数签名的完整链路

这里选型我直接说结论:个人或小团队做预约类小程序,优先用微信云开发,而不是自己买服务器部署后端。原因很简单:云开发自带数据库、文件存储、云函数,免鉴权获取 openid,省掉证书和域名备案这一整套麻烦。如果你以后想跨端,用 uni-app 基于云开发封装层做迁移也能走通,但第一版别分心,原生小程序加云开发是阻力最小的路。

3.1 初始化云环境和数据库集合

在app.js里做全局初始化,这一步是后续所有云能力的入口。

// app.js App({ onLaunch() { if (!wx.cloud) { console.error('基础库版本过低,请升级到 2.2.3 以上'); return; } wx.cloud.init({ env: 'cleaner-prod-xxxx', // 云环境 ID,在云开发控制台查看 traceUser: true // 将用户访问记录到云开发控制台 }); } })

env必须填实际环境 ID,不是cloud1这种默认值。很多人开发时用默认环境,上线前切了正式环境,结果云函数里写的环境还是旧的,下单时openid对不上,数据全写到了测试库。traceUser: true会在控制台里按用户维度记录访问记录,调试阶段开着,上线后可以关。

数据库集合在云开发控制台手动创建,不需要写初始化脚本。需要建六个集合:services、addresses、orders、order_log、technicians、users。所有集合的权限建议设置为“仅创建者可读写,管理员可读写”,因为用户只能动自己的数据,跨用户写入靠云函数完成——云函数端有管理权限,不受这条限制。

3.2 预约下单页面:选服务、选时间、提交订单的完整代码

下单页是核心页面,WXML 部分注意用picker处理时间段、用radio-group处理支付方式。家庭保洁场景不需要用户自己挑小区楼栋号,地址是预先维护好的。

<!-- pages/order/order.wxml --> <view class="service-card"> <picker mode="selector" range="{{services}}" range-key="name" bindchange="onServiceChange"> <view>服务类型:{{selectedService.name}}</view> </picker> <picker mode="date" bindchange="onDateChange"> <view>上门日期:{{selectedDate}}</view> </picker> <picker mode="time" bindchange="onTimeChange"> <view>上门时间:{{selectedTime}}</view> </picker> <view>订单金额:¥{{selectedService.price}}</view> </view> <button type="primary" bindtap="submitOrder" disabled="{{submitting}}"> 提交预约并支付 </button>
// pages/order/order.js Page({ data: { services: [], selectedService: {}, selectedDate: '', selectedTime: '', submitting: false }, async onLoad() { const db = wx.cloud.database(); const res = await db.collection('services').where({ enabled: true }).get(); this.setData({ services: res.data, selectedService: res.data[0] }); }, onServiceChange(e) { this.setData({ selectedService: this.data.services[e.detail.value] }); }, onDateChange(e) { this.setData({ selectedDate: e.detail.value }); }, onTimeChange(e) { this.setData({ selectedTime: e.detail.value }); }, async submitOrder() { if (!this.data.selectedDate || !this.data.selectedTime) { wx.showToast({ title: '请补充上门时间', icon: 'none' }); return; } this.setData({ submitting: true }); try { const res = await wx.cloud.callFunction({ name: 'createOrder', data: { serviceId: this.data.selectedService._id, timeRange: { start: `${this.data.selectedDate} ${this.data.selectedTime}`, end: this.addMinutes(this.data.selectedTime, this.data.selectedService.duration_min) } } }); await this.requestPayment(res.result.payParams); wx.redirectTo({ url: `/pages/order/detail?id=${res.result.orderId}` }); } catch (err) { wx.showToast({ title: '下单失败,稍后再试', icon: 'none' }); } finally { this.setData({ submitting: false }); } } });

每个时段的选择都对应状态数据,最后组装成一个timeRange区间传给云函数。addMinutes是我自己写的工具函数,把duration_min加到开始时间上,确保结束时间一定正确。页面端不直接写数据库,所有数据变更都走callFunction,这是云开发应用的关键约束:云函数端可以校验用户是否有权下单、时段是否冲突,页面端调数据库是绕过所有业务规则的后门。

3.3 云函数 createOrder:事务写订单和支付签名

云函数是整个小程序的“后端”。createOrder负责四件事:组装订单数据、校验时间段是否冲突、生成支付参数、返回给前端拉起支付。

// cloudfunctions/createOrder/index.js const cloud = require('wx-server-sdk') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db = cloud.database() exports.main = async (event) => { const { OPENID } = cloud.getWXContext() const { serviceId, timeRange } = event // 查服务价格 const serviceRes = await db.collection('services').doc(serviceId).get() const service = serviceRes.data // 检查同一时段有没有已支付/已派单的冲突订单 const conflictRes = await db.collection('orders') .where({ status: _.in(['PAID', 'ASSIGNED', 'WORKING']), 'timeRange.start': _.lt(timeRange.end), 'timeRange.end': _.gt(timeRange.start) }) .count() if (conflictRes.total > 0) { return { code: 'TIME_CONFLICT', msg: '该时段已被预约,请换一个时间' } } const orderId = 'ORD' + Date.now() + Math.random().toString(36).slice(-4) const orderData = { _id: orderId, user_openid: OPENID, service_id: serviceId, service_snapshot: { name: service.name, price: service.price, duration_min: service.duration_min }, time_range: timeRange, amount: service.price, status: 'PENDING_PAY', create_time: db.serverDate() } await db.runTransaction(async transaction => { await transaction.collection('orders').add({ data: orderData }) await transaction.collection('order_log').add({ data: { order_id: orderId, from_status: '', to_status: 'PENDING_PAY', operate: '用户提交预约', create_time: db.serverDate() } }) }) // 微信支付统一下单(v2 示例) const payParams = await unifiedOrder({ openid: OPENID, orderId, amount: service.price, body: service.name }) return { code: 'OK', orderId, payParams } }

逻辑说明:timeRange.start < timeRange.end并且timeRange.end > timeRange.start这个条件判断的是区间重叠,是我第 4 章要展开的排期核心。service_snapshot存的是快照,防止服务涨价后订单金额对不上账。runTransaction保证订单表和日志表要么同时写入成功、要么同时失败,不会出现订单在但日志缺失的情况。

支付签名这里用 v2 接口举例,参数不能少:appid、mch_id、nonce_str、body、out_trade_no、total_fee、spbill_create_ip、notify_url、trade_type=JSAPI、openid。其中total_fee是“分”,订单表里存的是“元”,所以云函数里要乘 100:

async function unifiedOrder({ openid, orderId, amount, body }) { const params = { appid: '你的小程序appid', mch_id: '你的商户号', nonce_str: Math.random().toString(36).slice(-8), body, out_trade_no: orderId, total_fee: Math.round(amount * 100), spbill_create_ip: '127.0.0.1', notify_url: 'https://your-domain.com/pay/notify', trade_type: 'JSAPI', openid } const sign = buildSign(params, '商户密钥') // 请求 https://api.mch.weixin.qq.com/pay/unifiedorder // 成功后拿到 prepay_id // 再生成供 wx.requestPayment 使用的 paySign return { timeStamp: String(Math.floor(Date.now() / 1000)), nonceStr: params.nonce_str, package: `prepay_id=${prepay_id}`, signType: 'MD5', paySign: buildPaySign({ ... }) } }

注意:v3 支付的签名方式与 v2 不同,v3 用商户私钥做 RSA 签名、接口路径是/v3/pay/transactions/jsapi。不建议新项目再学 v2,这里用 v2 举例只是因为它最容易讲清楚参数结构;如果你接入 v3,把unifiedOrder内部换成 v3 的参数组即可,云函数外围代码不变。

4. 保洁师排期与派单:时段防冲突、订阅消息和动态标题的三个落地细节

订单生成只是第一步,真正让系统跑起来的是排期和派单。这里三个细节决定系统能不能“用得住”:时段防冲突、消息触达、页面状态反馈。

4.1 时段防冲突:为什么不能只比较字符串

两个订单都选“2025-06-01 10:00”,看起来很整齐,但实际业务里保洁师可能住得很远、上一单延时,订单会有一个“服务中”的浮动区间。最朴素的实现是把时间存成字符串然后where相等匹配——这在新手代码里非常常见,也是第一处翻车点。

// 区间重叠判断:两个时间段只要有交集就冲突 function isOverlap(startA, endA, startB, endB) { return startA < endB && startB < endA } // 示例:10:00-12:00 和 11:00-13:00 // isOverlap('10:00','12:00','11:00','13:00') => true

这个判断看起来简单,实际容易错在接受边界值。比如 A 单 10:00-12:00,B 单 12:00-14:00,这不算重叠,上一单的结束时间正好等于下一单的开始时间,保洁师可以接。但页面上的时间选择器如果粒度是小时,12:00 这个边界就会被两个订单同时选中,需要在存数据时统一规定“开始时间包含、结束时间不包含”,即end是开区间。习惯上我把这个规则写进云函数的常量区,防止不同开发者理解不一致。

数据库查询时用_.gt和_.lt组合匹配,而不是where一个等值条件。另外在后台“派单”操作里也要校验一次冲突:用户下单时校验一次,管理员派单时再校验一次,因为派单的时间点可能离下单时间已经过了一整天,这段时间其他订单可能已经抢占了同一时段。两次校验不互相替代,都是必做的。

4.2 订阅消息:一次性授权怎么设计成“预约提醒”和“完工通知”

微信订阅消息是预约类小程序触达用户的主要通道,但它有个让很多人意外限制:wx.requestSubscribeMessage授权一次,只能推一条消息。也就是说,用户下单前授权提示“预约成功通知”,那么这条消息消耗掉以后,下次再想推“师傅已出发”就需要重新授权。

// 用户提交订单前,先请求订阅授权 async requestMsgPermission() { let tmplIds = [] // 预约成功通知 if (this.data.orderStep === 'submit') { tmplIds = ['预约成功通知模板ID'] } // 派单提醒(在下单按钮点击瞬间再请求一次) if (this.data.orderStep === 'pay') { tmplIds = ['派单提醒模板ID'] } const res = await wx.requestSubscribeMessage({ tmplIds }) if (res['预约成功通知模板ID'] === 'accept') { this.setData({ msgAuthorized: true }) } }

这里的设计教训是:不要在页面加载时一次性请求三个模板,用户大概率拒绝;要在用户真正执行某个动作前请求对应的那一个,把授权动作和用户需求绑在一起。比如“提交预约前请求预约成功通知”“支付成功后选提醒时间再请求派单提醒”,这样授权成功率会明显好看。

如果用户拒绝授权,消息触达就断了。我做了兜底:小程序的“服务通知”里如果历史有订单,会以“待办事件”的形式出现在微信的“服务通知”列表里,但这个不稳定。真实兜底方案是短信——在云函数里调用短信 API 给用户手机号发提醒,缺点是每一分钱都是成本,所以我只对前一天晚上还没付款的订单发催付短信,真到了上门当天靠用户自己看订单页。

4.3 动态标题和自定义导航栏:别让状态显示挤在页面里

订单详情页是用户自查预约进度的核心地方。很多人把状态放在页面上的一个角落,用户找不到;最简单的做法是把导航栏标题直接变成状态:待支付、待派单、服务中、已完成,一目了然。

// pages/order/detail.js const statusTitle = { PENDING_PAY: '订单待支付', PAID: '等待派单', ASSIGNED: '保洁师已接单', WORKING: '服务进行中', DONE: '订单已完成', CANCELLED: '订单已取消' } wx.setNavigationBarTitle({ title: statusTitle[this.data.order.status] })

还有一个容易被忽略的问题:自定义导航栏。很多人为了样式好看把navigationStyle设为custom,然后自己画标题栏。此时页面顶部内容的定位高度不是固定的,它等于状态栏高度加胶囊按钮高度。不同机型差异很大,iPhone 和安卓的状态栏高度不一样,一不留神标题就顶到胶囊按钮上了。保险的写法是拿到胶囊按钮的实际尺寸后再布局:

const { top, height } = wx.getMenuButtonBoundingClientRect() // 导航栏高度 = top + height + 6(上下留白)

不用自定义导航栏就没这个事,但用了就要算。小程序头部标题一旦被顶上去,在真机上直接显示错位,开发工具里却看不出问题,这属于典型的真机踩坑。

4.4 派单与“保洁师端”:状态流转的权限边界

订单从PAID到ASSIGNED这个动作要做权限控制。如果只是一个小程序面向用户,后台派单需要在管理员侧做。我见过的最小可行做法:做一个只有管理员能进的“派单页”,用 openid 白名单判断身份,管理员在订单列表里把PAID状态的订单指派给保洁师,云函数里校验当前操作者确实是管理员,然后才允许更新tech_userid和status。

保洁师端如果做成独立小程序要额外注册账号体系,成本一下子上去。更常见做法是第一版做“轻管家”:派单后不开放保洁师登录,由公司客服在内部订单列表中人工流转状态,消息提醒靠客服手动点击“发送服务中通知”。等订单量一天超过 50 单再上保洁师端,这是小团队最务实的节奏。

5. 避坑清单:保洁小程序上线前最容易翻车的五个点(现象→原因→解决)

5.1 现象:两个订单撞在同一个保洁师的同一个时段

原因:下单时只在页面端校验了时间是否冲突,没有在云函数里做二次校验;或者数据库并发高的时候两个请求同时读到“无冲突”,同时写入。解决:把时段冲突校验放进云函数,配合db.runTransaction做事务写入,并且为orders集合把status、time_range.start、time_range.end建复合索引。事务能保证并发请求下只有一个成功,这是数据库层面的最后一环。

5.2 现象:定时触发的提醒从来没发出去

原因:前端setTimeout只在小程序存活期间有效,用户退出小程序、手机息屏,定时器全部挂掉。解决:提醒这类任务不要依赖前端,改用云开发“定时触发器”——在cloudfunctions/config.json里配置定时规则,让云函数每天 08:00 触发一次,扫描当天的PAID订单并推送订阅消息。这类定时任务在云端跑,跟用户手机在不在线无关。

5.3 现象:新版手机号授权按钮在部分手机上点击没反应

原因:基础库版本低于 2.21.2,新版code动态令牌不生效;或小程序后台没有声明“获取手机号”隐私接口。解决:app.json声明基础库版本,在微信公众平台“设置—服务内容声明—用户隐私保护指引”中勾选手机号相关接口。开发工具里用“真机调试”反复测试授权链路,不要只在开发者工具里点。

5.4 现象:支付金额和订单金额差一分钱

原因:微信支付金额单位是“分”,订单金额单位是“元”,云函数里没有做转换或者用了parseInt把小数直接截断。解决:统一在云函数入口做Math.round(amount * 100),前端展示永远用“元”,数据库存储也存“元”,只在生成支付参数那一刻转“分”。这个转换逻辑单独放在一个工具函数里写单元测试,防止改价格时顺手改坏。

5.5 现象:体验版能打开但数据全是测试库的

原因:云函数里的环境 ID 写死在开发环境,cloud.init的env参数没有切到正式环境;或者小程序 appid 和云环境没有绑定。解决:上线前全局搜索env:字符串,把所有出现的环境 ID 统一替换为正式环境 ID。如果开发测试切换很频繁,就把环境 ID 放进config.js单独维护,发版前只改这一个文件。真机调试时用小程序抓包工具(如 Charles)确认云函数请求到达的是哪个域名,这条排查路径能省一到两小时。

6. 上线后先做一遍时间轴回放:验证订单状态机与支付时序

上线后第一件事别急着铺推广,先用订单日志把状态机完整走一遍。打开云开发控制台,找一个测试订单,在order_log集合里按时间排一下:

SELECT order_id, to_status, operate, create_time FROM order_log WHERE order_id = 'ORD20250601xxxx' ORDER BY create_time ASC

能看到这个样子,说明状态机是健康的:

时间事件结果
10:00:00用户提交预约PENDING_PAY
10:00:05用户完成支付PAID
10:15:00客服派单ASSIGNED
13:50:00保洁师开始服务WORKING
16:05:00保洁师完成服务DONE

如果日志里出现PAID之后直接跳到DONE、中间没有ASSIGNED,说明派单逻辑被绕过了,立刻查一下页面是不是有直接操作订单状态的代码。这类验证脚本可以做成一个 node 工具,定时巡检所有状态异常订单,因为人工盯日志很容易漏。

我自己的习惯是上线当天创建一个测试账号,走完整路径:注册—选服务—支付—派单—开始服务—完结,中间用抓包工具看一眼支付回调有没有正常回到云函数。这套流程走完,再把测试订单的日志清掉。之后每次改状态机代码,都先跑一遍回放脚本再发版。

排期、支付、状态机这三件事做扎实以后,这个上门预约保洁的方案就已经能扛住一天上百单的真实业务了。剩下的是运营层面的问题:保洁师的完工照片上传、用户评价、复购优惠券,这些都可以在后续版本里平滑叠加。希望帮到你,少踩一个坑,就多省一个晚上。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进