去年我们团队把跑了大半年的H5版远程在线诊疗系统整体重构成了微信小程序,整个过程从技术选型到上线维护踩了不少坑。这套系统核心解决的问题很朴素:患者不用到现场排队就能挂号、问诊、看报告,医生在排班时间可以在线接诊并开电子处方,费用通过小程序内支付实时结算。整个项目包含患者端小程序、医生端工作台和运营管理后台三块,覆盖预约挂号、图文问诊、视频问诊、电子病历、处方流转、在线支付、药品配送对接等十几条业务链路。这篇文章不打算讲空泛的架构理论,而是把新手最容易卡住的细节——请求封装、缓存时效、导航栏适配、支付接入、审核合规、线上问题排查——单独拎出来,给出可以直接抄作业的写法。如果你正在做医疗类小程序,或者准备把传统H5问诊业务小程序化,这篇会比较对口。
1. 项目概述与需求拆解
1.1 核心需求拆解:先把线下就诊流程翻译成在线流程
医疗行业有一个特殊性:稳定和安全优先于一切,功能再花哨也不能牺牲流程的严谨性。远程在线诊疗系统的本质,是把线下的“挂号—候诊—问诊—开方—取药—支付”这一整条链路搬到线上,同时在每个环节增加可追溯的状态记录。
从角色维度拆分,需求非常清晰:
| 角色 | 核心诉求 | 关键功能 |
|---|---|---|
| 患者 | 足不出户完成看病 | 预约挂号、候诊排队、图文/视频问诊、报告查询、电子处方、在线支付 |
| 医生 | 高效接诊、规范开方 | 排班管理、接诊队列、病历书写、开具处方、与患者沟通 |
| 管理员 | 运营监控与数据统计 | 医生管理、科室管理、订单统计、药品目录维护、退款处理 |
功能模块定了之后,紧接着要梳理业务边界:哪些功能必须在线完成,哪些需要线下配合。比如抽血化验、影像检查这类物理环节,在线系统只能做到“预约到院”,不能替代现场执行。我们在需求评审时把这类流程单独标记为“线上预约、线下履约”,避免开发时把系统做成一个不切实际的“全在线医院”。
1.2 业务流程设计:状态机是问诊系统的命根子
在线问诊最怕的是业务状态混乱:患者付了费,医生没接诊;医生开了方,支付回调没到账;患者取消订单,候诊队列没同步……这些问题全是状态机没设计好导致的。
我们的核心流程是这样的:
- 患者提交挂号/问诊申请,系统创建订单,状态为“待支付”;
- 支付成功后状态变为“已预约”,同时进入医生的接诊队列;
- 医生点击接诊,状态变为“问诊中”,此时患者端显示医生在线状态;
- 医生结束问诊并填写病历,状态变为“待开方”或“已完成”;
- 若开具电子处方,患者确认后进入“待支付药品费”;
- 药品费支付完成,订单进入“配送中/待取药”,最后闭环在“已完成”。
每一步状态流转都对应后端接口的显式请求,前端不能用本地变量记录订单状态,必须实时从接口拉取。这里有一个很关键的教训:问诊中的消息推送,我们一开始只依赖WebSocket,结果弱网环境经常断线导致患者收不到医生回复。后来改成“WebSocket实时推送 + 接口轮询兜底”双通道方案,虽然多写了一点代码,但消息可靠性提升非常明显。医疗场景里“消息没送到”比“消息慢几秒”严重得多,宁可冗余,不能漏。
2. 技术选型与工程结构设计
2.1 原生微信小程序还是 uniapp:没有标准答案,只有合适答案
这个是项目启动时争论最多的问题。我们的团队规模是两个前端,同时要维护微信小程序和后续可能要上的App端,所以技术选型直接关系到人力能不能撑得住。
| 对比维度 | 原生微信小程序 | uniapp + Vue |
|---|---|---|
| 多端复用 | 只能跑微信,代码无法直接迁移 | 一套代码可以打微信小程序、H5、App、鸿蒙等 |
| 性能表现 | 渲染效率最优,复杂列表更顺滑 | 中间层有转换损耗,极致性能需要优化 |
| 生态与组件 | 微信官方能力调用最直接 | uni-app插件市场丰富,但要甄别质量 |
| 学习曲线 | 需熟悉WXML/WXSS/小程序API | 会Vue就能上手,上手更快 |
| 原生能力颗粒 | 可以直接用wx.xxx全部能力 | 部分能力要查条件编译、看平台差异 |
我们最后选了uniapp。原因很实在:问诊系统有大量表单页面、列表页面和流程页面,用Vue的组件化开发效率比原生WXML高很多;而且同一个患者端以后还想覆盖支付宝小程序和独立的App,不可能每个端都重新写一遍。代价是遇到平台差异问题时,需要花时间做条件编译和处理兼容,这部分我在后面“常见问题”里会详细讲。
但如果你只做微信端、且团队对原生小程序已经非常熟练,那我建议直接用原生。uniapp的价值在跨端,不在单端性能。单端场景用原生更省心。
2.2 工程目录与分包设计:主包只放核心路径
项目采用uniapp标准目录,但根据业务做了分层:
src/ pages/ # 主包页面:首页、登录、就诊人管理、订单列表 pages-doctor/ # 分包1:医生工作台、接诊、病历、开方 pages-live/ # 分包2:视频问诊、聊天会话 components/ # 公共组件:医生卡片、订单卡片、空状态 api/ # 接口请求模块,按业务域拆分 utils/ # 缓存、导航栏适配、格式化等工具函数 store/ # 全局状态管理 static/ # 静态资源这里强烈建议做分包。我们首版把所有页面都塞进主包,开发时没感觉,一上线发现冷启动慢得明显,尤其是低端安卓机,体验很糟糕。后来问诊室、视频通话、支付结果这些重页面全部拆到分包,主包只保留首页、登录、订单等核心路径,启动耗时降了差不多三分之一。小程序有主包体积限制,医疗类系统又经常要上传报告图片、聊天素材,分包是必须做的事,不是可选项。
3. 关键模块的实现细节
3.1 请求封装与统一拦截:别让每个页面自己调wx.request
一个真实项目少说几十个接口,如果每个页面都自己写一遍wx.request,光错误处理就能写出三套不同版本。我们的做法是封装一个统一请求方法,全局只认一个出入口,所有接口模块都基于它构建。
// api/request.js const BASE_URL = 'https://api.yourdomain.com'; function request(options) { return new Promise((resolve, reject) => { uni.request({ url: BASE_URL + options.url, method: options.method || 'GET', data: options.data || {}, header: { 'Content-Type': 'application/json', 'Authorization': uni.getStorageSync('token') || '' }, success: (res) => { const { code, data, message } = res.data || {}; if (code === 0) { resolve(data); } else if (code === 401) { // 登录态失效,统一跳登录并清理本地凭证 uni.removeStorageSync('token'); uni.removeStorageSync('userInfo'); uni.navigateTo({ url: '/pages/login/index' }); reject(res); } else { uni.showToast({ title: message || '服务异常', icon: 'none' }); reject(res); } }, fail: (err) => { uni.showToast({ title: '网络连接失败,请稍后重试', icon: 'none' }); reject(err); } }); }); } export default request;封装的好处不只在于少写代码。统一拦截器让我们可以集中处理登录态失效、错误提示、埋点上报,甚至后续要做请求队列、接口加密,都只需要改这一个文件。这里有个细节:接口返回的code与HTTP状态码要分开处理。我们后端约定HTTP层只返回200,业务状态码放在body里,这样前端拦截器和后端网关可以解耦,排查问题也会清楚很多。
3.2 缓存策略与登录态时效:小程序storage没有自动过期机制
微信小程序的storage和浏览器localStorage一样,本身没有过期时间概念。如果不做处理,token、用户信息这些数据就会一直累积在那里,轻则数据陈旧,重则登录态过期后用户还在调用受保护接口。我们封装了一个带有效期的小工具,所有本地缓存统一走这个入口:
// utils/cache.js export function setCache(key, value, expireSeconds = 0) { uni.setStorageSync(key, { value, expire: expireSeconds ? Date.now() + expireSeconds * 1000 : 0 }); } export function getCache(key) { const data = uni.getStorageSync(key); if (!data) return null; if (data.expire && data.expire < Date.now()) { uni.removeStorageSync(key); return null; } return data.value; } export function removeCache(key) { uni.removeStorageSync(key); }实际业务里,不同数据的有效期策略差别很大:
- 登录token:2小时有效期,配合后端每次请求校验刷新,不能让token永久有效;
- 用户基础信息(昵称、头像、手机号):24小时;
- 医生排班列表:5分钟,避免每次进入都刷全量数据;
- 药品分类目录:一周,这类低频变化数据可以缓存久一点;
- 处方、病历、报告:不做本地持久化,只存在内存中,页面销毁就释放。敏感医疗数据留在本地有泄漏风险,这是合规红线。
3.3 顶部导航栏与自定义顶部的适配:全面屏时代的经典坑
微信小程序里做自定义顶部导航,最大的坑是不同机型的刘海屏和状态栏高度不一致。iPhone X以上有刘海,Android各家全面屏手势区的处理也五花八门,如果写死导航栏高度,马上就会出现标题被状态栏压住,或者自定义按钮离胶囊按钮太近的情况。
适配方案的核心是拿到胶囊按钮的位置,再反推导航栏高度。小程序提供了getMenuButtonBoundingClientRect,拿到胶囊按钮的top和高度,配合状态栏高度就能算出导航栏总高度:
// utils/navbar.js export function getNavBarInfo() { const winInfo = uni.getWindowInfo(); const menuRect = uni.getMenuButtonBoundingClientRect(); const statusBarHeight = winInfo.statusBarHeight || 20; let navBarHeight = 44; if (menuRect) { // 胶囊上下边距相等,导航栏高度 = 胶囊top与状态栏的间距 * 2 + 胶囊高度 navBarHeight = (menuRect.top - statusBarHeight) * 2 + menuRect.height; } return { statusBarHeight, navBarHeight, menuRect }; }这个公式的原理很简单:微信小程序的胶囊按钮在导航栏里垂直居中,所以胶囊按钮顶部到状态栏底部的距离,乘以2,再加上胶囊自身高度,正好等于导航栏的总高度。我们把这个工具函数放在全局,每个需要自定义顶部的页面都从这里取高度,再结合CSS变量动态设置padding,实测从iPhone SE到最新的Pro机型、各种安卓机型都没有再出现错位问题。
3.4 表单组件与交互细节:单选框的合理替代方案
诊疗系统里大量用到表单,性别、科室、支付方式、预约时段都需要单选。小程序的radio组件功能没毛病,但原生的样式很难看,而且跨端在安卓和iOS上的渲染差异明显。这里分享一个我们在实际项目里验证过的做法:选项不超过3个的场合,直接用自定义按钮组模拟单选。
比如支付方式选择,我们用三个卡片按钮,选中时切换边框色和背景色,底部加选中状态的打勾图标,交互反馈比原生radio清晰得多。核心代码如下:
<view class="pay-option" :class="{ active: payMethod === 'wx' }" @click="payMethod = 'wx'"> <text class="pay-name">微信支付</text> <text class="pay-check" v-if="payMethod === 'wx'">✓</text> </view>靠CSS控制选中态,配合一点过渡动画,视觉一致性更好,也避开了radio组件在部分Android机型上显示变形的问题。如果选项很多、需要用到picker滚动选择,建议用小程序内置picker组件,比手写滚动选择器稳定。
4. 核心业务流程实操记录
4.1 在线问诊主流程:支付到接诊的链路要环环相扣
在线问诊是整套系统的主动脉,我们花时间最多的地方是“患者支付成功到医生开始接诊”这中间的状态同步。我们的做法是:支付成功后,前端立刻调用后端“确认支付结果”接口,后端确认订单状态并写入医生队列;医生端通过长连接收到新接诊提醒,同时工作台会轮询拉取最新队列数据。
主流程实现分四步:
- 患者选择医生和时段,提交预约单,创建订单;
- 支付成功后,订单状态流转为“已预约”,医生端出现待接诊卡片;
- 医生点击“开始接诊”,患者端弹出聊天/视频窗口,双方进入问诊会话;
- 医生填写病历并提交处方(可选),患者端收到处方单,确认后进入药品支付。
这里我要特别提醒一个容易忽略的点:视频问诊一定要做“断线重连”和“排队兜底”。我们线上跑的时候遇到过一次视频服务商短暂故障,患者端黑屏,医生端的会话状态还停在“问诊中”,两边都不知道发生了什么。后来我们加了心跳检测,30秒无响应就在界面上提示网络异常,并支持一键切换到图文问诊继续沟通。这个备用方案看着不起眼,但真到生产环境就是救命的。
4.2 微信支付接入:个人主体绕不开的资质门槛
在线诊疗必然涉及收费,微信支付是绕不开的环节。首先要明确:个人主体的小程序无法开通微信支付,必须使用企业主体或个体工商户主体申请微信支付商户号。医疗信息服务类类目对资质审核更严格,这个问题要在项目立项时就想清楚,不然开发到一半才发现无法支付,返工成本极高。
支付流程设计上,遵循微信官方的“服务端统一下单 + 前端调起支付”模式:
- 患者点击支付,前端向后端发起“创建支付订单”请求;
- 后端调用微信支付统一下单API,拿到prepay_id;
- 后端使用商户私钥生成支付签名,把timeStamp、nonceStr、package、signType、paySign返回给前端;
- 前端调用uni.requestPayment传入上述参数;
- 支付结果由微信支付官方回调通知后端,后端验签并更新订单状态。
前端核心代码很简洁,但后端签名和回调验签必须严格按文档来:
// 前端调起微信支付 uni.requestPayment({ provider: 'wxpay', timeStamp: payData.timeStamp, nonceStr: payData.nonceStr, package: payData.package, signType: 'RSA', paySign: payData.paySign, success: () => { // 支付成功,等待后端回调确认后刷新订单 this.refreshOrderStatus(); }, fail: (err) => { // 用户取消或支付失败,别急着删订单,让用户选择重新支付或取消 } });这里要强调:前端只负责调起支付,绝对不能自己决定订单是否支付成功。我们曾经发现部分异常场景下前端success回调触发了,但后端回调没收到,导致订单卡在“已支付”和“未支付”的中间态。后来统一改成前端收到success后,立即调后端“查询订单状态”接口,以数据库里的最终状态为准。前端弹窗提示和页面跳转都基于这个接口的结果,这样任何一环出现问题都能正确回到订单页。
4.3 实名认证与身份证信息提取:合规收集必须过授权关
在线问诊涉及人身安全问题,患者实名认证是硬要求。小程序里实现“扫描身份证提取身份证号”一般有两条路:一条是前端拍照/上传身份证图片,传给后端OCR识别服务;另一条是直接接入第三方实名认证服务商的SDK,由服务商完成识别和活体检测。
我们采用的是后者,方案更稳妥,合规压力也小一点。不管用哪种方式,有两个底线必须守住:
第一,收集身份证信息前,必须通过弹窗获得用户明确授权,说明用途和使用范围。不能在用户无感知的情况下静默识别证件,这违反了个人信息保护的基本要求。
第二,证件原图不能长期存储在后端服务器。OCR识别完成后,我们会立刻裁剪掉敏感区域,原图在完成识别后定时清理。身份证号、姓名这类敏感字段在传输过程中要做加密,日志中做脱敏处理,只显示后四位。
前端实现上,小程序里直接用chooseMedia调起相机拍摄,拍摄完成后再上传到识别接口,整体交互就是“拍照—上传—回填表单”三步:
// 拍摄身份证并提取信息 uni.chooseMedia({ count: 1, mediaType: ['image'], sourceType: ['camera'], success: (res) => { const tempFilePath = res.tempFiles[0].tempFilePath; // 上传临时文件到后端OCR接口 uploadForOcr(tempFilePath).then((info) => { // 回填姓名、身份证号等字段 form.name = info.name; form.idCard = info.idCard; }); } });4.4 发布审核与合规:医疗类小程序的审核比普通应用严格得多
远程在线诊疗属于微信小程序里医疗类目下的敏感类目,不是随便一个企业主体就能开通。按平台规则,涉及在线问诊、电子处方的,需要提供《医疗机构执业许可证》或相关资质证明,具体的类目选择直接决定审核能不能过。我们在这上面耽误过两周,原因是资质文件主体和小程序主体不一致,审核被驳回。所以提到资质这件事,一定要在域名配置和类目选择之前就确认清楚,主体一致性是硬条件。
另外提审前的自查清单里,隐私保护指引是重头戏。小程序后台需要配置隐私协议弹窗,明确列出收集用户信息的目的和用途。我们第一次提审就是因为隐私指引里漏了“身份证号”这一项被驳回了。微信官方对敏感信息的收集非常敏感,医疗应用尤其如此。
还有一个平台规则容易忽略:小程序年审。我的小程序主体资质到期或营业执照更换,都需要在后台完成年度审核,不然线上版本可能被限制使用。很多独立开发者产品上线后就不管后台了,结果年底突然被下架,恢复流程又很麻烦。
5. 常见问题与排查技巧实录
5.1 iOS机型网络请求失败率高:错误码6001的坑
上线一段时间后,我们监控发现iOS设备上的接口请求失败率明显高于Android,尤其集中在老版本iOS系统,错误码频繁出现6001。排查路径大概有四步:
- 确认网络环境:6001多数是TLS握手失败或证书校验不过,先排除手机本身网络问题;
- 检查HTTPS证书链是否完整:部分iOS机型对证书链完整性要求比Android更严格,证书中间链缺失会导致握手失败;
- 检查后台域名白名单:小程序request合法域名必须在后台配置,而且必须HTTPS;
- 检查ATS合规性:iOS对非HTTPS请求有限制,但小程序层面已经强制HTTPS,主要排查还是证书配置。
我们最后的根因是服务器证书使用了旧版TLS配置,部分老版本iOS默认不兼容。升级证书配置后,iOS端失败率从2%降到了0.2%左右。这个问题给我们的经验是:医疗应用的用户群体年龄偏大,老旧机型的使用比例比你想象的高,开发阶段就要兼容够老的系统版本,不能拿自己的新手机测一遍就上线。
5.2 开发调试技巧:本地抓包和热刷新的正确打开方式
微信小程序开发工具自带的Network面板已经能看大部分请求数据,但真机测试时,想看线上环境的具体接口返回,抓包工具还是很有用。我常用的场景是:用户反馈某个页面数据加载不出来,本地又无法稳定复现,就通过抓包工具拦截真机的小程序请求,对比返回数据和正常接口的差异,快速定位是前端传参问题、后端返回异常还是网络层故障。
操作上,用代理工具将PC和手机置于同一局域网,手机设置代理指向PC,小程序开发版请求就会经过代理工具,在代理界面里能看到完整请求头、请求参数和响应内容。注意,抓包只用于开发调试,不能用它做任何非合规的事情,更不能拿线上敏感数据做其他用途。
开发效率方面,微信开发者工具的热刷新很好用。但是uniapp项目要注意:修改了utils目录下的公共文件,热刷新可能不生效,需要手动重新编译。我一般改完公共模块直接Ctrl+B手动编译,避免页面状态残留导致问题。还有一个小技巧:开发版小程序里打开“不校验合法域名”选项,可以临时调试未配置白名单的本地接口,但上线前一定要关掉,否则审核会被拒。
5.3 跨端兼容差异:uniapp不是“一次编写,到处运行”的银弹
uniapp宣传的口号很美好,但实际开发中,平台差异依然存在。我们踩过的几个具体问题:
微信小程序端的storage和App端的storage完全是两套实现,App端uni.setStorageSync同步写入本地文件,小程序端走的是微信的storage机制,数据量上限和行为都不一样。所以缓存工具函数最好自己封装,不要在业务层直接调用uni的storage方法。
视频问诊组件在Android和iOS上的权限表现不一致。Android需要动态申请摄像头和麦克风权限,iOS则通过系统弹窗询问。我们用条件编译分别处理了权限申请逻辑:
// #ifdef APP-PLUS // App端使用plus.android.requestPermissions申请权限 // #endif // #ifdef MP-WEIXIN // 小程序端使用wx.authorize申请权限 // #endif这是条件编译的真实应用场景。如果项目只在微信小程序里跑,不需要考虑这些;但如果以后要做App和鸿蒙,一开始就预留条件编译的处理,能省掉后面大量重构时间。
5.4 性能优化与并发控制:小程序的10个并发请求限制
微信小程序的并发请求上限是10个,超出部分的请求会排队,如果排队时间太长,用户侧直观感受就是“卡”。在线诊疗系统的特点是页面打开瞬间会同时发起多个请求:用户信息、挂单列表、医生排班、消息未读数,很容易在首屏阶段触到并发上限。
我们的优化策略分三层:
- 首屏只加载核心数据。首页优先拉用户基本信息+推荐医生,排班列表和消息未读数等二次加载;
- 公共数据走缓存。科室分类、医院列表这类不常变的数据,用前面说的带有效期缓存,直接省掉一个请求;
- 图片懒加载。医生头像、药品图、报告缩略图统一走懒加载,减少网络请求阻塞。
这里还要提一个容易被忽视的点:小程序页面切换时,前一个页面的异步请求如果还没返回,有可能触发setData报错。我们在请求封装的外层做了页面生命周期关联校验,页面onUnload时中断该页面未完成的请求的状态更新,避免“页面已销毁还在弹Toast”这种低级但是真实的线上问题。
做完整套系统回头看,最大的体会是远程在线诊疗这种业务,技术本身反而不是最难的,最难的是把复杂的线下流程转译成线上状态机,同时把稳定性和合规性焊进每一个细节。开发阶段多花时间把缓存、异常、过期这些边角处理干净,上线的日子就会好过很多。后面我们还计划把用药提醒和复诊随访模块做成订阅消息和公众号联动,这套基础架构应该还能继续撑一段时间。