
简介基于微信小程序的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)保证同一个项目同一个开始时间只能有一条排期记录这一点在后续生成排期时反复用到重复生成直接报错而不是写进两行。appointment的slot_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.vue的onLaunch里做门店配置拉取不要在页面 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 classtime-grid view v-forslot in slots :keyslot._id classtime-item :class{ active: selectedId slot._id, disabled: slot.disabled } clickonSelect(slot) {{ slot.start_time }} /view /viewonSelect里只做两件事记录选中 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 的BEGINCOMMIT逻辑一致。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。这个观察结果比任何代码审查都有说服力。本文还有配套的精品资源点击获取