1. 为什么做门诊系统:医院候诊的混乱场景与技术选型真相
1.1 没有信息化前门诊有多难管
做这个项目之前,我在医院信息科蹲过两周实地调研。三甲医院的门诊大厅,早高峰时段的真实状态是:人工挂号窗口排长队,自助机前人挤人,导诊台被围得水泄不通,医生诊室门口全是站着等叫号的患者和家属。护士每隔十分钟就得扯着嗓子喊"XXX号进来",诊室里医生一边问诊一边手工写纸质处方,写完还要患者拿着单子去收费处排队,再回药房交单子,药房药师对着手写处方逐字辨认字迹。一圈流程走下来,一个普通感冒患者看完病至少需要两个小时,其中真正面诊的时间不超过十分钟。
这就是我做医院门诊信息管理系统的原因。核心目标就三个:把挂号的流量从窗口分流到线上、把诊室的门诊秩序从人工喊号变成数字叫号、把处方从手写变成电子化流转。这个项目采用的是 JAVA 后端 + Vue 前端的组合,Node.js 则承担前端构建环境的重任。整套系统上线之后,我亲测了一位普通患者的完整就诊链路:在线挂号 → 到院签到 → 候诊区听叫号 → 诊室看诊 → 医生开电子处方 → 手机支付 → 药房扫码取药,全流程耗时从原来的两小时压到了四十分钟左右,候诊区的嘈杂程度明显下降,护士站也不用再专门派一个人盯叫号了。
1.2 "JAVA后端+Vue前端+Node.js构建"到底谁负责什么
很多刚接触这个项目的人一开始会被标题搞晕:又是 JAVA 又是 Vue 又是 Node.js,到底谁是谁?我梳理一下。
后端是 JAVA,具体用的是 Spring Boot + MyBatis-Plus + MySQL + Redis,负责所有业务逻辑、数据持久化、权限校验和接口输出。门诊系统这种业务规模,Spring Boot 的开发效率、生态成熟度和招人难度都是最优解,这一点没有悬念。
前端是 Vue,用的是 Vue 3 + Element Plus + Vite。Vue 负责页面渲染、交互逻辑、状态管理。患者端、医生端、药房端、管理端,四套界面全部由 Vue 承载。Vue 3 的组合式 API 在处理复杂业务表单和分角色页面时,比 Vue 2 的选项式 API 要舒服得多。
那 Node.js 在这里的角色是什么?它是前端工程的构建与运行基础。Vue 项目要跑起来,开发环境需要 Node.js 提供 npm 包管理能力,生产环境需要 Node.js 来执行构建脚本打包成静态资源,再由 Nginx 托管。换句话说,你看到的 Vue 页面代码,在浏览器里运行之前,必须经过 Node.js 环境下的构建环节。这就是项目标题里出现 Node.js 的原因。
1.3 PHP为什么不在最终技术栈里
PHP 是我在前期技术调研时认真考虑过的备选项。说实话,PHP 做 Web 系统确实快,Laravel 框架的 ORM 和模板引擎也很成熟。但在门诊这个场景下我最终没有选它,原因是多方面的。
第一是并发模型。门诊挂号的高峰是早上的七点半到九点,这个时段的请求密度远高于普通管理系统,PHP 的短连接模型的扩展成本比 JAVA 高。第二是团队维护成本。这套系统后续要不断集成新的医保对接接口、检查报告回传、排队叫号硬件联动,JAVA 体系在对接医院现有系统时标准更多。第三是安全合规。医院系统对数据审计、权限管理、操作留痕有严格需求,Spring Security + JWT 这套组合的成熟度在我的认知里是最稳的。PHP 不是不好,而是"适合"这个词在医疗场景里很残酷,选错一次后面几年都在还债。
2. 从挂号到取药:系统核心业务流程与功能模块拆分
2.1 五条核心业务线的闭环设计
门诊信息管理系统表面上看着不复杂,实际上线之后会发现它同时承载着五条业务流的闭环,任何一条断了都会直接影响医院正常运转。
第一条是挂号流。患者在线或者窗口挂号,系统扣减号源,生成就诊记录,推送候诊队列。第二条是接诊流。医生根据候诊队列按顺序叫号,问诊后录入主诉、诊断、医嘱,结束本次就诊后患者进入付费环节。第三条是处方流。医生录入的诊断结果和药品明细自动生成电子处方,药房端实时感知待配药列表。第四条是收费流。患者对处方单发起支付,支付成功后药房端该处方的状态从"待配药"变成"待取药"。第五条是管理流。管理端实时查看全院每个科室的候诊人数、医生接诊量、平均就诊时长、药品消耗排名。
设计的时候我特别注意了状态机的一致性问题。比如退号场景,患者在线挂了号但人没来,过了半小时要退号,这个请求会从挂号服务一直到排班表释放号源,再到患者端取消记录,链路很长,如果中间某一步失败,就会出现钱退了但号源没释放的脏数据。所以我在挂号记录里加了 status 字段(0待就诊、1已就诊、2已取消、3已退费),每次状态变更都走同一个 Service 方法,用事务保证七张关联表要么全部成功要么全部回滚。
2.2 门诊系统与普通管理系统最大的区别:状态机
普通的管理系统,比如商品订单、文章发布、用户管理,状态流转是"点一下按钮换个状态",线性且简单。门诊系统不一样,它的状态流转是"多条线并发漂移"。
举个例子,一张挂号单同时被三个系统观察着:患者端关心它是否有效、是否已完成;医生端关心它的候诊顺序;管理端关心它的诊室分配和医生工作量统计。同一张单子在不同视角下展示的状态名都不一样,但底层状态值必须是同一个。
我在后端统一维护了一个枚举类 VisitStatusEnum,所有模块的状态判断都走这个枚举,禁止任何模块自己写"1"或"2"的魔法数字。前端也维护了一份对应的 statusMap,接口返回的是状态码,前端通过字典翻译成文案。这样一来,返号、过号、转诊这类异常状态,前后端永远只有一个真相。
2.3 三种角色三种视图:医生、护士、药房,界面完全不同
一个特别容易做崩的地方是:很多人都以为门诊系统把页面做一套,按角色隐藏按钮就能上线。实际上,医生端、护士端、药房端的界面逻辑完全是三种物种。
医生端是"高密度表单操作",一个上午要看四十个号,界面必须支持纯键盘操作,处方录入要支持模糊搜索药品名、常用药收藏、历史诊断一键复制,界面花哨反而影响速度。护士端是"队列调度中心",核心诉求是实时看到候诊列表、过号和插队的处理面板。药房端是"任务清单模式",药师最关心的是待配药列表按时间排序、批量确认、药品库存预警。
四套界面的数据都来自同一个后端接口,但组装的维度完全不同。我在 Vue Router 里做了动态路由,根据登录角色加载不同的菜单和页面组件,医生的路由表里根本没有药房页面的路径,这样即使前端代码被拿到,也无法通过改 URL 的方式越权访问页面。后端接口层再做一层角色鉴权,双保险。
3. Spring Boot后端:挂号并发与号源锁的设计细节
3.1 号源扣减:为什么不能先查再减
病史信息系统里最典型的高并发场景就是号源扣减。比如某专家上午放出三十个号,七点半放号瞬间,假设有两百人同时点击,如果你的代码写成"先 select 剩余号源,判断大于0,再 update 减一",那几乎百分之百会超卖。原因很简单:你查到的"剩余号源"在下一秒可能就已经被另一台服务器上的另一个线程改掉了。
我在项目实施中见过有人用"select for update"行级锁解决这个问题,但这会让数据库的连接等待变得很长,高峰期可能直接拖垮 MySQL。最后我采用了一个更轻量的方案,把号源数量的校验和扣减合并成一条原子 SQL:
UPDATE doctor_schedule SET remaining_count = remaining_count - 1 WHERE id = #{scheduleId} AND remaining_count > 0 AND status = 1这条 SQL 能返回影响行数。等于1说明扣减成功,等于0说明号源已经没了或者排班已停用。MyBatis-Plus 的 update 方法可以传 wrapper 自定义条件,实际编码不复杂。关键是这个设计把"查询+判断+更新"三个步骤压缩成了一个原子操作,在高并发下不会出现竞态条件。
3.2 Redis预扣+MySQL最终落库的方案
单纯用数据库原子 SQL 能扛住一般的并发,但遇到那种几分钟内涌进上千请求的"网红专家号",数据库单表写压力还是大。我在这个项目里又加了一层 Redis 预扣减。
流程是这样的:用户点击挂号 → 后端先走 Redis 的 Lua 脚本扣减库存 → 扣减成功则异步写入 MySQL 挂号记录 → 挂号记录创建成功后设置一个十五分钟的支付超时时间 → 用户在十五分钟内完成支付,状态变成"已挂号";超时未支付则回补 Redis 库存,同时把 MySQL 里的数据标记为"已取消"。
Redis 扣减用的是 Lua 脚本,因为要保证"检查数量 + 扣减"的原子性。脚本逻辑很短大概二十行,核心就是 KEYS[1] 是号源 key,ARGV[1] 是扣减数量,先 GET 当前值,如果小于扣减数量就返回0,否则 DECR 并返回1。这套方案的好处是把热点的并发压力全部挡在 Redis 层,MySQL 只接收真正创建成功的订单,写入压力骤减。
有一个坑必须提醒:Redis 缓存的数据必须与 MySQL 数据库里的真实号源量保持最终一致。我写了一个定时任务,每五分钟跑一次"Redis 里已扣减总量 + DB 里有效剩余量 = 排班总数"的对账逻辑,如果发现不一致就按 MySQL 为准做补偿修正。这个对账任务在前期没上线的时候觉得是多余的,上线第二周就真的救了一次场,Redis key 过期策略和业务恢复逻辑冲突导致库存少放了两成。
3.3 JWT令牌与门诊系统常见的越权场景
门诊系统的安全设计和普通商城系统有个显著差异:医院非常在意"谁能看谁的什么信息"。患者只能看自己的挂号记录和处方,医生只能看自己的患者列表,管理端可以看全院数据但不能看患者敏感信息明文。
我用的是 JWT 作为登录态载体。用户登录成功后,后端签发一个 token,里面只放 userId、roleId、realName,过期时间设为两个小时。每个受保护接口的请求都带着 Authorization 头,后端通过拦截器解析 token 并校验过期时间。
越权防护的重点是接口参数校验。很多系统的接口设计成 GET /visit/detail?id=123 这种形式,患者登录后把 id 改成别人的挂号单编号,就能看到别人的病情记录。我在前端路由里隐藏了入口,后端在真正查询前还有一个"数据归属校验",从 token 里取 userId,再通过 Redis 查该 userId 是否有对这单 data 的访问权限。这个动作不能省,我在压测阶段就是用这种方式把系统里所有"按主键直接查"的危险接口筛查了一遍。
4. 数据库没那么简单:门诊系统里最关键的几张表
4.1 patient_visit(就诊主表):整个系统的"车头"
门诊系统看似模块很多,但所有模块追根溯源都会落到一张主表上:就诊记录表 patient_visit。挂号创建一条记录,医生接诊更新这条记录,处方关联这条记录,收费结算关联这条记录。我把它比作一列火车头的历史,车厢是诊断、处方、收费明细,车头走了,车厢数据就都悬空了。
字段设计上我分了四组。第一组是身份信息,patient_id、patient_name、patient_phone,手机号只做冗余存储,因为取号和短信通知需要高频使用,每次联表查患者主表反而慢。第二组是就诊归属,schedule_id、doctor_id、department_id、visit_time,这是为了管理端做工作量统计时不用三表联查。第三组是状态字段,status、pay_status、queue_number。第四组是时间戳,create_time、update_time、finish_time。
这里要提醒一个字段冗余的度的问题:冗余能提升查询性能,但冗余太多会导致数据一致性维护成本变高。我的原则是只冗余"只读不常改"的字段,凡是有更新逻辑的字段一律不冗余。patient_id 对应的手机号、姓名属于身份信息,几乎不变,可以冗余;schedule_id 对应的医生名和科室名如果医生发生转科,历史数据不追溯,也可以冗余。
4.2 排班表与号源表的联动设计
门诊系统的号源控制,核心是排班表(doctor_schedule)和号源流水表(schedule_slot)的设计。排班表定义"某医生某天某个时段出诊,放号总量是多少",号源流水表拆分到每个具体号源。
我没有把号源直接存成"剩余数量"一个字段,而是拆成独立表。原因是退号和取消挂号场景需要知道"谁占了哪个具体号的额度"。一个号源记录对应一个患者的就诊资格,初始状态是"可用",被抢占后变成"锁定",支付完成后变成"已占用",退号后变成"可回收"。这样就能精确控制同一时间段内同一排班下,已被占用的号源不会二次发放。
数据库层面我给 schedule_slot 表加了唯一索引 (schedule_id, slot_no),因为同一个排班下号源编号不允许重复。再配合前文说的那个原子更新 SQL,在数据层面彻底堵住了超发漏洞。
4.3 处方明细的拆分存储逻辑
电子处方是门诊系统中药房业务的核心数据载体。处方主表(prescription)存一笔业务单,字段包括 visit_id、doctor_id、total_amount、status。处方明细表(prescription_item)存药品明细,字段包括 drug_id、drug_name、specification、quantity、unit_price、dosage_usage。
这里有个容易掉坑的地方:处方明细里的 drug_name 和 unit_price 必须做冗余快照。原因是药房药品价格可能会调整,如果处方明细只关联 drug_id,等到患者拿处方去支付时却发现药品已经调价,就会出现患者看到的金额和最终支付金额不一致的纠纷。冗余快照的意思是,处方生成的那一刻把当时的价格和药品名一起存进去,后续无论药价怎么变,这笔处方都按开单时价格结算。这是医院系统的硬性要求,从第一天设计就该这么做。
还有一个细节是 dosage_usage(用法用量)字段,医生开处方时写的"一天三次,一次一片"这类医嘱,必须作为处方明细的一部分完整存储。药房发药时把这个字段原样打印到取药凭条上,患者回家后能直接看到用药说明。如果这个字段在数据库里丢失,药房就得逐张处方去问医生,整个流程就卡死了。
5. Vue前端与Node.js构建环境:从安装到上线的完整链路
5.1 Node.js装完必须做的三件事和管理员权限问题
如果你的机器是 Windows,装 Node.js 多半会踩到那个经典问题,就是 npm 命令直接报错:
npm : 无法加载文件 D:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本
这个报错的本质是 Windows PowerShell 的默认执行策略是 Restricted,不允许运行任何 .ps1 脚本文件,而 npm 的可执行文件正好是 .ps1。解决办法不是重装 Node.js,而是给当前用户放开脚本执行权限。
我的操作方式是:以管理员身份打开 PowerShell,执行
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned这条命令的意思是"本机创建的脚本可以运行,下载的脚本必须经过签名认证才能运行"。设置完之后可以通过 Get-ExecutionPolicy 验证结果,显示 RemoteSigned 就正常了。
装完 Node.js 之后我还做了三件小事,在这里一并列出来。
第一,把 npm 的全局安装目录和缓存目录迁移到非系统盘。默认情况下 npm 的全局包装在 C 盘 user 目录下,等项目越装越多会悄悄占满系统盘。我在 D 盘建了 nodejs_global 和 nodejs_cache 两个目录,然后执行:
npm config set prefix "D:\nodejs_global" npm config set cache "D:\nodejs_cache"第二,配置镜像源。npm 默认从国外源下载包,网络不佳的时候装个依赖能等好几分钟。我配的是淘宝镜像,命令如下:
npm config set registry https://registry.npmmirror.com配置好之后执行 npm config get registry 确认生效,装依赖的速度提升非常明显。
第三,用 nvm 管理 Node.js 版本。如果以后要同时维护多个前端项目,不同项目的 Node 版本要求可能不一样,nvm-windows 可以随时切换版本,比卸载重装省心太多。
5.2 开发环境代理与生产环境部署:Vite 的跨域破局
前端开发环境遇到最大的坑是跨域问题。前端跑在 localhost:5173,后端接口跑在 localhost:8080,浏览器直接发请求会被 CORS 拦截。解决方式是在 Vite 的配置文件 vite.config.js 里配置开发服务器代理:
server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } }这样设置之后,前端请求 /api/user/login 会被 Vite 开发服务器转发到 http://localhost:8080/user/login,而浏览器里看到的请求源是同源的,不会触发跨域拦截。
生产环境我换了个思路。因为前端最终的产物是一堆静态文件,我直接让 Nginx 托管前端文件,并在同一个 Nginx 里配置反向代理,把 /api/ 路径的请求转发到后端的 Java 服务。这样一个 Nginx 同时负责静态资源服务和接口转发,对外只暴露一个域名,没有跨域问题,也方便以后挂 HTTPS 证书。
有个细节值得提一下:接口路径统一加 /api 前缀这个约定,一定要在项目一开始就定好。我在开发阶段踩过一次坑,前端请求路径和后端接口路径不一致,代理转发时重写规则写错了,所有接口响应 404,排查了整整半天才发现是路径多了一层。
5.3 前端路由权限和菜单动态渲染
门诊系统的四个角色(患者、医生、护士、管理员)看到的功能菜单差异很大。Vue Router 的动态路由配置是整个前端权限控制的枢纽。
登录成功后,前端拿到的响应里有当前用户的角色信息和该角色的菜单权限列表。我根据权限列表调用 router.addRoute() 动态注册对应角色的路由模块。这样做的效果是:患者的 URL 地址栏里即使手动输入 /admin/user,路由也不认识这个路径,会直接跳转到 404 页面,而不是显示内容。
菜单的动态渲染用的是 Element Plus 的 el-menu 组件,通过递归组件遍历菜单树。后端接口返回的菜单结构就是一棵树,每个节点有 id、parentId、title、path、icon 字段,前端只需要两个组件,一个是根菜单,一个是递归子菜单,就能完整渲染出任意层级的侧边栏。
这套方案做完之后,我最大的感受是:前端权限控制的重点不在"看不见入口",而在"进不去路由"。菜单隐藏是用户体验问题,路由守卫和动态注册才是真正的安全问题。
6. 开发中踩过的坑与实际运行效果
6.1 半天查不出原因的白屏问题:位置错误的容器挂载
项目开发到一半时遇到过一个诡异问题:前端页面在灵机测试时正常,但部署到测试环境用 Nginx 之后,刷新页面就白屏,直接看 Network 面板发现所有 js css 资源都 404。
排查过程是这样的。先看 Nginx 配置,静态资源路径指向 dist 目录,看起来没问题。然后发现资源请求的 URL 带的是绝对路径 /assets/xxx.js,而项目部署在子目录下,域名后面还挂着一层路径前缀。找到原因了:Vue 3 默认的 base 是 '/',会把所有静态资源的 URL 拼成站点的根路径,站点如果部署在域名根路径没问题,部署在子路径就要显式设置 base。
解决方法是在 vite.config.js 里加一行:
export default defineConfig({ base: './', })这样打包后资源引用全部变成相对路径,子目录部署也不会白屏。
这个坑的麻烦之处在于它只会在部署环境暴露,开发环境永远测不出来。所以我后来总结出一个流程:任何 Vue 项目打包上线前,先把 dist 目录在一个空目录里起个静态服务测试刷新,白屏问题几乎都能提前暴露。
6.2 同一时段多科室并发的数据隔离问题
门诊系统上线一周后,运营反馈一个问题:早上八点半到九点之间,内科和外科的两个热门专家同时放号,后台出现了同一个患者同时抢到两个科室号源的情况,而医院的规则是同一时段只能挂一个号。
排查后发现,我的 Redis 预扣减用的是"患者维度 + 时段维度"达到了隔离,排班表本身的并发检查放在了数据库的原子 SQL 层,但那个检查只判断了号源剩余量,没有判断"当前患者在这个时段是否已经挂号"。两个科室的号源是两个不同的排班记录,每条记录只查自己 ID 的剩余量,自然不会相互感知。
解决办法是在 Redis 里维护一个"患者-时段-已挂号"的约束。患者点击挂号的瞬间,先用 Redis 的 SETNX 命令写入一个 key,key 的构成是 patientId + visitDate + sessionType,如果 SETNX 失败说明该患者在这个时段已经挂了号,直接拒绝。这样就把"同一患者同一时段多科室挂号"的问题在入口处拦截了下来。
这次问题的教训是:数据隔离性的设计不能只盯着单表事务,跨表、跨业务线的并发场景必须在设计阶段就想清楚约束条件,等运营反馈问题再补,往往要动核心表结构。
6.3 日志留痕与门诊系统的审计要求
最后说一个有点经验色彩但很重要的事情:门诊系统的日志设计。
医院的信息系统对操作留痕的要求比普通系统严格得多。谁在几点几分修改了哪条处方、谁在几点几分退了哪个患者的号、谁在几点几分调整了排班号源数量,这些都是需要完整记录的。我用的方案是 MongoDB 单独存操作日志,而不是把日志全塞进 MySQL 的日志表。因为操作日志的写入频率高但查询频率低,和核心的业务数据混在一个库里会影响主业务的数据库性能。
操作日志的内容我统一用一个切面来做,自定义注解 @OperationLog 标注在 Controller 方法上,切面里解析注解里的模块名和操作类型,再通过请求上下文拿到当前登录人信息,异步写入日志库。查询日志的页面也做了,管理端可以按操作人、操作时间、模块筛选。
这套系统从调研、开发到上线差不多花了三个月,期间遇到了不少"文档里教不会"的问题,比如上面那几个。后来在门诊实际运行,每天承载一千多笔挂号,高峰期并发也不焦虑了。如果你也在做同类系统,我建议你重点把号源扣减、处方状态机、角色权限这三块想清楚,这三块稳了,门诊系统的骨架就稳了。业务功能往后加,都只是在这三块的地基上添砖加瓦而已。