news 2026/9/12 10:40:05

微信小程序预约系统源码解析:排期建模与并发控制实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
微信小程序预约系统源码解析:排期建模与并发控制实战

简介:基于微信小程序的PowerPet宠物门店预约服务源码,专门为宠物主与门店管理者设计,覆盖美容、洗澡、理发、寄养等常见服务场景,用户可浏览服务详情、选择项目与时段,并完成预约的查看、确认、修改与取消,门店端也能进行服务管理,整体暖色调界面贴合宠物服务温馨氛围。压缩包共477个文件、约3.6MB,主体为186个JavaScript逻辑文件、105个WXSS样式文件、82个WXML页面结构文件以及70个JSON配置文件,另含PNG图片、GIF动图、安装使用手册和辅助脚本,类型覆盖小程序前端页面、交互逻辑与配置规范,便于对照学习整体项目搭建。目前已有103人学习下载。凭借这套源码,开发者可梳理微信小程序从页面展示、表单提交到本地数据处理的完整链路,理解模块化方法封装、预约状态管理等细节;同时其清晰的目录结构与暖色UI组件也适合作为二次开发底座,也适合宠物服务创业者用于产品原型搭建或高校课程设计参考。

1. 微信小程序里的 PowerPet 预约服务,难的不是页面而是排期

宠物门店的预约,十个项目里有八个把“提交表单”当成核心功能,结果上线第一周就出事故:周五晚上 7 点被拆成 8 个时段,但同一个美容师被排进了两个预约;用户在门店等了一个半小时,转头去美团给差评。PowerPet 这类宠物门店预约服务的源码设计,真正的复杂度不在地图选店、不在商品陈列,而在排期:同一个技师同一时刻只能服务一只宠物,洗护和美容的时长不一样,寄养又是按天算。把这个业务规则落到数据库和接口层,页面反而是最后一步。这篇文章按“领域建模、小程序端、并发控制、提效手段”的顺序,把一套可运行的预约源码从头讲清楚,拿到源码的人能对照代码改门店参数,自己从零写的人也能直接照抄表结构和事务逻辑。适合要做宠物店预约、洗车预约、美甲预约这类“一对一时段服务”的开发者。

2. 设计源码的第一步:把“门店-服务-排期-预约”四层模型立住

写预约系统之前,最容易犯的错是直接建一张“预约表”:user_id、time、status,顶多加个备注。这套表跑通 demo 没问题,一旦同一个时段多个项目共享技师,或者同一个技师在不同门店兼职,改表成本立刻失控。我一般用四层模型来设计源码的第一个版本。

2.1 先拆业务:PowerPet 预约场景里的对象和关系

PowerPet 的预约主体是“服务项目”而不是“门店”,这是和餐饮排号最大的区别。一次洗护预约占用的资源是“美容师 45 分钟”,一次寄养预约占用的资源是“笼位 N 天”,资源类型完全不同。

最小角色集合是:门店(shop)、服务项目(service_item)、排期槽位(slot)、预约单(appointment)。关系是:一个门店有多个服务项目,一个项目每天有多个可预约的开始时间(slot),一个 slot 被预约后记录到 appointment。服务人员(美容师)要不要单独成表,看源码的定位。小店模型里,美容师不参与选人,slot 只挂项目不挂人,一个项目一天最多可约数量写在 capacity 上,这是最常见的简化。

如果要做“指定美容师”的高级版,在 slot 上增加 employee_id 即可,不需要重构表。宠物档案(pet_profile)属于用户侧附属表,和预约主链路解耦,先不占核心模型。

2.2 固定时段还是滚动时段:宠物门店选固定时段更省心

时段建模有两种路径。固定时段是从开门时间开始,按“服务时长 + 缓冲时间”切出整数个开始时间,例如 10:00、10:50、11:40,每个 slot 是独立的可预约资源。滚动时段则让用户自己选任意时间点(比如 10:17),后端把“10:17 到 11:02”作为一个预约窗口。

维度固定时段滚动时段
冲突判定slot 级判断,天然无重叠需要检查时间窗口与所有已约订单的重叠
库存表达capacity 字段即可每单都改变相邻时段容量,回收逻辑复杂
用户体验只能在给定时间点选更自由,但可能选到奇怪时间
实现成本低,适合源码交付高,适合约车/会议室类产品

宠物洗护、美容的服务时长一般有明确下限(30 到 90 分钟),用户对“10 点整或 11 点整”没有抗拒,固定时段是预约源码里的默认方案。滚动时段在面试或文档里可以作为扩展点出现,不要默认实现。

2.3 用 MySQL / 云数据库建表:预约排他性由表结构兜底

无论后端用 Spring Boot、MyBatis 还是微信云开发,表结构是通用的。以 MySQL 为例,四张核心表的简版 DDL 如下。

CREATE TABLE shop ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, address VARCHAR(255), business_start TIME NOT NULL COMMENT '营业开始时间', business_end TIME NOT NULL COMMENT '营业结束时间' ); CREATE TABLE service_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, shop_id BIGINT NOT NULL, name VARCHAR(64) NOT NULL COMMENT '服务名称:洗护/美容/寄养', duration_minutes INT NOT NULL COMMENT '服务时长,分钟', buffer_minutes INT DEFAULT 10 COMMENT '两项服务之间的缓冲', capacity INT DEFAULT 1 COMMENT '同一slot可服务宠物数,一般洗护=1', price_cents INT NOT NULL COMMENT '价格,单位分' ) COMMENT '服务项目'; CREATE TABLE slot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, shop_id BIGINT NOT NULL, service_item_id BIGINT NOT NULL, date DATE NOT NULL, start_time TIME NOT NULL, end_time TIME NOT NULL COMMENT 'start_time + duration', capacity INT NOT NULL DEFAULT 1, reserved_count INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1 COMMENT '1可约 0已停用', UNIQUE KEY uk_shop_service_time (shop_id, service_item_id, date, start_time) ) COMMENT '排期槽位'; CREATE TABLE appointment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, shop_id BIGINT NOT NULL, service_item_id BIGINT NOT NULL, slot_id BIGINT NOT NULL, user_openid VARCHAR(64) NOT NULL, pet_name VARCHAR(32) NOT NULL, contact_phone VARCHAR(20) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待确认 1已确认 2已完成 3已取消', cancel_reason VARCHAR(255), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_slot (slot_id), KEY idx_user (user_openid) );

slot表用唯一键(shop_id, service_item_id, date, start_time)保证同一个项目同一个开始时间只能有一条排期记录,这一点在后续生成排期时反复用到,重复生成直接报错而不是写进两行。appointmentslot_id指向具体的可约时段,查询某天的预约情况只需要 join 这一张表。

reserved_count是核心字段:它代表这个时段已被占用的数量。发现“有 5 个名额还剩多少”只查这个字段即可,不需要每次 count 预约表。代价是每次预约成功、取消都要同步更新它,这个同步动作就是第 4 章的并发重点。

2.4 为什么预约记录不做物理删除:cancel 状态保留排期痕迹

取消预约时,常见误操作是直接DELETE FROM appointment WHERE id = ?。订单删掉后,门店想统计“今天有多少人取消”、用户想翻历史记录,全都无从查起。状态机设计里保留 status 字段,取消只是把 3 写进状态,同时把 slot 的 reserved_count 减一。源码里所有列表查询必须带 status 条件,否则会把已取消的订单当成有效占用。

状态流转的起点是待确认还是已确认,取决于门店经营策略。小店扫码预约一般直接确认,不搞人工审核;连锁店需要店员先确认再排技师,这时待确认状态就不能省。后面第 4 章会给出完整状态机。

3. 微信小程序端实现:从登录、选时段到提交预约

后端模型立住之后,小程序端要解决的是“把 slot 变成用户可操作的预约流程”。这套流程拆成选店、选项目、选日期、选时段、填宠物信息、提交六个步骤,核心是时段列表的实时可用性和提交按钮的防连点。

3.1 用原生还是 uniapp:看源码的后续维护场景

标题写的是微信小程序,源码实现却不一定只能跑微信端。uniapp 编译到微信小程序是最常见的做法,Vue 语法写一遍,同一个工程还能编译到 H5 做门店后台预览,调试成本低。原生微信小程序没有跨端能力,但胜在没有任何框架层取舍,新手读起来更直接。

对比项原生微信小程序uniapp 编译到微信小程序
语法WXML/WXSS/JSVue SFC
跨端仅微信微信 / 支付宝 / H5 / App
调试微信开发者工具HBuilderX + 微信开发者工具
组件生态miniprogram npmuni_modules
学习成本需理解小程序专有 API会 Vue 即可

给源码做二次开发时,如果不需要多端发布,原生更轻;如果门店还要一个 H5 分享页,uniapp 少维护一套代码。下面示例用 uniapp 语法写,改动到原生小程序的成本主要在 template 和事件绑定上。加载页面的启动逻辑放在App.vueonLaunch里做门店配置拉取,不要在页面 onLoad 里重复请求。

3.2 预约首页:加载未来 7 天的排期槽位

时段列表的数据来源是 slot 表按日期聚合。云函数querySlots接收 shopId、serviceItemId、date 三个参数,返回当天所有 slot 以及每个 slot 的剩余量。

// 云函数 querySlots/index.js const db = uniCloud.database(); exports.main = async (event) => { const { shopId, serviceItemId, date } = event; const res = await db.collection('slots') .where({ shop_id: shopId, service_item_id: serviceItemId, date, status: 1 }) .orderBy('start_time', 'asc') .limit(50) .get(); return { code: 0, data: res.data.map(slot => ({ _id: slot._id, start_time: slot.start_time, end_time: slot.end_time, remaining: slot.capacity - slot.reserved_count, disabled: slot.capacity - slot.reserved_count <= 0 })) }; };

前端拿到 disabled 字段后直接决定时段格子是否可点。日期横向滚动 7 天,切换日期时重新调用云函数。这里不建议一次性拉 7 天数据,因为每次预约成功后剩余量都会变化,按天拉取能保证看到的库存和实际提交时一致。

3.3 日期栏和时段格子:注意自定义导航栏的适配

页面顶部如果做成“标题 + 日期横栏”,自定义导航栏时uni.getMenuButtonBoundingClientRect()拿到的胶囊位置是固定顶部元素的关键。日期栏要避开胶囊,否则右侧的日期会被微信右上角菜单盖住。常见做法是给日期栏留出胶囊宽度,或把日期横栏整体放在胶囊下方固定一条。

时段格子用简单的 grid 布局即可。选中的格子改变边框和背景色,已约满的格子加disabled类和禁止点击事件。

<view class="time-grid"> <view v-for="slot in slots" :key="slot._id" class="time-item" :class="{ active: selectedId === slot._id, disabled: slot.disabled }" @click="onSelect(slot)" > {{ slot.start_time }} </view> </view>

onSelect里只做两件事:记录选中 slot,并把预约按钮的 disabled 置为 false。不在这里发起任何网络请求,避免用户快速切换时段时产生并发请求堆积。

3.4 提交预约:防连点 + 用户信息落库

提交按钮的点击要同时防两种异常:用户连续点多次导致的重复预约,以及当选中的 slot 已被别人抢先时的库存不足。

const submitAppointment = async () => { if (submitting.value) return; const slot = selectedSlot.value; if (!slot) { uni.showToast({ title: '请先选择时段', icon: 'none' }); return; } submitting.value = true; try { const res = await uniCloud.callFunction({ name: 'createAppointment', data: { slotId: slot._id, petName: petInfo.value.petName, contactPhone: petInfo.value.contactPhone, requestId: `${Date.now()}_${Math.random().toString(16).slice(2)}` } }); if (res.result.code === 0) { uni.showToast({ title: '预约成功', icon: 'success' }); } else { uni.showToast({ title: res.result.msg || '预约失败', icon: 'none' }); } } finally { submitting.value = false; } };

submitting标志位在 finally 里复位,保证请求失败后按钮还能再点。requestId由前端生成,格式是时间戳加随机串,它是后端做幂等的依据,具体用法见 4.3。用户 openid 不从前端传,云函数里通过上下文自动获取,前端传 openid 会被人伪造,后端必须自己解析。

在源码里把用户信息存入user_profile表时,注意微信小程序的setData用变量做 key 要写this.setData({ ['user_info.' + key]: value })而不是this.setData({ 'user_info.' + key: value }),这是原生小程序里一个常见的坑。uniapp 里直接操作对象属性再整体赋值就没有这个问题。

4. 并发与冲突:预约落库时的“最后一个名额”怎么守住

预约系统的脏数据几乎全出在“读改写”竞争:两个用户同时看到 slot 还剩 1 个名额,先后提交预约,后端如果只是先查后写,两个请求都会检查通过,最后 slot 的 reserved_count 变成 2,超过 capacity,超卖。宠物门店超卖的代价是顾客到店干等,比电商超卖更容易产生差评。

4.1 为什么前端校验只是体验层:本质是数据库层的写冲突

时段格子显示“可约”只代表查询那一刻的状态。从用户看到可约、选时段、填宠物名、点提交,中间隔着好几秒甚至几十秒,这段时间里另一个用户可能已经抢走最后一个名额。前端校验是体验层,真正要保证的是落库时把“剩余量校验”和“名额扣减”做成一个原子操作。

常见的失败做法是三步走:先查 slots 表看剩余量,再 insert appointment,然后 update slots 的 reserved_count。三步操作在低并发下没问题,并发一上来,第二步和第三步可以被另一个请求插队,最后两笔订单都插入成功,库存也更新了,但总预约数超过 capacity。

4.2 云开发的原子扣减:把校验写进 update 条件里

微信云开发数据库的 update 支持条件更新,条件里可以直接塞比较表达式。预约扣减名额的正确顺序是:先条件更新 slot,更新结果等于 1 才说明名额抢到,再写预约单。

// 云函数 createAppointment/index.js const db = uniCloud.database(); const _ = db.command; exports.main = async (event) => { const { slotId, petName, contactPhone, requestId } = event; // 先查一次 slot 拿到 capacity,作为更新条件 const slotRes = await db.collection('slots').doc(slotId).get(); const slot = slotRes.data; if (!slot || slot.status !== 1) { return { code: 1, msg: '时段不存在或已停用' }; } // 条件更新:只有当前预约数小于容量时才执行,并且数量 +1 const updateRes = await db.collection('slots') .where({ _id: slotId, reserved_count: _.lt(slot.capacity) }) .update({ reserved_count: _.inc(1) }); if (updateRes.updated !== 1) { return { code: 2, msg: '手慢了,该时段已被约满' }; } // 名额已锁定,写预约单 try { await db.collection('appointment').add({ order_no: `PET${Date.now()}${Math.floor(Math.random() * 1000)}`, slot_id: slotId, service_item_id: slot.service_item_id, shop_id: slot.shop_id, pet_name: petName, contact_phone: contactPhone, status: 0, request_id: requestId, created_at: Date.now() }); } catch (e) { // 写单失败要把名额还回去,否则会白白少一个可约号 await db.collection('slots').doc(slotId).update({ reserved_count: _.inc(-1) }); return { code: 3, msg: '系统繁忙,请重试' }; } return { code: 0, msg: 'ok' }; };

这里最关键的是where里的reserved_count: _.lt(slot.capacity)。数据库在更新时会对匹配到的记录加行锁,两个并发请求同时执行这条 update,只有一个能匹配成功,另一个 updated 为 0。这比先查后写在绝大多数场景下更可靠,也是预约源码里最基本的并发防线。

插单失败后的_.inc(-1)是补偿逻辑,必须放在 try/catch 里。否则用户已经看到“预约成功”但记录没写进去,slot 的剩余量却减少了。

4.3 用 requestId 做幂等:防止重复点击生成两单

前端submitting防连点只能控制同一个页面的按钮,防不住用户“提交成功后秒退再进,同一时段再提交一次”这种情况,也防不住 4G 网络差时的重试。幂等键的作用是让后端对同一个请求只处理一次。

在 4.2 的代码里,request_id字段已经写入了订单。幂等校验放在 update 之前:

const existing = await db.collection('appointment') .where({ request_id: requestId }) .count(); if (existing.total > 0) { return { code: 0, msg: 'ok', duplicated: true }; }

如果同一个 requestId 已经存在,直接返回成功,不重复扣减名额。云开发没有 MySQL 那种唯一索引直接挡重复,并发极低时 count 会出现两个请求都查到 0,这种场景下把 requestId 作为 appointment 的_id更稳:

await db.collection('appointment').add({ _id: requestId, // ... });

_id冲突时 add 会抛错,捕获到错误就按重复请求处理。缺点是_id变成了业务字符串,分页和排序时要额外处理。

4.4 预约状态机:给取消和确认留清楚出口

预约单创建成功后,状态流转要闭环。我习惯把状态值用常量定义在云函数公共模块里,不要在业务代码里写死数字:

const STATUS = { PENDING: 0, // 待门店确认 CONFIRMED: 1, // 门店已确认 COMPLETED: 2, // 服务已完成 CANCELLED: 3 // 已取消 };
当前状态允许的下一步触发动作
PENDINGCONFIRMED / CANCELLED门店确认 / 用户取消
CONFIRMEDCOMPLETED / CANCELLED服务完成 / 开始前 2 小时用户取消
COMPLETED终态
CANCELLED终态

用户在预约开始前 2 小时外取消时,云函数要做三件事:把 appointment 状态改为 CANCELLED、把 slot 的 reserved_count 减一、记录取消原因。这三件事必须放在一个数据库事务里,用云开发runTransaction包裹,防止“状态改了但名额没放回”的中间状态。自建后端则使用 MySQL 的BEGIN+COMMIT,逻辑一致。

5. 进阶设计:排期预生成、取消回收和订阅消息提醒

源码能跑通预约闭环之后,真正影响门店使用体验的是三个边缘能力:明天的 slot 从哪来、用户取消的名额什么时候放回去、预约开始前怎么提醒。这三个能力都建立在前面四章的数据模型之上。

5.1 每天定时生成未来 14 天的排期

slot 是预生成的而不是用户预约时动态创建的。用云开发定时触发器每天凌晨 2 点跑一次,生成未来 14 天(除今天外)的 slot。核心逻辑是先查这天的 slot 是否已存在,存在就跳过,保证幂等。

// 云函数 generateSlots 定时触发:0 0 2 * * * * exports.main = async () => { const days = 14; for (let i = 1; i <= days; i++) { const date = new Date(Date.now() + i * 86400000); const dateStr = formatDate(date); // 手动实现 YYYY-MM-DD const exists = await db.collection('slots') .where({ date: dateStr }) .count(); if (exists.total > 0) continue; const serviceItems = await db.collection('service_items').get(); const slots = []; for (const item of serviceItems.data) { for (const start of buildStartTimes(item)) { slots.push({ shop_id: item.shop_id, service_item_id: item._id, date: dateStr, start_time: start, end_time: addMinutes(start, item.duration_minutes), capacity: item.capacity, reserved_count: 0, status: 1 }); } } await db.collection('slots').add(slots); } };

buildStartTimes按营业时间和服务时长自动排开始时间,两个时段之间天然带 buffer。批量写入时不需要事务,因为生成的是互不冲突的新行,唯一键冲突会被 catch 住。

注意:将服务时长、缓冲时间写在 service_item 表上而不是写死在生成函数里,门店调整项目时长时不需要改代码。

5.2 取消后的名额回收:立即放回还是延迟放回

预约取消后名额放回 slot 的时机影响抢约率。立即放回是最简单的,用户取消后下一分钟就能被约走。延迟放回(比如 30 分钟后再加回剩余量)是为了防止“先占坑再秒退”的恶意操作,但宠物门店低频次、低并发,恶意占坑影响有限,建议默认立即放回。用事务保证 appointment.status 和 slots.reserved_count 两步同成败,再给取消接口加一个“距开始时间不足 2 小时不可取消”的服务端校验就够了。

5.3 用订阅消息做预约提醒

预约成功后的微信订阅消息是触达用户最稳定的方式。用户点击“允许”订阅后,云函数里调用 subscribeMessage.send 发送模板消息。注意订阅消息是一次性的:用户订阅一次只能下发一条,所以在预约成功和预约开始前 24 小时各需要订阅两次。实现上,预约成功页放一个“开启提醒”按钮,点了之后把用户 openid、预约单号、预计开始时间写入提醒表,定时触发器在预约开始前 30 分钟扫表、发消息、标记已发送。参数校验、模板 ID 这些放在源码的配置文件中,不要写死在代码里。

验证并发控制是否生效的最直接方法是:用两个微信开发者工具实例同时点同一个 slot 的提交按钮,看返回结果是否恰好一成功一失败;再检查 appointment 表只有一条记录且 slots.reserved_count 比之前大 1。这个观察结果比任何代码审查都有说服力。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/12 10:38:50

WMSST与MCNN在工业故障诊断中的应用与实现

1. 项目概述在工业设备维护领域&#xff0c;故障诊断一直是个棘手的问题。传统方法就像用老式收音机收听模糊不清的信号&#xff0c;很难准确捕捉设备故障的细微特征。而基于小波多尺度同步压缩变换(WMSST)结合多尺度卷积神经网络(MCNN)的方法&#xff0c;就像是给工程师配备了…

作者头像 李华
网站建设 2026/9/12 10:38:42

嵌入式工程师核心技能与开发实战指南

1. 嵌入式工程师的核心能力图谱作为从业十二年的嵌入式开发者&#xff0c;我深刻体会到这个领域对工程师的综合素质要求极高。嵌入式系统是软件与硬件的完美结合体&#xff0c;工程师需要在这两个维度上都具备扎实功底。1.1 硬件基础能力硬件理解是嵌入式开发的根基。我们需要掌…

作者头像 李华
网站建设 2026/9/12 10:35:17

Doris数据分布策略与分区分桶优化实践

1. Doris数据分布策略概述Apache Doris作为一款高性能MPP分析型数据库&#xff0c;其数据分布策略直接决定了查询性能与系统稳定性。在实际生产环境中&#xff0c;合理设计分区分桶方案能够将查询延迟降低50%以上&#xff0c;同时避免数据倾斜导致的节点过载问题。2. 分区设计原…

作者头像 李华
网站建设 2026/9/12 10:34:45

Flink读取Kafka数据实战:连接器配置、位点管理与性能调优

简介&#xff1a;一套完整的Flink实时数据处理实战项目打包为zip&#xff0c;覆盖从Kafka消费数据、流式计算到写入Redis集群与MySQL的完整链路。项目面向大数据开发学习者&#xff0c;适合正在搭建实时数仓、需要掌握Kafka Connector、Flink窗口计算以及多种Sink集成的读者。压…

作者头像 李华
网站建设 2026/9/12 10:33:51

AI Agent 生产级基础设施搭建实战:从模型接入到数据安全

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华