拓冰建站拓冰建站
首页 / 资讯中心 / 正文

宠物医院预约挂号小程序实战:从微信登录到并发防超卖的完整方案

先说结论这个项目是个标准的小程序业务系统技术栈不复杂但胜在业务链路完整从微信登录、号源展示再到预约下单、订单管理几乎把小程序开发里最常踩的坑都趟了一遍。如果你正准备做类似的预约类小程序这篇东西应该能帮你省下不少排查时间。1. 项目背景与整体规划1.1 为什么做宠物医院挂号预约系统宠物医疗这个赛道这几年增长很猛但大部分中小型宠物医院的线上化程度其实很低。很多医院还在用微信群接龙、电话预约甚至到店排队的方式管理挂号效率低不说还容易出错。做这个小程序的初衷很直接让宠物主人能像人去医院挂号一样在小程序里看到医院有哪些科室、哪些医生、哪个时间段有空号然后直接预约不需要打电话问前台到了店也不用干等。从产品角度看这个系统本质上是一个垂直领域的预约工具核心解决三个问题信息透明让用户提前知道医生排班和号源情况、流程标准化把预约、取消、核销的规则固化成系统逻辑、数据可回溯商家能统计每个医生的接诊量、每个时段的预约率。1.2 技术选型PHP uni-app 的搭配逻辑先说后端为什么选 PHP。这个项目的定位是中小型宠物医院的数字化工具不是高并发大流量的平台级应用用 PHP 开发速度快、部署成本低、生态成熟ThinkPHP 或 Laravel 框架都够用。实际开发中我用的 ThinkPHP 8因为它的路由、ORM 和中间件机制比较轻量符合这类业务系统的需要。如果你的团队更熟悉 Laravel那一套也行核心业务逻辑差别不大。前端选 uni-app 的理由更充分一套代码编译成微信小程序、H5、App对业务方来说意味着买一送一。哪怕初期只发布微信小程序后续要扩展抖音小程序、百度小程序或者打包成 Appuni-app 的跨端能力能省掉一整条开发线。而且 uni-app 的语法基于 Vue组件的生态也比较完善像uni.request、uni.login、uni.navigateTo这些 API 封装得比较顺手写起来不像原生小程序那么繁琐。提示如果你的项目只需要微信小程序一个端用原生开发也没问题。但考虑到宠物医院这类甲方经常会在上线后提能不能做个 H5 版本给用户在微信里打开用 uni-app 前期多花的一点学习成本是值得的。1.3 项目整体模块划分整个系统分成用户端小程序和管理后台两个部分我用一张功能清单来梳理模块用户端小程序管理后台登录授权微信授权登录、手机号绑定账号密码登录医院信息首页展示、医院介绍、科室列表医院信息编辑医生管理医生列表、医生详情医生信息维护、排班设置号源预约选择科室/医生/日期/时间段、提交预约号源管理、放号设置订单管理预约记录、取消预约、就诊状态预约审核、核销、统计报表个人中心宠物档案、我的预约、消息通知会员管理消息通知预约成功、就诊提醒模板消息消息模板配置2. 系统架构与数据库设计2.1 技术架构与部署方案这个项目的架构可以用一张图说清楚前端 uni-app 编译后的微信小程序通过wx.request请求后端 API后端采用 ThinkPHP 8 的 MVC 结构Nginx 做 Web 服务器MySQL 8.0 做数据存储Redis 做缓存和号源防并发控制。小程序端通过 HTTPS 调用接口域名需要在小程序后台配置白名单。开发环境我建议用宝塔面板部署省去手动配 Nginx、PHP、MySQL 的时间。PHP 版本选 8.0 以上主要是性能更好而且 TP8 本身也要求 PHP 8.0。Redis 用来存 token、短信验证码、号源锁在生产环境是刚需。2.2 核心数据表结构设计数据库设计是这类系统的重头戏直接决定后期开发的顺利程度。我这里把最关键的表结构列出来具体的字段设计如下用户表user字段类型说明idint主键openidvarchar(64)微信 openid唯一索引nicknamevarchar(50)昵称avatarvarchar(255)头像 URLphonevarchar(20)手机号create_timedatetime创建时间宠物档案表pet字段类型说明idint主键user_idint所属用户namevarchar(50)宠物昵称typetinyint宠物类型1狗 2猫 3其他breedvarchar(50)品种gendertinyint性别birthdaydate出生日期avatarvarchar(255)宠物照片科室表departmentid、名称、介绍、图标、排序、状态。医生表doctorid、科室id、姓名、职称、头像、擅长领域、简介、状态。排班表schedule字段类型说明idint主键doctor_idint医生work_datedate日期time_slotvarchar(20)时间段如09:00-09:30total_numint总号源数remain_numint剩余号数statustinyint状态1正常 0停诊预约订单表appointment字段类型说明idint主键order_novarchar(32)订单号user_idint用户pet_idint宠物档案doctor_idint医生schedule_idint排班 idappoint_datedate预约日期time_slotvarchar(20)时间段statustinyint状态映射cancel_reasonvarchar(255)取消原因create_timedatetime创建时间这里的status字段建议用状态机设计我用的是0待就诊、1已完成、2已取消、3已核销、4待支付如果有付费需求。后期如果要对接支付可以在订单表加上pay_status和transaction_id字段。2.3 核心设计思路号源扣减的并发处理预约系统中最重要的就是防止超卖——两个用户同时抢同一个时间段的最后一个号不能都成功。我的方案是 Redis 原子扣减 数据库乐观锁兜底。具体逻辑是// 1. 尝试扣减 Redis 中的号源 $redisKey schedule:{$scheduleId}:remain; $remain Redis::decr($redisKey); if ($remain 0) { // 号已放完回滚 Redis::incr($redisKey); return error(该时间段已约满); } // 2. 扣减成功生成订单 $result Db::name(appointment)-insert($orderData); // 3. 数据库操作失败需要回滚 Redis if (!$result) { Redis::incr($redisKey); return error(系统繁忙请重试); }同时在 appointment 表里对schedule_id加唯一约束每个用户每个排班只能预约一次防止用户重复提交。这个双保险机制在测试环境下可能看不出差别一旦上线遇到真实流量就会明白它的价值。3. 后端接口开发与核心逻辑实现3.1 微信登录与 Token 鉴权微信小程序的登录流程大家应该不陌生但真正写好需要处理不少细节。核心流程是小程序端调用uni.login获取 code后端拿 code 调用微信的jscode2session接口换回 openid 和 session_key自定义一个 token我用的是md5(openid . time() . random)存 Redis 并设置过期时间7 天小程序后续请求在 header 里带 token后端用中间件校验关键代码如下public function login($code) { $appId config(wechat.appid); $appSecret config(wechat.secret); $url https://api.weixin.qq.com/sns/jscode2session?appid{$appId}secret{$appSecret}js_code{$code}grant_typeauthorization_code; $result file_get_contents($url); $data json_decode($result, true); if (!isset($data[openid])) { return json([code 400, msg 登录失败]); } // 检查用户是否存在 $user Db::name(user)-where(openid, $data[openid])-find(); if (!$user) { $userId Db::name(user)-insertGetId([ openid $data[openid], create_time date(Y-m-d H:i:s) ]); } else { $userId $user[id]; } // 生成 token $token md5($data[openid] . time() . mt_rand(1000, 9999)); Redis::setex(token: . $token, 604800, $userId); return json([code 200, token $token, user_id $userId]); }这里有个坑要注意session_key涉及解密用户手机号的逻辑如果你要获取用户手机号需要保存 session_key而它有效期很短。我的做法是登录后不主动解密手机号等用户授权手机号时再临时用 code 换 session_key 解密这样可以避免 session_key 过期的问题。3.2 医生排班与号源列表接口预约系统最核心的接口就是获取医生的排班信息和剩余号源。前端需要展示某一天哪些医生有号、哪些时间段还能约。接口设计上我按照日期维度提供数据public function getScheduleList() { $doctorId input(doctor_id); $workDate input(work_date); // 查询某个医生某天的排班 $scheduleList Db::name(schedule) -where(doctor_id, $doctorId) -where(work_date, $workDate) -field(id, time_slot, total_num, remain_num, status) -select() -toArray(); return json([code 200, data $scheduleList]); }前端拿到这段数据后把时间段渲染成网格已满的时间段置灰不可选用户点击可用的时间段填入预约信息。这里有个产品层面的小设计为了让号源看起来更真实我把每个时间段的号源数默认设为 5用户预约后remain_num就 -1。医生可以设置每天的号源总量如果某个时间段停诊后台把status置 0 即可。3.3 预约下单与取消预约预约下单接口是整个系统中逻辑最复杂的一块涉及排班校验、宠物选择、订单生成三个环节。我建议把下单流程拆成两步第一步选时间段第二步填用户/宠物信息提交。这样前端页面结构更清晰后端接口也更单一职责。提交预约的核心逻辑public function createAppointment() { $userId request()-userId; $scheduleId input(schedule_id); $petId input(pet_id); $doctorId input(doctor_id); $appointDate input(appoint_date); $timeSlot input(time_slot); // 验证排班状态 $schedule Db::name(schedule)-where(id, $scheduleId)-find(); if (!$schedule || $schedule[status] ! 1) { return json([code 400, msg 该时间段不可预约]); } if ($schedule[remain_num] 0) { return json([code 400, msg 该时间段已约满]); } // Redis 原子扣减 $redisKey schedule:{$scheduleId}:remain; $remain Redis::decr($redisKey); if ($remain 0) { Redis::incr($redisKey); return json([code 400, msg 手速太慢号已被约走]); } // 生成订单 $orderNo date(YmdHis) . mt_rand(1000, 9999); try { Db::name(appointment)-insert([ order_no $orderNo, user_id $userId, pet_id $petId, doctor_id $doctorId, schedule_id $scheduleId, appoint_date $appointDate, time_slot $timeSlot, status 0, create_time date(Y-m-d H:i:s) ]); Db::name(schedule)-where(id, $scheduleId)-dec(remain_num)-update(); } catch (\Exception $e) { Redis::incr($redisKey); return json([code 500, msg 系统繁忙]); } return json([code 200, msg 预约成功, order_no $orderNo]); }取消预约的逻辑是反向操作把订单状态改为已取消同时把排班的remain_num1并释放 Redis 中的号源。注意取消操作必须做状态校验只有待就诊状态的订单才能取消已完成或已取消的订单要直接拦截。3.4 消息通知预约成功与就诊提醒消息通知用的是微信小程序的订阅消息模板消息已经下线了别再踩这个坑。用户预约成功后需要引导用户订阅预约成功通知和就诊提醒。这里有个体验细节订阅消息的弹窗不能由后端直接触发必须由用户点击行为触发所以要在预约提交后的成功页放一个接收通知按钮点击后调用uni.requestSubscribeMessage让用户授权。后端发送订阅消息的核心逻辑public function sendSubscribeMessage($openid, $page, $data) { $templateId config(wechat.template_id); $accessToken $this-getAccessToken(); $url https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token{$accessToken}; $body [ touser $openid, template_id $templateId, page $page, data $data ]; $result $this-httpPost($url, json_encode($body)); return $result; }提示订阅消息有个关键限制——用户点击授权一次后端只能发一条。所以预约成功通知和就诊提醒需要两个不同的模板 ID用户分别授权。前端在提交成功的页面里可以同时调用两次uni.requestSubscribeMessage一次传一个模板 ID。4. 前端核心页面与交互实现4.1 微信授权登录与全局状态管理uniapp 里的登录和原生小程序略有一点区别。我用的方式是在 App.vue 的onLaunch里主动调用uni.login拿 code然后请求后端登录接口拿 token 存到uni.setStorageSync。用一个全局的loginStatus变量管理登录态可以在 Vuex 或 Pinia 中维护。因为项目用的是 Vue 3 语法所以状态管理我选了 Pinia。login module 的核心逻辑export const useUserStore defineStore(user, { state: () ({ token: , userInfo: {}, petList: [] }), actions: { async login() { const { code } await uni.login({ provider: weixin }); const res await request({ url: /auth/login, method: POST, data: { code } }); this.token res.data.token; this.userInfo res.data.user_info; uni.setStorageSync(token, this.token); }, async checkLogin() { this.token uni.getStorageSync(token) || ; if (!this.token) { // 跳转到登录页 uni.navigateTo({ url: /pages/login/login }); } } } });这里有个细节uni.login拿到的 code 是一次性的5 分钟有效。如果用户在弱网环境下请求超时第二次调用uni.login会重新生成 code所以前端需要做重试机制。4.2 首页与预约流程页面实现首页在小程序中承担的功能是指引用户快速进入预约流程的导航页。考虑到宠物医院的信息结构我把首页拆成几个模块轮播图宣传位、科室入口横向滚动 icon 列表、推荐医生卡片展示、底部快捷操作预约挂号按钮。预约流程从首页点击预约挂号开始经过四个步骤第一步选择科室科室列表用最简单的列表页即可但要注意加载性能。科室数量一般不超过 20 个可以直接一次性加载不需要做分页。每个科室带一个 icon用v-for循环渲染。view classdepartment-list view classdepartment-item v-foritem in departmentList :keyitem.id clickselectDepartment(item) image :srcitem.icon modeaspectFill/image text{{ item.name }}/text text{{ item.doctor_count }}位医生/text /view /view第二步选择医生进入医生列表后需要展示医生所属的科室、职称、擅长方向用户点进医生详情页看简介再返回列表点击预约按钮。这里有一个交互细节医生列表的排序规则建议按预约量降序排列让热门医生排在前面。这个排序逻辑后端接口直接处理好前端只需要透传排序参数即可。第三步选择日期与时间段日期选择器是整个预约流程中最核心的组件。我用了uni-calendar组件的二次封装。日历组件默认是整月展示的但这会导致初始渲染 30 多天体验不太好。我的做法是设置start-date为当天、end-date为未来 14 天并且用插值的方式控制日历一次只渲染 7 天的数据用户左右滑动切换周。时间段的选择用网格布局展示每个时间段的格子包含时间如 09:00-09:30和剩余号数。剩余为 0 的格子置灰用户无法选中。第四步确认预约信息这一步有一个重要的细节宠物档案的选择。因为一个用户可能养多只宠物要让用户在下单时选择给哪只宠物挂号所以在确认页需要展示当前的宠物列表默认选中第一只用户可以切换。如果有支付需求这步还需要展示订单金额并调起uni.requestPayment。4.3 预约记录页与状态管理预约记录页展示当前用户的所有预约订单需要支持待就诊、已完成、已取消三个 tab 的切换。每个 tab 对应的数据通过后端接口带状态参数过滤。我踩过的一个大坑是tab 切换时直接通过v-if切换内容区域导致每次切换都重新请求接口。后来优化成保持三个页面实例用v-show切换显示只有下拉刷新时才重新请求。这个改动让切换体验顺畅了不少不会出现白屏或者 loading 闪烁的状态。预约卡片的展示信息要完整宠物头像名字、医生姓名、医院名称、预约日期、时间段、状态标签。已取消的订单要显示取消原因已完成的订单要让用户能评价和查看病例记录。状态标签的颜色建议做一个统一的映射表const statusMap { 0: { text: 待就诊, color: #FF9900 }, 1: { text: 已完成, color: #07C160 }, 2: { text: 已取消, color: #999999 }, 3: { text: 已核销, color: #1989FA } };4.4 宠物档案管理宠物档案管理是一个看起来简单但容易做废的模块。需求很明确用户可以增删改查自己的宠物信息因为预约时需要选择宠物。实现细节上接口就是标准的 CRUD但图片上传是一个绕不开的点。宠物头像和病例图片需要上传到服务器uniapp 里的图片上传相对简单使用uni.chooseImage选择图片再用uni.uploadFile上传。后端接收文件后用 ThinkPHP 的文件上传逻辑存储并返回 URL。这里建议把图片处理成缩略图后再返回避免原图过大影响加载速度。ThinkPHP 的上传代码比较简单public function upload() { $file request()-file(file); $info $file-validate([size 2097152, ext jpg,jpeg,png,gif]) -move(public_path(uploads)); if ($info) { $path /uploads/ . $info-getSaveName(); // 生成缩略图 $image \think\Image::open($info-getPathname()); $image-thumb(200, 200, \think\Image::THUMB_CENTER)-save($info-getPathname()); return json([code 200, url $path]); } return json([code 400, msg 上传失败]); }5. 关键难点与常见问题排查5.1 微信小程序登录态过期与静默刷新小程序登录态过期是个高频问题。微信官方机制是 access_token 的有效期是 7200 秒但那是接口调用凭证不是用户登录态。用户登录态openid本身不会过期但我们自定义的 token 有过期时间。我遇到的情况是用户在小程序里停留时间超过 token 有效期后再点击接口就全部 401。解决办法是在 request 封装里做统一拦截遇到 401 就自动调用静默登录重新拿 token然后重放原请求。这个逻辑写好后基本无感// request.js const request (options) { return new Promise((resolve, reject) { const token uni.getStorageSync(token); uni.request({ url: BASE_URL options.url, method: options.method || GET, data: options.data || {}, header: { Content-Type: application/json, Authorization: token }, success: async (res) { if (res.data.code 401) { // token 过期静默登录 await userStore.refreshLogin(); // 重放请求 const retryRes await request(options); resolve(retryRes); } else { resolve(res.data); } }, fail: (err) { reject(err); } }); }); };5.2 跨域问题与发起请求时 Nginx 代理配置开发环境下小程序请求本地后端接口需要开启微信开发者工具的不校验合法域名否则会报url not in domain list。但正式上线必须走 HTTPS 域名。如果你用的是 H5 端调试还会碰到跨域问题。我的建议是开发环境直接把接口地址配成局域网 IP Nginx 转发生产环境配正式域名。Nginx 配置参考server { listen 80; server_name yourdomain.com; return 301 https://$host$request_uri; } server { listen 443 ssl; server_name yourdomain.com; ssl_certificate /etc/nginx/ssl/yourdomain.pem; ssl_certificate_key /etc/nginx/ssl/yourdomain.key; root /var/www/yourproject/public; index index.php index.html; location / { if (!-e $request_filename) { rewrite ^(.*)$ /index.php?s$1 last; break; } } location ~ \.php$ { fastcgi_pass 127.0.0.1:9000; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } # 上传文件大小限制图片上传需要调大 client_max_body_size 20m; }5.3 并发预约导致号源超卖前面提到过 Redis 原子扣减的方案但实际生产环境中还会遇到一个问题Redis 服务挂了怎么办如果真的出现了 Redis 不可用的极端情况系统会直接报错这时需要数据库的乐观锁兜底。数据库层面的兜底方案在 schedule 表加一个version字段更新号源时带上 version 条件UPDATE schedule SET remain_num remain_num - 1, version version 1 WHERE id 1 AND remain_num 0 AND version 5;如果影响行数为 0说明号源被抢完或版本号不匹配需要让用户重试。这个方案虽然不够优雅但至少能保证系统在极端情况下的数据一致性。5.4 uni-datetime-picker 在 iOS 滚动容器中的渲染兼容如果你在项目中使用了uni-datetime-picker并且把它放进了 scroll-view 里那么在 iOS 的微信小程序上可能会出现渲染异常——日期选择器弹层被裁剪、滚动不顺畅。这是因为 iOS 上小程序 webview 的渲染机制对position: fixed元素在滚动容器内的表现不一样。解决方案有三个方向把日期选择器的弹层通过uni-popup组件渲染到页面根节点而不是放在 scroll-view 内部。如果只需要选择日期而非时间建议直接用原生picker modedate组件性能更好bug 也更少。如果非要保留uni-datetime-picker建议用v-show控制弹层的显示数据变化用change事件监听。同时给滚动容器加上enhanced属性能缓解一部分渲染问题。5.5 微信小程序包体积优化第一次打包上传时我遇到过一个问题项目的包体积接近 2.5MB微信小程序主包限制是 2MB直接提交不了。这个问题的本质在于 uni-app 把 Vue 运行时和组件库一起打包进去了。优化思路有两条路径路径一是分包加载。把预约流程的页面、宠物档案页面拆到分包里主包只保留首页和个人中心。pages.json中的长这样{ pages: [ { path: pages/index/index }, { path: pages/user/user } ], subPackages: [ { root: pagesAppoint, pages: [ appoint/department, appoint/doctor, appoint/date, appoint/confirm ] }, { root: pagesPet, pages: [ pet/list, pet/edit ] } ] }路径二是把不需要的组件库给剔除掉。如果你只用到了 uni-ui 的部分组件在main.js里按需引入别用全量uView之类的组件库。我观察到很多人为了省事把整个 UI 库引进来包体积直接翻一倍。图片资源的压缩也很关键把超过 100KB 的图全部走 CDN本地只保留 loading 图等基础素材。5.6 微信小程序上架与发布常见问题这类系统上线时要过微信审核有几个常见的坑第一个是用户隐私协议。现在微信要求小程序必须配置《用户隐私保护指引》如果你收集了用户的手机号、头像、位置信息等必须在后台把对应的事项勾选清楚否则接口调用时会报隐私授权错误。第二个是类目审核。挂号预约类的小程序需要选择医疗-私立医疗机构或者商业服务-预约服务类目不同类目需要的资质文件不同。如果你做的是非盈利性质的宠物医院展示工具建议先以信息服务类目提交审核会容易一些。第三个是订阅消息的审核。订阅消息模板申请时关键词需要和实际使用场景一致。比如预约成功通知模板关键词要包含预约时间、宠物姓名、医生但是模板库里的关键词有限你也可以自定义关键词但需要提供使用场景说明审核可能需要 1-3 天。提前申请好模板别等开发完再申请。第四个是关于获取位置的需求。如果要做附近的宠物医院推荐就需要在小程序端调用uni.getLocation这涉及到用户隐私接口的申请还需要在小程序管理后台配置位置接口的用途说明。如果只是展示医院地址方便用户导航建议直接用wx.openLocation打开地图不需要提前申请定位权限省去一堆审核流程。6. 项目扩展方向与总结项目做完了预约功能正常跑通后期还有几个可以做的方向第一对接微信支付。目前版本是免费预约如果医院要做线上挂号收费就需要在小程序后台申请微信支付商户号前端用uni.requestPayment调起支付后端用回调接口处理支付结果。订单状态机从待就诊扩展成待支付-已支付-待就诊。第二管理后台的统计报表。目前我做的后台只是基础的预约管理和医生排班管理但真的运营起来医院方会需要数据报表。比如按日统计预约量、按医生统计接诊量、按时段统计预约饱和度。增加一个数据看板对提升项目的整体价值感帮助很大。第三多端发布。uni-app 最香的地方就是可以一套代码发布多端。把微信小程序发布到抖音小程序、支付宝小程序没什么额外成本前端代码基本不用改只是底层 API 略微有些不同。如果需要 App 版本用uni-app自带的打包功能云打包成 Android/iOS 原生 App走一遍发布流程即可。第四智能提醒与回访机制。预约完成后用定时任务在就诊前一天给用户发订阅消息提醒就诊结束后隔三天给用户推送回访问卷。这部分可以用 PHP 的队列功能来做比如把需要推送的消息丢进 Redis 队列再用一个消费者脚本轮询发送能有效避免定时任务在大流量下堆积。最后再分享一个实际开发中的细节。做这种多端小程序项目时接口的返回格式一定要从一开始就统一我用的格式是{ code: 200, msg: success, data: {} }前后端联调时所有接口都遵守这个规范前端request.js里统一拦截code ! 200的情况做错误提示。一旦中途修改返回结构前端所有页面都要跟着改这种坑我踩过一次之后就变得特别敏感。项目规模越小越要在一开始把基础规范定好不然后面都是给自己挖坑。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门