前段时间帮某小型家政平台做了一款微信小程序端的预约系统,从需求梳理到上线前后折腾了一个多月。家政服务预约这事听起来跟外卖点单差不多,用户选服务、选时间、下单付款,但实际上做起来完全不是一回事——家政服务绑定的不止是"时间",还有一个具体的服务人员,以及上门地址、服务时长、改期取消规则这些外卖场景里完全不需要考虑的变量。这篇文章就把整个系统的设计思路、核心代码逻辑和实测踩坑过程完整过一遍,给准备做同类小程序的朋友一个可以直接参考的落地范本。
这套系统最终服务三个角色:用户端小程序、阿姨端小程序(后来合并进了管理后台)、运营管理后台。用户端负责浏览服务、预约下单、支付和订单跟踪;阿姨端负责查看排班、接单、确认服务完成;后台负责服务项目管理、阿姨审核、订单调度和数据统计。下面从业务模型开始讲,然后是技术选型、数据库设计、核心预约逻辑、前端实现重点,最后是运维上线后的几类坑。
1. 家政预约和小程序商城是两码事:先把业务模型拆清楚
1.1 为什么不能照搬"选品-加购-支付"的电商模式
最开始产品团队给的页面结构参考的是电商小程序,首页放服务列表,点进去就是购买页,然后支付完事。但这个模式放到家政场景马上遇到问题:家政服务不是标准品,同一个"日常保洁"项目,2小时和4小时的服务价格不同,不同等级的阿姨价格也不同;而且用户下单时不仅选服务,还要选具体某个阿姨的某一段空闲时间。这就变成了一种"带库存的预约系统",每个阿姨的时间段就是库存,库存还按小时、甚至按半小时切分。
另一个和电商差异巨大的点是改期和取消的权重非常高。用户买了件衣服不合适可以退,但家政服务约好了,阿姨已经排进当天档期,临时取消对阿姨收入影响很大。所以订单状态不能只有"待支付-已支付-已完成"三层,至少要有"待支付-已预约-待服务-服务中-已完成-已取消"这些状态,而且取消申请、改期申请还需要运营介入审核。
1.2 三方角色的核心诉求
做预约系统前,我先把三方的真实诉求列了一张表,后面所有功能设计都围着这张表转:
| 角色 | 核心诉求 | 系统需要解决的问题 |
|---|---|---|
| 用户 | 快速找到可约时间、预约不被放鸽子、取消方便 | 实时展示阿姨空闲档期、预约确认机制、明确的取消规则 |
| 阿姨 | 档期不被恶意占用、减少空跑、收入清晰 | 派单前确认、服务完成确认、订单与结算挂钩 |
| 平台运营 | 撮合效率高、阿姨工作量均衡、售后有据可查 | 统一的订单状态流转、派单规则、操作日志 |
这个拆解最大的作用是我意识到,预约系统的核心不是"下单"这个动作,而是"档期管理"。所有功能都围绕"某个阿姨在某天某时段是否可约"来展开。
1.3 最小可行版本的功能范围
第一期上线我没有做拼团、优惠券、积分这类营销功能,聚焦在以下清单:
- 服务项目管理(服务项、时长、价格、描述、封面图)
- 阿姨管理(身份信息、服务项目、技能标签、相册)
- 排班与档期管理(阿姨设置可服务时间段,系统切成槽位)
- 用户预约(选服务、选阿姨、选时间、填地址、支付)
- 订单流转(待支付、已预约、待服务、服务中、已完成、已取消)
- 取消与改期(用户申请、运营审核、阿姨端同步)
- 消息通知(预约成功、服务提醒、取消通知)
营销类功能完全可以后面迭代加,但预约主流程的稳定性、状态闭环必须一版做对,否则后面加什么功能都会觉得地基不稳。
2. 技术选型复盘:原生小程序加云开发,还是自建后端?
2.1 小程序端:原生还是跨端框架
关于小程序端用原生还是uni-app或者Taro,我是这样权衡的:项目只做微信小程序,没有同时发支付宝、抖音小程序的刚需,而且团队里熟悉Vue的人也不多,那直接用原生小程序的成本最低。原生小程序开发时遇到问题,官方文档和社区案例最多,排查效率高。跨端框架的优势是"一次写多端跑",但代价是中间多了一层编译和运行时抽象,遇到平台差异时反而要多花时间处理兼容。
当然原生的缺点也明显,比如语法相对老旧、组件化体验不如现代框架。我的处理思路是,用TypeScript加官方自定义组件机制来写,逻辑层和视图层分离清晰,后期维护压力小很多。如果你团队主力是前端Vue技术栈,而且未来确定要多端发布,那uni-app是更合理的选择。技术选型没有绝对好坏,关键看团队现状和产品方向。
2.2 服务端:用云开发还是自建服务器
这个小程序最初我选了微信云开发的方案,因为可以免运维、自带数据库和云函数,用户身份鉴权也直接使用微信的免登录能力,开发速度非常快。但做到第二周我发现了两个问题:
- 云开发数据库的查询能力对事务支持偏弱,而预约场景里"扣减档期"和"生成订单"必须保证原子性,否则会出现超卖;
- 后续可能要接自有管理系统、数据报表、阿姨App,服务端逻辑会越来越重,全部堆在云函数里面,代码结构会很难看。
所以最终改为自建Node.js服务端加MySQL,小程序端只负责展示和交互,所有预约核心逻辑都放在服务端统一处理。服务端使用Node.js加Express框架,数据库用MySQL,缓存用Redis存阿姨档期和分布式锁。小程序端通过HTTP接口与服务端通信,登录状态用微信的code换取openid,再签发自有的token。
2.3 整体目录结构
项目分成了三个部分,对应三个代码仓库:
mini-program/ 小程序端(原生 + TypeScript) ├─ pages/ 页面 ├─ components/ 自定义组件 ├─ services/ API请求封装 └─ utils/ 公共工具 server/ Node.js 服务端 ├─ routes/ 路由 ├─ controllers/ 业务逻辑 ├─ models/ 数据模型 ├─ services/ 核心服务(排班、订单状态机) └─ jobs/ 定时任务 admin/ 运营管理后台(Vue + Element Plus)这里我建议一开始就把目录拆好,不要等代码写多了再重构。当时为了让小程序能快速跑起来,我把很多业务逻辑写在了小程序端,后来自建服务端时几乎重写了一遍业务层,白白浪费了时间。
3. 数据模型与订单状态机:预约系统的“骨架”怎么搭
3.1 订单表:状态字段远比想象中复杂
订单表是整套系统的核心,字段设计直接决定后面所有逻辑好不好写。我最终的订单表结构大致如下:
CREATE TABLE `order_info` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '业务订单号', `user_id` bigint NOT NULL COMMENT '用户ID', `staff_id` bigint NOT NULL COMMENT '家政阿姨ID', `service_id` bigint NOT NULL COMMENT '服务项目ID', `service_date` date NOT NULL COMMENT '服务日期', `service_time_start` varchar(5) NOT NULL COMMENT '开始时间 HH:mm', `service_time_end` varchar(5) NOT NULL COMMENT '结束时间 HH:mm', `address` varchar(255) NOT NULL COMMENT '服务地址', `contact_name` varchar(50) NOT NULL COMMENT '联系人', `contact_phone` varchar(20) NOT NULL COMMENT '联系电话', `amount` decimal(10,2) NOT NULL COMMENT '订单金额', `status` tinyint NOT NULL COMMENT '订单状态 见状态枚举', `cancel_type` tinyint DEFAULT NULL COMMENT '取消类型 1用户取消 2平台取消 3阿姨取消', `cancel_reason` varchar(255) DEFAULT NULL COMMENT '取消原因', `canceled_by` bigint DEFAULT NULL COMMENT '取消操作人ID', `created_at` datetime NOT NULL, `updated_at` datetime NOT NULL, KEY `idx_user_id` (`user_id`), KEY `idx_staff_date` (`staff_id`, `service_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里特别说明几个容易被忽略的点:
- 服务时间用字符串存,比如"09:00",而不是用datetime。因为预约按当天日期加时间段组合来定位,日期和时间分开存,查询某阿姨某天空闲时段时SQL更好写,也不用处理时区换算的麻烦。
- 冗余了联系人和地址。这些信息确实可以从用户表里查,但订单是一种历史快照,用户以后可能改手机号、搬家,订单里的联系信息必须保持下单那一刻的样子,方便售后回溯。
- 所有关键操作都记录了操作人ID。出现过用户和客服打电话说"我没取消过订单",一查日志发现是客服误操作的情况,有操作记录省了很多扯皮。
3.2 阿姨档期表与日排班
阿姨不会7乘24小时都在工作,每个阿姨有自己可约的时间,比如周一到周五9点到18点。但如果只存这种周维度规则,遇到阿姨请假、临时有事就没法处理,所以我做了一张具体的排班表:
CREATE TABLE `staff_schedule` ( `id` bigint NOT NULL AUTO_INCREMENT, `staff_id` bigint NOT NULL, `work_date` date NOT NULL COMMENT '排班日期', `time_start` varchar(5) NOT NULL COMMENT '可约开始时间', `time_end` varchar(5) NOT NULL COMMENT '可约结束时间', `slot_minutes` int NOT NULL DEFAULT 120 COMMENT '服务槽位时长(分钟)', `status` tinyint NOT NULL DEFAULT 1 COMMENT '1正常 0停用', UNIQUE KEY `uk_staff_date` (`staff_id`, `work_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;预约时,系统根据这条排班记录把time_start到time_end切成若干个槽位。比如9点到17点、每个服务2小时,就有4个完整槽位(9到11、11到13、13到15、15到17)。注意这里不是简单地按开始时间算,还要考虑用户选的服务时长——比如用户选了3小时的服务,那9点这个槽位实际占用到12点,下一个可用槽位就从12点开始,而不是11点。这块计算逻辑在服务端做,前端只负责展示可用的开始时间。
这样做的好处是,哪天阿姨请假,运营直接改那天的排班状态或删掉排班即可,完全不影响其他日期的预约逻辑。
3.3 状态机:没有它,订单流转一定乱
我梳理的订单状态流转如下:
- 待支付(PAYING):用户提交预约后未支付,系统保留档期15分钟,15分钟未支付自动释放档期、订单关闭。
- 已预约(BOOKED):支付成功,档期被真正锁定。
- 待服务(READY):服务当天0点,订单进入待服务状态,用户和阿姨都会收到提醒。
- 服务中(SERVING):阿姨点击开始服务。也可以用定时任务在服务开始时间自动置为服务中,但人工触点更准确。
- 已完成(DONE):阿姨点击完成,或者用户确认完成。此时订单金额计入阿姨月结算。
- 已取消(CANCELED):用户、阿姨或平台发起取消,根据取消时间决定是否扣手续费或全额退款。
这些状态我用枚举存在代码里,数据库只存tinyint,避免散落在业务逻辑里的魔法数字。状态机的作用是每个状态只允许特定的流转动作,比如已取消不能再变为服务中,待支付不能直接变为已完成,这样即使前端传了非法参数,服务端也能拦住。
4. 服务端预约核心逻辑:档期冲突检测与并发控制
4.1 最简单的冲突检测方案——重叠区间判断
预约的行为可以抽象成:在阿姨某天的可服务时间段内,插入一个时长为N的服务,且不能与已有订单重叠。服务端在插入前要查一次:
async function checkConflict(staffId, serviceDate, startTime, endTime) { const conflictOrders = await OrderModel.find({ where: { staff_id: staffId, service_date: serviceDate, status: ['PAYING', 'BOOKED', 'READY', 'SERVING'], // 新时间段的开始 < 已存在订单的结束 // 且 新时间段的结束 > 已存在订单的开始 // 即两个区间存在重叠 } }); // 用SQL表达: // WHERE staff_id=? AND service_date=? // AND status IN (...) // AND (time_start < ? AND time_end > ?) // 这里传入 startTime 和 endTime }这段逻辑看起来简单,但我要特别提一个很容易写错的地方:重叠判断的条件不是"新开始时间在已有时间段内",而是"新开始的 < 已有结束 且 新结束 > 已有开始"。等价的两个区间只要有任何一秒交叉就会被捕捉到。只判断单边边界条件会漏掉"新订单完全包含已有订单"和"已有订单完全包含新订单"这两种情况。
这里用了一个小技巧:预约的最小时间单位是30分钟,所有时间字符串都是"09:00"这种格式,直接按字符串比较大小即可,不需要转成Date再比较,免掉了时区问题。
4.2 并发下的超卖问题:事务加锁
但光查一次再插入还不够。如果两个用户在同一毫秒提交同一个阿姨同一个时间段的预约,两个请求都通过了冲突检查,最后两个订单都插入成功,这就是典型的超卖。
我的解决办法是两步:
- 使用MySQL事务,在冲突检测前先对
staff_schedule表的对应行加FOR UPDATE锁; - 检测和插入必须在同一个事务里完成。
const transaction = await sequelize.transaction(); try { // 锁定排班行,防止其他事务同时修改或插入同一阿姨同一时段的订单 await StaffScheduleModel.findOne({ where: { staff_id: staffId, work_date: serviceDate }, lock: transaction.LOCK.UPDATE, transaction }); const conflict = await checkConflict(...); if (conflict) { throw new Error('该时间段已被预约'); } await OrderModel.create({...}, { transaction }); await transaction.commit(); } catch (e) { await transaction.rollback(); throw e; }Redis分布式锁在当时我也评估过,但考虑到这个预约系统的并发量不会特别高(一天几千单已经是很大规模了),数据库行锁已经足够,而且没有Redis带来的额外运维成本和一致性边界问题。在并发量可控的情况下,优先用数据库锁,简单且可靠。
4.3 取消与改期:不是改一条记录那么简单
用户取消订单时,我最开始只是把订单状态改成"已取消"。后来发现阿姨端会出现一个"莫名其妙被取消"的订单,阿姨无法区分是自己取消还是用户取消。所以后来取消了"取消原因"和"操作人"两个字段,并且向被影响的一方推送模板消息通知。
改期则完全不支持直接修改原订单——我采取的方案是**"先取消、再重新预约"**。用户申请改期后,原订单标记为"改期等待中",原档期暂时不释放;用户重新下单成功后,原订单自动取消并释放档期。这样可以防止"用户以为改期成功了但新时间没约上,又回头发现原时间已经被别人占了"的尴尬。
5. 小程序端的关键页面实现与前后端配合
5.1 首页服务列表:服务与阿姨的两层筛选
首页没有做成电商那种标准分类页,而是分了两层:第一层选服务项目(日常保洁、深度保洁、擦玻璃、家电清洗等),第二层选阿姨(按评分、距离、接单量排序)。选完阿姨才进入"选时间"环节。这个交互顺序是运营用真实用户访谈验证过的:用户最关心的是"谁来做"而不是"做什么",所以阿姨的层级要浅,最好在二级页面就能看到阿姨照片、评分和服务次数。
服务列表的接口同时返回服务信息和可约阿姨数量,前端需要展示"距离最近""评分最高"这类标签。这里给一个实际开发中的建议:[小程序端不要在onLoad里串行请求多个接口],要并行请求,并用Promise.all合并结果,否则首页加载会明显变慢,用户很容易流失。
5.2 预约时间选择器:把服务时长和槽位联动的逻辑放在前端
时间选择器是预约流程里最容易出体验问题的地方,我前后改了三个版本。第一版是用户先选服务时长(2小时、4小时),再选日期,然后显示阿姨当天所有可用开始时间。问题是,选完日期后返回的时间列表没有考虑服务时长,导致用户选了4小时服务,但列表里给出了"16:00"这种到18点才结束、实际上超过阿姨可约时间范围的不合法选项。
最后处理方式是:服务端返回阿姨当天的原始可约时间窗口(比如9:00到17:00),前端按用户选择的服务时长,自动计算所有合法的开始时间并过滤掉不满足的选项。
// 前端计算可选开始时间 function getAvailableStartTimes(scheduleStart, scheduleEnd, durationMinutes, bookedSlots) { const allSlots = []; let current = scheduleStart; while (current <= scheduleEnd && addMinutes(current, durationMinutes) <= scheduleEnd) { // 跳过与已预约时间重叠的槽位 if (!bookedSlots.some(slot => isOverlap(current, addMinutes(current, durationMinutes), slot.start, slot.end) )) { allSlots.push(current); } current = addMinutes(current, 30); } return allSlots; }这样做一方面减轻服务端压力,另一方面时间选择器响应更快,用户体验更流畅。
5.3 支付与订单状态同步
微信支付接入用的是小程序支付的标准流程:小程序端请求服务端创建支付单,服务端调用微信支付统一下单接口拿到payParams返回给前端,前端调用wx.requestPayment。支付成功后,微信服务器会异步通知服务端支付结果,服务端在收到通知后把订单从"待支付"改为"已预约"。
这里有个很多新手会踩的坑:不能只依赖前端wx.requestPayment成功回调去改订单状态,因为小程序端存在用户支付成功但网络波动导致回调丢失的情况。必须以后台微信支付回调为准,前端只做UI上的提示。我当时的做法是,支付回调更新订单状态后,通过WebSocket或定时轮询通知小程序刷新订单详情页状态,保证"用户付了钱但界面还显示待支付"的情况不会持续太久。
6. 上线之后踩过的坑:时区、提醒通知与数据统计
6.1 时间字符串比较的陷阱
前面提到我用字符串存"09:00"这类时间,在绝大多数场景下确实比Date对象省心,但有一个边界条件必须注意:MySQL和Node.js在比较字符串的时候,'09:00'和'9:00'是不相等的,而且排序结果会和你预期不一致。所以所有时间写入前必须格式化补零,统一成HH:mm格式。我在这个上面出过一次线上事故:某阿姨排班时间后台填的是"9:00",用户端展示正常,但服务端判断重叠时漏掉了当天的所有订单,导致两个订单重叠,最后只能人工协调退款。
6.2 提醒通知的触达问题
预约系统上线后,运营反馈"用户老放鸽子,约了不来"。一开始我用小程序订阅消息推预约提醒,但效果很差——微信订阅消息是一次性订阅,用户如果拒绝授权就永远收不到。后来我改成了两段式触达策略:
- 支付完成后,立刻引导用户订阅"服务开始前"的一次性消息;
- 服务前一天晚上8点,给所有已预约用户发送一条模板消息,提醒明天的服务时间和阿姨姓名。
第二个方案触达率明显提高。但我也发现模板消息长期依赖微信的审核和接口规则,后来逐步把触达重心迁移到企业微信客服消息上——通过微信客服接口给用户发提醒,不受一次性订阅限制,对平台来说可控性高很多。
6.3 每日结算与数据统计的坑
订单量上来以后,运营每天要看阿姨的服务单量和收入,我一开始是在后台直接SELECT COUNT(*) FROM order_info WHERE staff_id=? AND status=5 AND service_date=?。这个SQL在大数据量下非常慢,因为staff_id和service_date的联合索引当时没建好,而且订单表里还有大量历史数据。
后来我加了每日凌晨的定时任务,把前一天所有订单按阿姨维度汇总成一张staff_daily_stats表,运营看报表直接查汇总表,秒开。同时,这个汇总表也是月底和阿姨结算工资的数据来源,避免了直接统计订单表时因中间状态变化导致结算金额不一致的问题。
7. 写在最后
这个家政预约小程序从启动到稳定运行,让我最深的体会是:预约类系统的复杂度不在前端交互,而在于业务规则的一致性和状态的可回溯性。初期为了快速上线,我把很多状态逻辑放在前端判断,后期在微信小程序审核、真实用户并发、阿姨端数据同步这几个环节都吃了不少亏。如果你也想做这类系统,建议从第一天就把服务端状态机和档期锁机制确定下来,前端可以糙一点,但核心交易链路绝不能含糊。
小程序端页面如果遇到"用户选好了时间但提交时提示该时段被约走"的情况,不要感到意外,这是预约系统固有的冲突概率。它恰恰说明后台的冲突检测在起作用,而不是bug。在这里可以给用户提示"换个时间段试试",而不是直接报错,体验会好很多。
最后再分享一个实用小技巧:上线初期建议在后台加一个"订单操作日志"页,把用户、客服、管理员的所有关键操作(创建、支付、取消、改期、派单、完成)都记录下来。表面上看会增加一些开发量,但等到有人投诉、产生纠纷需要追溯原因的时候,你会感谢当时的自己。这套系统的完整代码结构并不复杂,核心业务逻辑两三千行就能覆盖,真正的难点都在这些看不见的细节里。