去年接了个模拟项目,帮某医疗信息化团队从零搭一套在线问诊系统源码级的Demo。最初大家以为这活儿和做电商订单差不多,无非患者下单、医生接单、聊天室答疑。真正把需求和业务规则捋完才发现,视频/图文问诊平台搭建步骤里最难的从来不是音视频本身,而是问诊单的状态机、消息可靠性、并发接诊控制和合规留痕这一连串绕不开的底层问题。
这篇文章我想把整个搭建过程按实操顺序重新梳理一遍,把决策理由、数据表设计、关键代码链路和实测踩坑都摊开来讲。适合正在做问诊类产品、准备接医疗信息化项目,或者自己想做垂直领域远程咨询平台的开发者参考。内容会偏后端和架构方向一些,前端只点关键交互。
1. 问诊业务和普通业务系统差的不是一星半点:先定三件事
1.1 问诊单是核心实体,而不是聊天会话
很多第一次做在线问诊的人会直接套用即时通讯那套模型:一个会话,两个用户,互相发消息。这个思路在主赛道聊天产品里没问题,但放在医疗问诊场景里会埋下大坑。
问诊平台上每一次医患沟通都必须有明确的范围边界。患者今天挂号问的是皮肤问题,医生就不能在这个会话里聊出另一个毫不相关的诊断结论;患者提交的图片、病历、医生给出的文字建议和处方,都要作为问诊记录固定下来,将来可以追溯。如果以“会话”为顶层实体,会话被人为拉长了、话题漂移了,整条责任链就说不清楚。
所以我在项目里把顶层实体设计成问诊单(inquiry_order),所有东西都挂在问诊单下:图文消息、音视频会话、处方、支付订单、退款记录、评价,无一例外。会话只是问诊单生命周期里的一个交互载体。这个决定越早做越好,后期改会非常痛。
1.2 三类角色与权限边界
系统最少要包含三类角色:患者、医生、平台运营。
患者端的核心诉求是:发起问诊、支付、发消息、发起视频、查历史记录、补全电子病历。医生端核心诉求是:接诊、看患者资料、回复图文、发起/接听视频、开处方、结束问诊。平台运营端则关注医生管理、问诊单监控、异常介入和统计报表。
三类角色对应三套完全不同的权限边界。患者不能看到医生私密信息,医生只能看到当前接诊问诊单关联的患者资料,运营可以看全局但不能修改医患聊天内容。这块在设计接口时要注意:不能只靠前端菜单隐藏,后端接口按角色做鉴权,比如Spring Security里给每个接口配hasRole,或者自定义注解,别省这一步。
1.3 图文问诊和视频问诊在时序上完全不是一回事
这是项目初期最容易混淆的地方。图文问诊本质是异步消息系统,患者发一条消息后可能要等半小时、一小时甚至更久医生才回复,系统要处理“等待回复”“超时提醒”这类时序逻辑。
视频问诊则是强同步场景。医患双方约定某个时间同时在线,进入虚拟房间后做实时音视频交互,系统要处理迟到、缺席、中途断线、提前结束、录制留痕等事件。
两种问诊的服务端设计逻辑差异很大:图文靠消息队列和推送兜底,视频靠信令服务和媒体服务器保证实时性。所以数据模型里问诊单一定要有 type 字段区分图文/视频,后续从创建订单到状态机流转都会按类型走分支。
2. 技术选型:为什么视频走WebRTC、图文走消息推送
2.1 视频问诊为什么必须走WebRTC而不是自研推流
一开始团队里有人提议用传统RTMP推流方案,把医生和患者的画面推到流媒体服务器,再分发给对方。这套方案的延迟通常在3到5秒,用来做直播没问题,但放在医患实时对话场景里体验非常糟糕。你这边说完一句话,对方三四秒后才听到,往返延迟接近十秒,沟通成本太高。
我最后选的是WebRTC加SFU转发架构。WebRTC是浏览器原生支持的实时通信协议,自带音视频编解码、丢包重传、码率自适应和端到端加密协商,不需要用户装任何插件。两端各推一路流到SFU媒体服务器,由服务器做转发,每个端只和服务器建一条连接。这样避免了P2P直连穿透失败的风险,也方便做服务端录制和监控。
选型对比可以看这张表:
| 方案 | 延迟 | 穿透难度 | 服务端介入 | 适用场景 |
|---|---|---|---|---|
| WebRTC P2P直连 | 低 | 高,NAT打洞易失败 | 弱 | 双端网络条件理想 |
| WebRTC+SFU转发 | 低 | 低 | 强 | 一对一/一对多实时问诊 |
| RTMP推流播放 | 高 | 低 | 强 | 直播授课,不适用实时问诊 |
| 自研UDP协议 | 最低 | 极高 | 强 | 暂无必要,成本过高 |
做这个项目时我选了基于SFU的成熟媒体服务器作为网关,自己只写信令服务和业务回调。这样开发和交付周期可控,又保留了核心源码的自主性。
2.2 图文问诊的消息可靠性设计:在线推送加离线补偿
图文问诊消息链路不能只靠一个WebSocket连接就完事。真实场景里用户会锁屏、切后台、坐地铁断网、App被杀掉,消息随时可能发不到。
我采用的方案是:消息先写数据库,再通过WebSocket推送在线客户端;推不出去就落进待推送队列;客户端每次从后台回到前台时,调用增量拉取接口,按时间戳或消息序号同步所有未读消息。这个“写库优先、推送补偿”的思路和普通IM没什么本质区别,但问诊场景对消息完整性要求更高,一条医生的回复漏了可能导致患者反复追问,甚至延误反复咨询。
另外,消息顺序问题要在数据库层面解决。我给图文消息表加了自增消息序号,按问诊单粒度做排序;客户端展示时不依赖推送到达顺序,而是等消息拉全后按序号排序渲染,这样乱序问题彻底兜住。
2.3 一套服务端能同时撑起视频与图文的最小技术栈
梳理一下最终技术栈,给准备动手的人一个参考:
- 服务端框架:Java Spring Boot,工程结构清晰,生态完善,适合做这种多模块业务系统。
- 数据库:MySQL,存问诊单、用户、消息、处方等核心业务数据。
- 缓存:Redis,做接诊分布式锁、在线状态缓存、未读计数。
- 消息推送:自建WebSocket服务加消息队列解耦,在线实时通道,离线走客户端补拉。
- 音视频:WebRTC信令服务加SFU媒体服务器,浏览器/移动端原生支持,兼容性稳。
- 对象存储:存用户上传的问诊图片、病历附件、视频录制文件。
- 支付:第三方支付,患者支付完才能进入待接诊队列。
这套组合的好处是每个环节都有成熟的落地路径,不会因为某个中间件太冷门卡住进度。图文链路和视频链路共用同一套业务服务和数据模型,只是消息通道和音视频网关不同。
3. 核心数据模型与问诊状态机:源码里最值得抄的部分
3.1 核心数据表:从问诊单到电子病历
问诊平台的数据表比普通业务系统多了不少医疗语义字段。这里列几个最核心的表:
用户表 user:
| 字段 | 说明 |
|---|---|
| id | 用户ID |
| role | 角色:患者/医生/运营 |
| phone | 手机号 |
| name | 姓名 |
| id_card | 身份证号,加密存储 |
| status | 状态:正常/禁用 |
医生扩展表 doctor_profile:
| 字段 | 说明 |
|---|---|
| id | 医生用户ID |
| department | 科室 |
| title | 职称 |
| license_no | 执业证书编号,加密存储 |
| verify_status | 资质审核状态 |
| service_status | 接诊状态:上线/离线/停诊 |
问诊单表 inquiry_order:
| 字段 | 说明 |
|---|---|
| order_no | 问诊单号,全局唯一 |
| patient_id | 患者ID |
| doctor_id | 医生ID,接诊后写入 |
| type | 图文/视频 |
| status | 状态机字段,见下节 |
| amount | 金额 |
| pay_status | 支付状态 |
| start_time | 问诊开始时间 |
| end_time | 问诊结束时间 |
图文消息表 message:
| 字段 | 说明 |
|---|---|
| id | 消息自增ID |
| inquiry_id | 问诊单ID |
| sender_role | 发送方角色 |
| content_type | 文本/图片/语音/病历附件 |
| content | 内容正文或文件URL |
| seq | 消息序号,按问诊单自增 |
| created_at | 发送时间 |
视频会话表 video_session:
| 字段 | 说明 |
|---|---|
| id | 会话ID |
| inquiry_id | 问诊单ID |
| room_id | 房间号 |
| start_time | 实际开始时间 |
| end_time | 实际结束时间 |
| duration | 时长 |
| record_url | 录制文件地址 |
| status | 等待中/进行中/已结束 |
还有处方表 prescription、操作审计表 audit_log。处方表会关联问诊单ID和医生ID,审计表记录每次关键操作,这两张表往后会经常被问到。
3.2 问诊状态机:流量入口、无人接诊与超时关闭
问诊单状态是整个系统的灵魂。我梳理出的状态流转如下:
| 当前状态 | 触发动作 | 下一状态 |
|---|---|---|
| 待支付 | 支付成功 | 待接诊 |
| 待支付 | 超时未支付 | 已取消 |
| 待接诊 | 医生接诊 | 问诊中 |
| 待接诊 | 超时无医生接诊 | 已取消,自动退款 |
| 问诊中 | 医生发送结束消息 | 待评价 |
| 问诊中 | 患者主动结束 | 待评价 |
| 待评价 | 评价完成 | 已结束 |
| 已取消 | 无 | 终态 |
图文问诊在“问诊中”里还可以细分为等待医生回复和等待患者回复,这个状态用于驱动提醒策略。我建议在问诊单上存 current_ask_role,表示当前需要哪一方答复,方便定时任务自动催回复。视频问诊不用这么细,双方都在房间里就是进行中,一方退出就触发结束确认。
为什么状态机值得单独写?因为问诊平台所有异常情况都要靠状态字段接住。患者付了款医生一直不接诊怎么办?定时任务扫到待接诊单超时,自动发提醒再等待,再超时就取消退款。医生问诊中途掉线怎么办?视频会话状态和问诊单状态分离,先记录视频中断,再让医生重新入会,而不是直接把问诊单关掉。状态不清晰,这些分支逻辑会全部堆在代码里,越写越乱。
3.3 并发接诊时如何防止一个问诊单被两个医生同时抢走
问诊单进入待接诊状态后,会同时推送给多个同科室医生。这时候最直接的问题就是两个医生同时点了接诊,后端两个请求都查到状态还是待接诊,然后都更新成问诊中,一个患者被两个医生接走,事故。
解法分两层。第一层用Redis分布式锁锁住问诊单ID,抢到锁的医生才能进入接诊流程,处理完释放;第二层在数据库更新时加上乐观锁条件,执行更新语句时强制带上 status 等于待接诊的条件,影响行数为0说明已经被别人抢先,直接返回失败。
伪代码如下:
// 接诊竞争核心逻辑 boolean locked = redisLock.tryLock("inquiry:accept:" + inquiryId); if (!locked) { throw new BizException("问诊单正在被其他医生处理"); } try { int rows = inquiryOrderMapper.updateStatus( inquiryId, InquiryStatus.CONSULTING, InquiryStatus.WAITING_ACCEPT, // where条件 doctorId ); if (rows == 0) { throw new BizException("问诊单已被接诊"); } } finally { redisLock.unlock("inquiry:accept:" + inquiryId); }两层都做了才能万无一失。只靠分布式锁,万一锁过期但事务没提交完,依然可能出问题;只靠数据库条件更新,业务上想做一些前置校验会不太方便。搭配使用是我实测下来最稳的组合。
4. 分步搭建:从患者发起问诊到医生接诊的完整链路
4.1 图文问诊从发起到医生回复的完整代码链路
图文问诊的完整主链路我拆成七个环节:
- 患者选择科室和医生,提交病情描述和图片附件
- 系统创建问诊单,状态为待支付,生成支付二维码
- 支付成功回调后,状态机推进到待接诊
- 系统把问诊单推送至对应科室医生列表
- 医生接诊,状态变更为问诊中
- 医患双方通过WebSocket实时收发图文消息
- 医生点击结束问诊,患者进入评价流程
创建问诊单时要注意业务校验,不能简单insert一张表。需要判断患者是否实名认证、所选医生是否在线接诊、同一时间是否已有未结束的问诊单。这些校验放在Service层统一处理,避免绕过接口直接调用Mapper。
public InquiryOrder createInquiryOrder(CreateOrderRequest req, Long patientId) { // 校验患者实名 if (!userService.isVerified(patientId)) { throw new BizException("患者未实名认证"); } // 校验医生可接诊 DoctorProfile doctor = doctorProfileMapper.selectById(req.getDoctorId()); if (doctor == null || !DoctorStatus.ONLINE.equals(doctor.getServiceStatus())) { throw new BizException("医生当前不在线"); } // 校验重复问诊 long activeCount = inquiryOrderMapper.countActive(patientId); if (activeCount > 0) { throw new BizException("您有未结束的问诊单"); } // 创建问诊单,状态置为待支付 InquiryOrder order = new InquiryOrder(); order.setOrderNo(generateOrderNo()); order.setPatientId(patientId); order.setDoctorId(req.getDoctorId()); order.setType(req.getType()); order.setStatus(InquiryStatus.WAITING_PAY); order.setAmount(req.getAmount()); inquiryOrderMapper.insert(order); return order; }消息收发这层,我建议把所有图文消息先写库再推送。WebSocket连接只承担“实时通知”职责,客户端收到通知后从消息接口拉增量数据,确保最终展示和数据库完全一致。
4.2 视频问诊的房间创建、信令交换与媒体协商
视频问诊比图文多了一条实时音视频链路。我按照以下步骤实现:
- 问诊单进入问诊中,医生和患者都进入“视频准备页”
- 任一方点击“开始视频问诊”,客户端请求服务端创建视频会话
- 服务端生成room_id,写入video_session,状态置为等待中
- 服务端通过WebSocket把room_id推给对端
- 双端各自连接SFU媒体服务器,发布本地音视频流
- 双端通过信令通道交换WebRTC的offer、answer和ICE候选
- 媒体协商成功,双方画面接通,video_session状态改为进行中
信令服务本质上就是一个小型WebSocket服务,负责转发SDP和ICE信息。需要特别注意,信令服务和业务服务用同一套鉴权,确保只有问诊单里关联的医生和患者能加入这个房间。
房间加入要校验身份,这一点也是很多人容易漏的。我刚做时只校验了登录态,没有校验操作者是否属于这个问诊单,结果只要知道房间号就能乱入,后来加了一层权限校验才堵上。
4.3 支付下单与超时处理:和普通商城订单的差别
支付部分和商城订单逻辑类似,但有两个差异点必须处理。
第一,问诊服务属于先付款后服务。支付成功之前,问诊单不占用医生资源,因此不需要锁定医生。支付成功回调后,问诊单自动进入待接诊队列。这样如果患者迟迟不付款,医生端的列表不会被无效单刷屏。
第二,问诊单有严格超时策略。患者待支付超过15分钟自动关闭;待接诊超过5分钟系统提醒一次,超过15分钟仍无医生接诊则自动取消并原路退款。这个策略用定时任务实现,每分钟扫描一次待支付和待接诊状态的问诊单,对超时单做批量状态流转和退款。
// 定时扫描超时问诊单 @Scheduled(cron = "0 * * * * ?") public void closeTimeoutOrders() { List<InquiryOrder> payTimeoutOrders = inquiryOrderMapper .selectByStatusBefore(InquiryStatus.WAITING_PAY, nowMinusMinutes(15)); for (InquiryOrder order : payTimeoutOrders) { inquiryOrderService.cancelOrder(order, "患者超时未支付"); } List<InquiryOrder> acceptTimeoutOrders = inquiryOrderMapper .selectByStatusBefore(InquiryStatus.WAITING_ACCEPT, nowMinusMinutes(15)); for (InquiryOrder order : acceptTimeoutOrders) { inquiryOrderService.cancelAndRefund(order, "无医生接诊"); } }定时任务的并发问题也要考虑到。多实例部署时同一批问诊单可能被多个实例重复扫描,我给扫描任务加了一个分布式锁,抢到锁的实例才执行后续逻辑,避免重复关单和重复退款。
5. 实测逃不掉的几个坑:弱网、消息丢失、权限边界与合规留痕
5.1 弱网环境下视频卡顿与码率自适应
视频问诊实测下来,最影响体验的不是服务器性能,而是移动端网络环境的剧烈波动。患者在诊室、地铁、家里分别对应完全不同的网络质量,如果视频参数固定,要么画质模糊,要么卡顿严重。
WebRTC自带拥塞控制和码率自适应,但要在工程上把它调好还是有几个注意点。第一,开启simulcast,让发送端同时编码多个分辨率的视频流,接收端根据网络状况选择接收哪一层;第二,设置合适的码率上下限,不要完全交给默认策略,避免网络抖动时码率一下降到极低;第三,处理网络切换场景,手机从WiFi切到4G会导致ICE连接中断,需要监听网络变化触发ICE重协商。
我额外发现一个移动端的坑:App切到后台时浏览器会暂停定时器和视频采集,导致对端看到画面冻结。这个问题需要前端在页面隐藏时发送“暂停视频”信令,恢复显示时重新发流,而不是单纯依赖操作系统自动处理。
5.2 消息丢失与乱序:图文问诊的推送补偿
图文消息模块踩过最深的一个坑出现在弱网重连场景。客户端断网期间,医生连续发了好几条消息,服务端WebSocket推送必然失败。如果只依赖推送,这些消息就永久丢了。
我的做法是双通道兜底。服务端每收到一条消息,除了推送给在线端,还会把未读消息数累加到Redis;客户端重连成功后,先拉取未读计数,再按消息序号增量拉取所有缺失消息。这里有一个细节:客户端判断缺失消息不能只看当前拉到的最后一条ID,要按问诊单请求服务端返回自某一序号之后的所有消息,然后本地做去重合并,否则会出现数据空洞。
还有消息编辑和撤回功能,问诊场景下我建议直接做“只可撤回不可编辑”,且撤回要留痕。因为医疗沟通内容具备证据属性,撤回操作本身要进审计日志,避免医患双方私下篡改沟通记录。
5.3 权限边界、数据加密与审计留痕
最后说合规和安全,这部分不展开讲具体政策条文,因为每个地区要求不同,但技术上有些能力是通用的,必须在搭建初期就把口子留好。
患者实名认证和医生资质校验是基础能力,实名信息如身份证号、医生执业证书编号在数据库里不能明文存储,建议用加密算法落库,展示时脱敏。日志系统要记录每一次问诊单创建、接诊、消息发送、视频接通、处方开具和退款操作,操作人、操作时间、操作前后状态都要可追溯。音视频录制文件需要加密存储,并且设置访问有效期,不能随便一个URL就能拉走。
我在项目里单独设计了audit_log表和加密工具类,所有关键操作统一通过一个审计切面记录。这个设计早期投入不大,但后期应对资质审查时省了大力气。没有审计能力的问诊系统,在真实生产环境里是没有可信度的。
另外,敏感接口必须做频控。比如发送消息、发起视频、获取验证码这些接口,按用户和问诊单粒度做限流,防止有人拿问诊单ID遍历拉取数据,也防止恶意脚本刷消息骚扰医生。
做完这个系统我最大的体会是:视频和图文两条链路本身都不难,真正考验架构能力的,是所有边界情况的兜底设计——问诊单超时、并发接诊、消息乱序、网络切换、权限越权,每一个都是真实线上会遇到的场景。如果再让我重新搭一遍,我会把状态机的设计和审计埋点放到优先级最前面,先把业务骨架打稳,再填充音视频和消息这些技术亮点。
给准备动手的团队一个建议:第一版先只做图文问诊,把问诊单状态流转和消息可靠性跑顺,第二版再引入WebRTC视频问诊。这样两条链路的复杂度不会同时压上来,团队也更容易建立起对这套业务模型的理解。问诊平台的源码从来不是看热闹的工程,每一个模块背后都是一堆真实业务规则的凝结,希望这篇拆解能帮你少踩几个坑。