去年接了个模拟项目帮某医疗信息化团队从零搭一套在线问诊系统源码级的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打洞易失败弱双端网络条件理想WebRTCSFU转发低低强一对一/一对多实时问诊RTMP推流播放高低强直播授课不适用实时问诊自研UDP协议最低极高强暂无必要成本过高做这个项目时我选了基于SFU的成熟媒体服务器作为网关自己只写信令服务和业务回调。这样开发和交付周期可控又保留了核心源码的自主性。2.2 图文问诊的消息可靠性设计在线推送加离线补偿图文问诊消息链路不能只靠一个WebSocket连接就完事。真实场景里用户会锁屏、切后台、坐地铁断网、App被杀掉消息随时可能发不到。我采用的方案是消息先写数据库再通过WebSocket推送在线客户端推不出去就落进待推送队列客户端每次从后台回到前台时调用增量拉取接口按时间戳或消息序号同步所有未读消息。这个“写库优先、推送补偿”的思路和普通IM没什么本质区别但问诊场景对消息完整性要求更高一条医生的回复漏了可能导致患者反复追问甚至延误反复咨询。另外消息顺序问题要在数据库层面解决。我给图文消息表加了自增消息序号按问诊单粒度做排序客户端展示时不依赖推送到达顺序而是等消息拉全后按序号排序渲染这样乱序问题彻底兜住。2.3 一套服务端能同时撑起视频与图文的最小技术栈梳理一下最终技术栈给准备动手的人一个参考服务端框架Java Spring Boot工程结构清晰生态完善适合做这种多模块业务系统。数据库MySQL存问诊单、用户、消息、处方等核心业务数据。缓存Redis做接诊分布式锁、在线状态缓存、未读计数。消息推送自建WebSocket服务加消息队列解耦在线实时通道离线走客户端补拉。音视频WebRTC信令服务加SFU媒体服务器浏览器/移动端原生支持兼容性稳。对象存储存用户上传的问诊图片、病历附件、视频录制文件。支付第三方支付患者支付完才能进入待接诊队列。这套组合的好处是每个环节都有成熟的落地路径不会因为某个中间件太冷门卡住进度。图文链路和视频链路共用同一套业务服务和数据模型只是消息通道和音视频网关不同。3. 核心数据模型与问诊状态机源码里最值得抄的部分3.1 核心数据表从问诊单到电子病历问诊平台的数据表比普通业务系统多了不少医疗语义字段。这里列几个最核心的表用户表 user字段说明id用户IDrole角色患者/医生/运营phone手机号name姓名id_card身份证号加密存储status状态正常/禁用医生扩展表 doctor_profile字段说明id医生用户IDdepartment科室title职称license_no执业证书编号加密存储verify_status资质审核状态service_status接诊状态上线/离线/停诊问诊单表 inquiry_order字段说明order_no问诊单号全局唯一patient_id患者IDdoctor_id医生ID接诊后写入type图文/视频status状态机字段见下节amount金额pay_status支付状态start_time问诊开始时间end_time问诊结束时间图文消息表 message字段说明id消息自增IDinquiry_id问诊单IDsender_role发送方角色content_type文本/图片/语音/病历附件content内容正文或文件URLseq消息序号按问诊单自增created_at发送时间视频会话表 video_session字段说明id会话IDinquiry_id问诊单IDroom_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() { ListInquiryOrder payTimeoutOrders inquiryOrderMapper .selectByStatusBefore(InquiryStatus.WAITING_PAY, nowMinusMinutes(15)); for (InquiryOrder order : payTimeoutOrders) { inquiryOrderService.cancelOrder(order, 患者超时未支付); } ListInquiryOrder 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视频问诊。这样两条链路的复杂度不会同时压上来团队也更容易建立起对这套业务模型的理解。问诊平台的源码从来不是看热闹的工程每一个模块背后都是一堆真实业务规则的凝结希望这篇拆解能帮你少踩几个坑。