2. 核心业务建模与数据库设计
想清楚业务怎么流转,再动手写代码,能省掉后面一大半重构的功夫。预约挂号系统最核心的环节有几个:用户选科室、选医生、选时间、提交预约、医院确认、就诊签到。每一步的状态怎么流转,数据怎么存,都需要事前定义清楚。
2.1 角色与权限设计
系统的用户分为三类:患者、医生(后台操作者)、管理员。在小程序端,我们主要面对的是患者,医生和管理员一般通过管理后台或Web端操作,小程序端只负责展示和预约。
- 患者:注册登录、浏览科室医生、提交预约、查看预约记录、取消预约、在线签到。
- 医生/后台:管理排班、确认或接诊、查看当日预约列表。
- 管理员:维护科室、医生信息、号源池配置、数据统计。
小程序端不需要做得太复杂,把患者侧的流程做好就成功了一大半。我当时一开始想加入医生端在线问诊功能,后来砍掉了,原因很简单:预约挂号的痛点在于“挂号和排队”,问诊是另一个完整的业务线,混在一起会把系统做臃肿,审核和上线都会增加难度。
2.2 预约流程与状态机
预约系统最怕的是状态混乱:用户明明预约成功了,到了医院却说没有记录;用户取消预约了,后台还显示已预约。这些问题的根源是状态流转没设计好。我梳理了一套状态机,全程只有五个状态:
| 状态 | 含义 | 触发条件 |
|---|---|---|
| 待支付/待确认 | 用户提交预约,尚未支付挂号费(如适用) | 用户点击提交预约 |
| 已预约 | 支付完成或免支付确认,号源已锁定 | 支付回调成功/后台确认 |
| 已取消 | 用户主动取消或超时未支付自动取消 | 用户取消/定时任务触发 |
| 已完成 | 用户线下就诊完毕 | 医生/后台标记完成 |
| 已爽约 | 预约未取消且未按时就诊 | 就诊时间过后自动标记 |
这里有个关键点:预约和支付的关系一定要想清楚。多数公立医院的在线挂号,其实是不需要预付费的,预约成功后直接生成凭证,线下取号缴费。但如果是私立诊所或体检中心,往往需要在线支付定金。两种模式对应的流程不同,状态机也要相应调整。我建议第一版先做“预约不支付”,因为支付涉及微信支付商户号申请,医疗类目需要额外资质,审核周期长,可以放到二期迭代。
2.3 核心数据表设计
数据库是整个系统的地基。我用的MySQL,表结构设计如下,字段只列核心的:
- 用户表(user):openid(唯一索引)、nickname、avatar、phone、create_time。这里提醒一下,phone 一定不要一开始就要求用户填写,建议等用户预约成功后再通过手机号验证绑定,这样注册转化率更高。
- 科室表(department):name、description、sort_order。注意预留一个排序字段,因为科室列表不是按创建时间排的,而是医院内部有主次之分。
- 医生表(doctor):name、title(职称)、department_id、avatar、intro、good_at(擅长领域)。职称和擅长领域这两个字段,在用户决策时非常重要,尽量不要省。
- 排班表(schedule):doctor_id、work_date、start_time、end_time、total_slots、booked_slots、status。排班表是号源管理的核心,total_slots - booked_slots 就是剩余号源数。
- 预约表(appointment):order_no(唯一)、user_id、doctor_id、schedule_id、appointment_date、appointment_time、status、cancel_reason、create_time。
预约表一定要有 order_no,格式建议:日期 + 时间戳 + 随机数,比如 202505051030001234。这既方便用户报号,也方便后台检索。还有一个很容易被忽略的点:预约表除了 user_id 和 schedule_id 各建索引外,status 字段也要建索引,因为统计“当日预约”“待就诊”这类查询非常频繁,没有索引表一大数据量就慢。
排班表设计我踩过坑:早期版本把号源总数(total_slots)放在医生表里,结果每个医生的不同日期号源数完全没法配置。后来单独拆出排班表,按 doctor_id + work_date 粒度控制号源,灵活多了。
3. 前端核心功能实现与实操细节
前端是小程序的门面,也是用户直接感知的部分。下面挑几个核心功能的实现思路,包括登录授权、请求封装、列表分页、预约下单,都是可以直接拿过去用的方案。
3.1 登录授权与请求封装
小程序登录和大前端登录不太一样。大前端是输入账号密码,小程序是静默登录 + 获取用户信息,核心函数是 wx.login()。用户首次进入小程序时,调用 wx.login 获取 code,然后传给后端,后端用 code 换取 openid。这里注意,session_key 和 openid 必须由后端去微信接口换取,绝对不要在小程序端做任何敏感操作。
登录之后要维护 token。我习惯的流程是:
- 前端 wx.login() 拿到 code。
- 将 code 发给后端
/api/auth/login。 - 后端通过 code 请求微信接口,拿到 openid 和 session_key,查找用户。
- 如果是新用户,自动注册并返回前端一个自定义的登录态 token(JWT)。
- 前端把 token 存到 wx.setStorageSync,并放入后续所有请求的 header。
这里强调一个点:很多教程会教你用wx.getUserProfile()获取用户头像和昵称,然后在登录时传给后端。但这个过程在小程序的新规范下已经变了,用户头像昵称的获取规则也在不断调整。我的建议是:后端不要强制要求用户必须授权头像昵称,用默认头像 + “微信用户”作为初始值,等用户主动完善资料时再更新。这样登录路径更顺滑,也符合平台规则。
请求封装我也说一下。可以在小程序 utils 目录下创建一个 request.js,统一处理 baseURL、token 注入、响应拦截、错误提示、loading 控制。我习惯用 Promise 封装:
// utils/request.js const BASE_URL = 'https://api.example-hospital.com' function request({ url, method = 'GET', data = {}, loading = true }) { if (loading) { wx.showLoading({ title: '加载中...' }) } return new Promise((resolve, reject) => { wx.request({ url: `${BASE_URL}${url}`, method, data, header: { 'Content-Type': 'application/json', 'Authorization': wx.getStorageSync('token') || '' }, success(res) { if (res.statusCode === 200 && res.data.code === 0) { resolve(res.data) } else if (res.statusCode === 401) { // 登录态过期,跳转到登录页 wx.removeStorageSync('token') wx.navigateTo({ url: '/pages/login/login' }) reject(res) } else { wx.showToast({ title: res.data.msg || '请求失败', icon: 'none' }) reject(res) } }, fail(err) { wx.showToast({ title: '网络异常,请检查网络', icon: 'none' }) reject(err) }, complete() { if (loading) { wx.hideLoading() } } }) }) } module.exports = { request, BASE_URL }需要注意的几个细节:
- 401 处理逻辑一定要有。token 过期是必然发生的,如果不处理,用户会在某个页面卡住,体验极差。
- loading 开关要可配置,否则列表加载时弹 loading、下拉刷新时弹 loading、loading 叠 loading,非常烦躁。
- BASE_URL 要区分环境和版本。开发时用局域网 IP 地址,联调时可以用内网测试域名,发布时必须用已备案且配置了合法域名的正式域名。这个后面在审核那一节专门说。
3.2 科室列表的“加载更多”实现
热搜词里提到了“微信小程序页面列表加载更多”,这确实是用户在真实使用中非常高频的操作。预约挂号系统里,科室列表、医生列表、预约记录,都可能出现超过一屏的数据。
加载更多的常规做法是“上拉加载下一页”。小程序在页面的 json 配置里开启"onReachBottomDistance": 50,然后在onReachBottom生命周期里加载下一页。但直接这么写会踩坑,我给你说下细节:
Page({ data: { list: [], page: 1, pageSize: 10, hasMore: true, isFetching: false }, onLoad() { this.fetchList(true) }, onReachBottom() { if (this.data.hasMore && !this.data.isFetching) { this.fetchList(false) } }, fetchList(reset) { if (this.data.isFetching) return const page = reset ? 1 : this.data.page + 1 this.setData({ isFetching: true }) request({ url: '/api/department/list', data: { page, pageSize: this.data.pageSize }, loading: false }).then(res => { const records = res.data.list || [] const hasMore = records.length >= this.data.pageSize this.setData({ list: reset ? records : this.data.list.concat(records), page, hasMore, isFetching: false }) }).catch(() => { this.setData({ isFetching: false }) }) } })几个关键点在代码里已经体现了,我再补充说明:
isFetching防重入。用户手速极快,连续触发 onReachBottom,如果不加这个判断,会发起多个重复请求,导致数据重复或错乱。- 判断
hasMore不要用“返回条数等于 pageSize”这种方式,而应该让后端返回一个hasMore字段。因为最后一页恰好等于 pageSize 条时,前端会多请求一次,虽然问题不大,但没必要。 - 列表底部要加“没有更多了”的提示,不然用户会一直往上滑,滑不到底心里没底。
- 下拉刷新可以配合
enablePullDownRefresh: true,在onPullDownRefresh里调用fetchList(true),然后 wx.stopPullDownRefresh()。预约记录页特别需要刷新,因为用户取消预约后回到列表页,列表要同步更新。
3.3 预约下单与号源扣减
预约下单是整个系统最核心的业务,也是最容易出并发问题的地方。想象一下:某个热门专家的号源,一天 20 个,凌晨 0 点放号,几百人同时抢,怎么保证不会超卖?
常见的方案有三种:
- 数据库乐观锁:
UPDATE schedule SET booked_slots = booked_slots + 1 WHERE id = ? AND booked_slots < total_slots,受影响行数为 1 才表示抢号成功。 - 数据库悲观锁:对排班记录加
SELECT ... FOR UPDATE,事务内检查和更新。 - 队列/缓存:用 Redis 预扣库存,或者用消息队列削峰。
对于预约挂号这种业务,我推荐方案一,最简单且可靠。下单接口的后端逻辑(以 Node.js + MySQL 为例):
// 伪代码 const result = await db.query( `UPDATE schedule SET booked_slots = booked_slots + 1 WHERE id = ? AND booked_slots < total_slots`, [scheduleId] ); if (result.affectedRows === 1) { // 扣减成功,生成预约单 const appointment = await db.query( `INSERT INTO appointment (order_no, user_id, doctor_id, schedule_id, status) VALUES (?, ?, ?, ?, 'BOOKED')`, [orderNo, userId, doctorId, scheduleId] ); // 事务提交 } else { // 扣减失败,提示“号源已约满” }注意这段逻辑必须放在数据库事务里,UPDATE 和 INSERT 要么同时成功,要么同时回滚,否则会出现号源扣了但预约单没生成(或者反过来)的数据不一致问题。
前端提交预约时,最好弹一个二次确认的模态框,显示医生、科室、就诊时间、就诊地点等关键信息,让用户确认后再提交。因为预约是不可逆操作(虽然可以取消,但会消耗号源),多一步确认能显著降低误操作和客诉。
3.4 就诊提醒与订阅消息
预约成功后,用户最关心的是“别错过时间”,所以就诊提醒功能非常关键。小程序提供了订阅消息能力,但注意,小程序订阅消息是一次性的——用户订阅一次,你只能发送一条消息。如果用户预约了 3 天后就诊,你不能在第 2 天提醒一次、第 3 天再提醒一次,除非用户在每次发送时重新授权。
这个限制让很多开发者头疼。我的处理方案是:
- 预约成功时,引导用户订阅“预约成功通知”(包含就诊时间、科室、地点)。
- 预约前一天 20:00,由后端定时任务发送“就诊提醒”,但这需要用户在预约时同时订阅两次(微信支持订阅一次就授权多次吗?不支持。自 2020 年起,点击订阅可以弹多条模板,用户可接受多次订阅)。
- 前端在预约成功页放一个引导组件,请求订阅消息授权,可以同时勾选“预约成功通知”和“就诊提醒通知”。
订阅消息模板要在微信公众平台申请,申请时需要选择行业分类。医疗类目下的模板通常有“预约挂号成功通知”“就诊提醒通知”等,审核一般 1-3 个工作日。
一个重要细节:订阅消息触达率很低。很多用户会拒绝授权,或者系统级关闭了通知权限。所以我建议在预约成功页把关键的预约信息显示清楚,同时提醒用户“可在就诊前 1 天通过预约记录查看详情”,不要把订阅消息作为唯一的通知渠道。
3.5 页面细节:顶部导航高度与安全区适配
热搜词里有“微信小程序顶部导航栏高度”“自定义顶部”,这两个问题几乎每个小程序开发者都会遇到。预约挂号系统的页面结构相对简单,但不同机型适配一定要做好,否则会出现内容被刘海屏遮挡、被底部黑条遮挡的问题。
获取顶部导航栏高度的常规做法:
// 在 onLoad 中获取 const { statusBarHeight, platform } = wx.getSystemInfoSync() // 胶囊按钮信息 const menuButton = wx.getMenuButtonBoundingClientRect() // 导航栏高度 = 胶囊按钮顶部到状态栏底部的距离 * 2 + 胶囊高度 const navBarHeight = (menuButton.top - statusBarHeight) * 2 + menuButton.height计算原理不复杂:胶囊按钮垂直居中在导航栏内部,titleBarHeight = (capsule.top - statusBarHeight) * 2 + capsule.height。在 iPhone X 等刘海屏上,状态栏高度大约是 44px,在普通机型上是 20px,胶囊按钮的位置也相应不同,所以不能写死。
使用自定义导航栏时,需要在页面的 json 里配置"navigationStyle": "custom",然后在页面顶部用占位 view 撑出状态栏和导航栏的高度。这个方案让导航栏不再是单一的“返回 + 标题”,可以自定义背景色、手势、按钮,视觉上更统一,但要注意返回事件的兼容处理。
底部安全区适配相对简单,给页面底部预留env(safe-area-inset-bottom)即可,或者在app.json的window配置里设置"style": "v2",新版基础库会自动处理。预约确认按钮如果固定在底部,必须考虑这个,否则 iPhone 上按钮会被 Home 指示条遮住一部分。
4. 调试、联调与真机测试实录
这一部分是我实际开发中花费时间最多的地方。小程序开发环境和生产环境差异巨大,很多问题在开发者工具里一切正常,一到真机就原形毕露。下面把几个高频问题逐个说透。
4.1 开发者工具、真机预览与体验版分发
把开发中的小程序发给其他人试用,收集反馈,有三种方式:
- 真机预览:在微信开发者工具点击“预览”,生成二维码,用微信扫码即可在真机打开。但每次代码更新就需要重新预览,且预览二维码有效期较短(一般约 15 分钟),不适合长期评测。
- 体验版:将代码上传到微信公众平台,设置为体验版,然后把“体验成员”的微信号加入到体验成员列表。体验版相当于一个稳定的测试版本,适合多人多次使用,体验成员也可以单独设权限。
- 开发版:管理员扫码进入的是开发版,调试信息最完整,但仅限管理员使用。
我第一次给医院方演示时,遇到一个尴尬情况:对方用 Android 手机扫码体验版,打开后所有请求都报错,而我在 iOS 真机上是正常的。排查了很久,最终发现原因:开发者工具、iOS 真机、Android 真机的网络环境测试规则不完全相同,Android 对域名的校验更严格。这个问题引出了下面的话题。
4.2 合法域名与 request fail 的排查
小程序规定:wx.request、wx.uploadFile等网络接口的域名,必须是 HTTPS 并且已在小程序后台“开发管理-开发设置-服务器域名”中配置。这是上线前的硬性门槛,否则正式版会请求失败。
本地联调阶段,有两个绕过方式:
- 在开发者工具中勾选“不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书”,这样可以在开发阶段使用
http://localhost或局域网 IP。 - 在真机调试中,使用“真机调试”功能(不是预览),真机调试模式下同样不校验域名,因为代码是通过 USB/局域网通道连接的。
我之前遇到的一个经典问题:开发版和体验版都请求成功,预览版请求失败。原因是预览生成的是临时开发版,在某些场景下走的是真机直连,域名校验开启。一旦你用了未配置的域名(比如裸 IP),预览扫码就会出现request:fail url not in domain list。排查思路就是去后台看域名配置是否正确,HTTP 是否升级成了 HTTPS,证书链是否完整。
另外,HTTPS 证书必须是有效证书,不能用自签名证书。小程序对证书的校验有几种不同表现:iOS 提示“不受信任的证书”,Android 直接报错。我建议证书用正规 CA 机构签发的,比如免费版的 Let‘s Encrypt,或者云厂商提供的免费证书都行。开发时用 IP 联调虽然方便,但一定要预留切换域名的时间,上线前可能还需要联调一次。
4.3 用抓包工具调试小程序请求
热搜词里提到了“charles使用教程”“burp抓包微信小程序”,很多开发者一头雾水:小程序不是加密的吗?怎么抓包?
原理我先说清楚。微信小程序本质上是跑在微信客户端里的 Web 应用,它的网络请求遵循 HTTP(S) 协议。要让抓包工具(Charles、Fiddler、Burp Suite)看到 HTTPS 请求的明文内容,需要做“中间人代理”:让手机连接电脑的代理端口,并安装抓包工具的根证书,这样抓包工具就能解密 HTTPS 流量。
以 Charles 为例,抓包小程序请求的步骤:
- 电脑安装并打开 Charles,默认代理端口 8888。
- 打开“Proxy → SSL Proxying Settings”,勾选 Enable SSL Proxying,添加
*号匹配所有域名。 - 手机和电脑连同一个 Wi-Fi,手机设置代理为电脑的局域网 IP,端口 8888。
- 用手机访问
chls.pro/ssl下载并安装 Charles 根证书。 - iOS 需要额外操作:设置 → 通用 → 关于本机 → 证书信任设置 → 开启对 Charles 证书的完全信任。
- 在小程序开发者工具里不要勾选“不校验合法域名”,保持最接近生产环境的网络策略,让真机走正式的网络链路。
- 打开小程序,操作预约流程,Charles 里就能看到
/api/appointment/create等请求的 URL、Header、请求参数和响应内容。
为什么需要抓包?典型场景:
- 排查请求参数是否正确,比如时间戳格式、签名是否一致。
- 查看后端返回的错误码,小程序端有时候把错误吞掉了,只弹了个“请求失败”,通过抓包能看到具体错误信息。
- 模拟弱网环境:Charles 的 Throttle 功能可以模拟 3G 网络,看小程序在慢网下是否会超时、Loading 状态是否正常。
需要提醒的是,抓包工具证书如果不信任,会报SSLHandshakeError之类的错误。另外,新版微信对证书校验做了一些升级,部分抓包方案在小程序场景下不稳定,如果抓不到包,可以先检查证书是否安装完整,再检查代理是否生效。
4.4 医疗类目资质、审核与年审
这里要重点说明,预约挂号系统涉及医疗健康类目,小程序审核比普通工具类严格得多。如果主体是公立医院,相对好办,提供事业单位法人证书即可。如果是第三方平台(比如我这个项目),就需要提供:
- 《医疗器械网络销售备案凭证》或《医疗机构执业许可证》等资质文件。
- 如果涉及在线支付,还需要微信支付商户号,且商户号主体要与小程序主体一致。
- 隐私协议:必须清晰说明收集了哪些用户信息(手机号、身份证号、就诊信息),用途是什么,是否共享给第三方。
小程序审核最常遇到的驳回理由有这么几条:
- “涉及医疗健康服务,需提供相关资质”。解决:提前准备资质材料,在类目审核时一并提交。
- “页面功能与所选类目不符”。这个比较坑,我遇到过明明申请了医疗类目,但页面并没有展示执业许可证编号,被驳回了。解决:在“关于我们”页面放上机构资质信息,最好能跳转卫健委查询页。
- “无法登录/体验功能不完整”。审核人员需要能走通整个预约流程,如果没有测试号源,会被判定为功能不完整。解决:设置一个 debug 模式的测试号源,或者提供一个体验账号,让审核人员能顺利创建预约。
年审也是容易忽略的点。小程序认证每年需要续费(目前个人 30 元/年,企业 300 元/年),年审期间如果不及时处理,小程序会被暂停新用户访问。我项目上线半年后收到年审提醒,当时因为忙差点错过,重新认证花了几天时间,期间新用户无法关注和访问,损失很大。
5. 常见问题速查与排错心得
最后把我开发过程中积累的高频问题和解决思路整理成表格,方便你遇到问题时快速定位。这些问题不一定都能从官方文档里找到直接答案,但都是真实场景里会遇到的。
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| wx.request 报 url not in domain list | 域名未配置或未备案 | 检查服务器域名配置,确认 HTTPS、ICP 备案号 |
| 真机一切正常,开发版报错 | 开发者工具关闭了域名校验,真机没关 | 用真机调试模式,逐个页面测试 |
| 预约成功但医生端看不到 | 事务未提交,或医生端查询的排班 ID 不一致 | 检查后端日志,确认订单号是否写入 |
| onReachBottom 不触发 | 页面高度不够,或没有设置 onReachBottomDistance | 确认列表可滚动,页面总高度超过视口 |
| 订阅消息发送失败 errcode 43101 | 用户未授权或授权已过期 | 重新引导用户在业务操作中授权 |
| iOS 返回键消失 | 自定义导航栏没有处理返回事件 | wx.navigateBack 失败时调用 wx.reLaunch 到首页 |
| 图片无法上传 | 域名未加入 uploadFile 合法域名 | 后台下载域名里补充上 file 域名 |
| 页面底部内容被遮挡 | 未适配安全区 | 加 env(safe-area-inset-bottom) |
| 版本更新后用户还是旧版 | 小程序缓存 | 调用 wx.getUpdateManager 提供热更新提示 |
| 时间显示相差 8 小时 | 后端存的时间是 UTC | 统一用时间戳或东八区时间存储,前端格式化 |
再分享一个优化体验的小技巧:预约列表页和科室列表页的数据,如果变化不频繁,可以用 wx.setStorageSync 做本地缓存,设置 5 分钟的过期时间。这样用户再次进入时可以先展示缓存,再静默更新,体感上会快很多。但注意,预约记录页面不要做强缓存,因为状态变化太频繁,用户取消后如果看到旧状态会很困惑。
操作上,我还习惯把所有请求的耗时和错误码埋点上报到后端日志服务。上线初期,通过日志发现两类问题:一类是“创建预约”接口偶发 500 错误,排查后是数据库连接池配置太小;另一类是部分用户点击预约后无反应,因为是双击导致的重复提交,后端加了幂等校验才解决。接口耗时监控也是必须的,如果“获取医生列表”接口超过 2 秒,用户大概率会直接退出小程序——这就是真实世界的用户耐心阈值。
6. 我的一些个人迭代建议
按照我实际开发这个系统的经验,最想和你说的是:不要一上来就想着做一个功能完备的大系统,先跑通一个最小闭环,再逐步迭代。
第一版可以只做到:用户登录 → 科室列表 → 医生列表 → 选择一个时段 → 提交预约成功 → 后台能看到预约记录。这个闭环能跑通,就说明架构是通的,剩下的都是细节填充。
第二版可以加:订阅消息提醒、预约取消和改期、医生的排班管理(后台)。
第三版再加:在线支付、电子就诊卡、候诊叫号进度查询、满意度评价。
迭代过程中要特别注意数据沉淀。预约量、科室热度、医生热度、爽约率,这些数据统计到一定量级后,既能帮助医院优化排班,也是你简历上或项目汇报里的亮点内容。
关于技术选型,如果你不是必须用原生小程序,也可以考虑 uni-app 或 Taro。这两个框架都支持一套代码编译到微信小程序、支付宝小程序等多个平台。但我的建议是:如果这是你第一个小程序项目,老老实实学原生小程序。原因很简单:原生小程序虽然写起来繁琐一些(每个页面要写 wxml、wxss、js、json 四个文件),但编译链路最短、调试最直观,踩坑时网上资料也最多。框架帮你省的时间,最后会加倍花在“框架怎么定位这个问题”上。等你对原生有了手感,再切换到跨端框架也会快很多。
最后说一个我在真实项目中验证过的做法:预约表单里的“就诊人”信息,不要每次都让用户手填。在小程序端用本地缓存记住上一次填写的就诊人姓名、身份证号、手机号,下次自动填充。这个看似不起眼的细节,确实能大幅提升用户的复约率——尤其对于那些每周都要做产检或复诊的用户,每次手填身份证号真的是劝退操作。