聊到“医院挂号预约管理系统”,可能很多人第一反应是:这不就是一张预约表加上增删改查吗?去年我帮一个街道卫生服务中心做这套系统时,也差点这么想。等到真正调研完现场流程,又动手用ThinkPHP和Laravel各实现了一版之后,才发现真正的坑不在CRUD,而在号源并发、状态流转和排班规则上。这篇文章就把整个从设计到落地的过程整理出来,包含技术选型思路、表结构设计、防超卖方案,以及部署阶段踩过的一些细节,希望能给正在做同类系统的人一些参考。
如果你是要毕业设计、外包项目,或者自己所在的小型医疗机构想摆脱纸质排班和Excel登记,这篇文章都适用。我会尽量把“为什么这样做”讲清楚,而不只是丢几段代码。因为这类系统的核心难点,从来都不是代码本身,而是怎么把混乱的线下规则翻译成一套严谨的线上状态机。
1. 为什么同时研究ThinkPHP和Laravel:先想清楚框架选型,再动手写代码
1.1 两个框架在项目里的真实分工
标题里同时出现“ThinkPHP”和“Laravel”,不是随手把两个热门词拼在一起。我实际经历是:项目启动时,那边机房跑着一个用ThinkPHP 5.1写的老预约系统,功能极其简陋,但稳稳定定用了三年;新需求要加多科室排班、在线支付、微信通知,大家都倾向于重写。问题就来了:继续用ThinkPHP,还是一步到位换Laravel?
我当时的做法,是把两套框架各写了一个可运行的原型,跑同一份数据库脚本、同样的业务规则,然后对比开发体验、部署成本和后期维护难度。ThinkPHP的优势非常明显:文档全是中文,上手快,随手一写就能跑,特别适合一个人快速交付;而且老系统就是用它写的,很多公共函数和SQL习惯可以无缝迁移。Laravel这边的优势则需要用几天才能感受到:中间件机制让权限校验变得异常干净,Eloquent的模型关系让排班和预约的查询少写大量JOIN,队列和事件系统更是处理消息通知的利器。
1.2 同样是MVC,差距比想象中大
很多初学者以为换框架只是换个写法,其实两者的编程思维差异很大。以数据库操作为例,ThinkPHP 5.1默认用Db::table()写查询构造器,字段用字符串数组,业务逻辑可以全部堆在控制器里,反正也能跑;Laravel则更倾向于你先定义Doctor模型、ScheduleSlot模型,然后用whereHas、with这类方法去做关联查询,代码层从控制器拆到了Service层甚至Action类里。
这种差异在开发前两周不明显,一旦功能增加到五六十个接口,差异就出来了。ThinkPHP版本的控制器会慢慢变成一千多行的“胖控制器”,而Laravel版本靠着Model关系和三五个核心Service,维护起来反而轻松。所以我最后的结论是:如果整个项目就我一个人维护、交付周期极短,选ThinkPHP没问题;如果这个系统要持续迭代两年以上,Laravel更值得投入。
1.3 我列过一张对比表,供你参考
| 对比项 | ThinkPHP 5.1/6.0 | Laravel 8/9/10 |
|---|---|---|
| 路由定义 | 路由文件+控制器方法名绑定 | 路由文件+控制器方法,支持分组和中间件 |
| 数据库操作 | 查询构造器为主,ORM较弱 | Eloquent ORM完善,模型关系好用 |
| 权限校验 | 可自定义中间件,但默认弱 | 中间件+Gate策略,结构清晰 |
| 队列任务 | 内置Queue,功能基本够用 | 成熟的事件+队列体系,重试/延迟很方便 |
| 定时任务 | 需要自己写crontab | Scheduler调度器,schedule:run统一管理 |
| 模板引擎 | 内置ThinkTemplate | 默认Blade,也可换成别的 |
| 中文生态 | 原生中文文档,社区中文资料多 | 中文社区逐年丰富,但很多问题要查英文 |
如果你现在还在纠结选哪个,我建议别光看技术对比,先问一句:谁负责后期维护?如果接手的人只懂ThinkPHP,强行上Laravel只会增加沟通成本。技术选型没有绝对的对错,只有适合不适合团队现状。
2. 从就医流程倒推系统边界:挂号系统的功能拆分与角色权限设计
2.1 先把线下流程画出来,再谈功能
做这类系统最容易犯的错,是一上来就画表结构。我的习惯是先把线下流程完整走一遍,把所有角色和动作列出来,再倒推系统边界。以我做的这个卫生服务中心为例,患者到院后的完整链路是这样的:预检分诊→选择科室→选择医生→选择时段→挂号缴费→候诊签到→医生叫号→就诊→开具检查或处方→离院。这套流程里,真正需要线上系统支撑的节点其实是“选择科室→选择时段→挂号缴费→签到候诊”,后面就诊环节由医生工作站自己的系统负责,我们不做越界的事。
这个边界的确定非常重要。很多做挂号系统的人想顺手把电子病历也做了,结果半年都交付不了。我当时明确跟需求方讲:本系统只覆盖“预约”和“挂号”两个主流程,以及支撑这两个主流程所需的排班、医生管理、通知和统计功能。前后端所有需求都围绕这个边界展开,其他需求一律记录到“二期”清单里。
2.2 角色权限:四种身份,四种视图
系统一共四类角色,权限划分必须从一开始就定清楚:
| 角色 | 核心权限 | 典型操作 |
|---|---|---|
| 患者 | 注册登录、查询科室医生、预约/取消预约、在线支付/退费 | 通过小程序或公众号完成 |
| 前台收银 | 替患者建档、现场挂号、修改号源、处理退号 | 使用PC端前台界面 |
| 科室管理员 | 维护本科室医生信息 | 只限本科室数据 |
| 超级管理员 | 系统全量配置:科室、医生、排班模板、统计报表 | 使用管理后台 |
权限模型我用了经典的RBAC:角色绑定权限组,用户绑定角色。值得一提的细节是,Laravel端我用了自带的路由中间件配合can()方法去校验权限,在控制器方法里基本上只需要一行代码;ThinkPHP端则是自己封装了一个权限中间件,框架本身没给现成的,需要写些逻辑。这里确实体现了Laravel在这方面的优势。
还有一个容易被忽略的权限点:数据范围权限。同样是“查看医生列表”,患者看到的应该是全部出诊医生,科室管理员看到的只能是自己科室的医生,超级管理员能看到所有科室。这个数据范围不能用同一个接口解决,我最终是分成/api/doctors和/admin/doctors两套接口,避免在业务层写一堆if判断角色。
2.3 功能清单成型后,工作量就清晰了
做完流程梳理和角色划分之后,功能清单基本就定下来了:用户注册登录、科室管理、医生管理、排班模板管理、排班实例生成、预约下单、订单支付/退款、取消预约、签到叫号、停诊通知、爽约统计、后台日/月报表。列出来之后我自己都倒吸一口气,单子不小,但每个模块的边界都很清晰,排期也就有了依据。这里我的经验是:功能清单必须写到“动作级”,比如“排班模板管理”要拆成新增模板、修改模板、复制上一周模板、批量生成实例这几个动作,不然排期一定会失控。
3. 数据库怎么设计才能撑住号源、医生排班和预约状态流转
3.1 核心表结构:六张表打底,再逐步扩展
数据库设计是整个系统的地基。我最终用的表结构可以浓缩成六张核心表:
| 表名 | 关键字段 | 作用 |
|---|---|---|
| users | id, name, id_card, phone, password | 患者与后台管理员共用一张表,type区分角色 |
| departments | id, name, parent_id, sort | 科室树形结构,支持二级科室 |
| doctors | id, department_id, name, title, introduction | 医生档案,关联科室 |
| schedule_templates | id, doctor_id, week_day, start_time, end_time, capacity, status | 每周排班模板 |
| schedule_slots | id, doctor_id, date, start_time, end_time, capacity, remaining, status | 某天某个医生某个时段的号源实例 |
| appointments | id, slot_id, user_id, appointment_no, status, paid_at, cancelled_at | 预约订单,记录完整状态流转 |
这里核心的设计思路是“模板与实例分离”。schedule_templates管规律性排班,比如某医生每周一三五上午出诊;schedule_slots管具体某一天的号源。系统每天凌晨定时任务根据模板批量生成未来14天的号源实例,如果遇到临时停诊,直接停用某一天的schedule_slots即可,不会影响模板。
3.2 排班生成与号源数据的关系
为什么要把号源拆成独立表,而不是在doctors表里存一个剩余号数?因为剩余号是随时间变化的,且每个时段的号源相互独立。医生周三上午有20个号,下午有15个号,如果只存一个总量,根本没法判断患者约的是上午还是下午。拆成schedule_slots之后,每次预约只针对某一个具体时段实例做扣减,逻辑就非常直白。
生成排班实例的伪逻辑大概是这样:找到未来日期范围内所有启用状态的模板,排除节假日停诊标记,然后批量创建schedule_slots记录,remaining初始等于capacity。这个定时任务在Laravel端用Schedule实现,在ThinkPHP端则写了一个命令行脚本挂crontab,每天早上五点执行一次。生成完后要做一次幂等保护,也就是同一天同一个医生同一个时段只能存在一条记录,避免重复生成导致号源翻倍。
3.3 预约状态机:准确地说,它就是系统的灵魂
预约订单的状态设计非常关键,我最开始只设计了“已预约/已取消”两个状态,结果上线两天就发现问题:患者预约后没付费怎么办?到院没签到怎么算?医生停诊导致订单要自动变更为取消怎么办?后来我把状态扩展成完整的状态机:
| 状态值 | 含义 | 允许的下一状态 |
|---|---|---|
| pending | 待支付(预留号源) | paid, cancelled, expired |
| paid | 已支付/待就诊 | arrived, cancelled, no_show |
| arrived | 已签到/候诊中 | in_progress, complete |
| in_progress | 就诊中 | completed |
| completed | 已完成 | 终态 |
| cancelled | 已取消 | 终态 |
| no_show | 爽约 | 终态 |
每个状态迁移都会写一条appointment_logs记录,包含操作人、动作、时间、备注。这个日志表看着不起眼,后期查纠纷、对账、统计爽约率时帮了大忙。而且状态迁移我统一封装在AppointmentService里,任何入口(小程序、前台、定时任务)都走同一个服务方法,绝不直接在控制器里改状态字段。
3.4 字段类型与索引,这些细节别省事
字段类型上有个经验:appointment_no这种业务单号用varchar(32)并加唯一索引,方便按单号查询;金额统一用decimal(10,2),绝不用float;手机号、身份证号要加索引但不加唯一约束,因为确实存在同一个监护人给多个家属挂号的情况。时间字段统一用timestamp,别混用datetime,不然跨时区或者做日统计的时候会想哭。
索引方面,appointments表上一定要对slot_id和user_id分别建索引,因为高频查询就是“查某个号源被约了多少”和“查某用户的历史预约”。schedule_slots表则对doctor_id + date建联合索引,排班查询基本都是这个组合。我上线后最严重的一次慢查询,就是没给appointments.slot_id加索引导致的,200毫秒查询直接飙到2秒,加上之后恢复了正常。
4. 预约冲突与超卖问题:高并发下的号源扣减与防重实现
4.1 超卖是怎么发生的:一次并发请求的经典事故
假设某个时段只剩最后1个号源,两个患者同时点击“确认挂号”。如果没有并发控制,程序会先查出remaining=1,A请求判断大于0,执行扣减;B请求也查出了remaining=1,同样判断大于0,同样执行扣减。最终数据库里也可能出现两条预约记录,但号源已被扣成负数——这就是典型的超卖。
很多项目会在前端加“按钮点击后置灰”、在接口层加“用户五分钟内不能重复提交”,但这两招都防不住并发。前端防御只是降低概率,真正的可靠性必须落在数据库层面。我的方案是三层防线:数据库事务 + 行级锁 + 前端幂等键。
4.2 方案对比:乐观锁、悲观锁、Redis原子操作
| 方案 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| 乐观锁 | 更新时带version条件,失败重试 | 并发性能好 | 冲突多时用户体验差 |
| 悲观锁 | SELECT ... FOR UPDATE | 逻辑简单,绝对可靠 | 锁等待可能造成连接堆积 |
| Redis扣减 | DECR原子操作 | 性能极高 | 需要处理Redis与MySQL的一致性问题 |
具体场景不同选择也不同。挂号系统的号源扣减频率远低于秒杀,单时段一般就几十个号,并发压力有限,所以我最后选择了悲观锁加事务。这样最保险,逻辑也最容易给需求方讲明白。
4.3 Laravel实现:事务加行锁,代码量其实很少
public function createAppointment(array $data) { return DB::transaction(function () use ($data) { $slot = ScheduleSlot::query() ->where('id', $data['slot_id']) ->where('remaining', '>', 0) ->lockForUpdate() ->first(); if (!$slot) { throw new BusinessException('该时段号源已约满'); } $slot->decrement('remaining'); return Appointment::create([ 'appointment_no' => $this->generateOrderNo(), 'slot_id' => $slot->id, // ... 省略其他字段 'status' => 'pending', ]); }); }重点是lockForUpdate():它会对命中行加排他锁,第二个事务在第一个事务提交前会一直等待,这样就不会出现两个请求同时读到remaining=1的情况。where('remaining', '>', 0)加了一重数据库层面的兜底,哪怕业务代码出bug,也不可能把剩余号扣成负数。DB::transaction确保扣减号和创建预约记录要么同时成功,要么同时回滚。
4.4 ThinkPHP实现:同样的思路,语法略有区别
public function createAppointment($data) { return Db::transaction(function () use ($data) { $slot = Db::name('schedule_slot') ->where('id', $data['slot_id']) ->where('remaining', '>', 0) ->lock(true) ->find(); if (!$slot) { throw new BusinessException('该时段号源已约满'); } Db::name('schedule_slot') ->where('id', $slot['id']) ->dec('remaining') ->update(); return Db::name('appointment')->insertGetId([ 'appointment_no' => $this->generateOrderNo(), 'slot_id' => $slot['id'], // ... 'status' => 'pending', ]); }); }ThinkPHP 5.1里对应的是lock(true),配合Db::transaction()就可以实现行锁。要注意的是lock(true)必须配合find()使用,如果先查出数据再调lock(),锁可能没生效。这个坑我实际踩过,因为TP的链式查询里lock和查询条件的位置会影响SQL拼接,建议写完后打印一下SQL日志确认有FOR UPDATE。
4.5 防重不止后端:幂等键和取消后的号源释放
除了行锁,我还给前端加了幂等键机制。用户点击“确认挂号”时,前端生成一个client_token,后端拿这个token在Redis里做SETNX,重复提交同一个token直接返回“请勿重复操作”。这个token同时也能挡住部分脚本刷接口的行为。
取消预约后的号源释放也是一个容易出错的地方。我规定只有pending和paid状态的订单可以取消,取消时状态置为cancelled,同时把对应schedule_slots的remaining加回来,并且给用户退款(如果已支付)。这两个动作同样放在一个事务里,避免出现订单已取消但号源没恢复的脏数据。
实测中我用压测工具模拟了200个并发请求同时抢同一个只剩10个号的时段,最终预约成功的记录恰好是10条,没有一条重复,没有负号源,数据库一致性没问题。这个结果验证了“事务+行锁”方案在当前量级下完全够用,没必要为了炫技引入复杂的Redis扣减方案。
5. 消息通知、支付对接与行政后台:容易被低估的配套模块
5.1 预约成功和停诊通知:设计一个可插拔的通知通道
挂号系统逃不掉消息通知,最基础的两个场景是“预约成功提醒”和“医生临时停诊提醒”。通知渠道至少包括短信、公众号模板消息或小程序订阅消息。一开始我把通知逻辑直接写在预约Service里,后来发现每次加一个渠道就要改业务代码,非常痛苦。重构后我抽象了一个通知接口:
interface NotificationChannel { public function send(string $to, string $title, string $content): bool; }短信渠道、微信渠道、站内信渠道分别实现这个接口,业务端只调Notification::send()。Laravel自带成熟的Notification组件,可以直接扩展;ThinkPHP版本我是在app/Service/Notification目录下手动实现的同样逻辑。这个抽象做完之后,新增一个“APP推送”只需要加一个实现类,业务代码一行不用动。
5.2 挂号费支付与退款:回调处理是重中之重
支付模块不是自己做支付,而是对接第三方统一支付接口。流程是:用户提交预约且状态为pending,后端生成支付单号并返回支付参数;用户完成支付后,第三方回调通知后端;后端在回调里校验订单信息,把订单状态从pending改为paid,同时更新号源状态。
这里最容易出问题的是回调处理,必须做到“一次且仅一次”的语义校验。我会判断回调单号是否存在、订单状态是否已经为paid,如果已经是paid就直接返回成功,不再重复改状态。退款则走反向流程:用户取消订单,若已支付则调用退款接口,退款回调成功后把状态标记为refunded。千万别在取消订单的路上直接改状态而不管退款结果,否则会出现“订单显示已取消,但钱没退”的客诉。
5.3 爽约规则与黑名单:设计越简单越好用
爽约的定义是:患者预约成功且已签到状态为paid,但在就诊时段结束后仍未到院签到。为了避免运营规则太复杂,我用的是最简单的策略:就诊日次日零点跑定时任务,扫描paid状态且就诊时间已过的订单,标记为no_show;一个月内爽约两次进入黑名单,限制未来一周不能在线预约。
只有线上下单才参与爽约判定,前台人工挂号的订单不参与。这个规则在需求确认时和一线的护士长反复对齐过,因为“哪种算爽约”的界定,他们最清楚。我也建议后来者在这一步多花时间跟运营同事确认,技术本身不复杂,但规则理解偏差会导致上线后各种客诉。
5.4 后台报表:管理者真正关心的几个数字
行政后台除了基础CRUD,最不能缺的是统计报表。我做了三张核心报表:科室号源使用率报表(某科室某天放号多少、已约多少、使用率百分比)、医生出诊工作量报表(某医生周期内接诊人数、平均每人时长)、患者爽约率报表。这些报表的查询条件都很简单,真正麻烦的是数据口径,比如“已挂号数”要不要剔除取消订单,“使用率”分母是放号数还是可预约天数。我的建议是开发时先把口径写在代码注释里,不然三个月后你自己都忘了当初怎么算的。
技术实现上,这类统计报表不建议实时跑大聚合SQL,我是每天晚上用定时任务把当天的统计结果预聚合到一张daily_stats表里,后台直接查表,响应时间基本都在100毫秒以内。
6. 从本机到服务器:部署、日志与性能调优的实操记录
6.1 部署环境与基础架构
最终上线环境用的是Nginx + PHP-FPM + MySQL 5.7 + Redis 6.0,服务器配置不高,2核4G,但支撑卫生服务中心一天几百号的预约完全没有压力。Nginx里最关键的是伪静态配置,Laravel和ThinkPHP的入口都在public目录,需要把请求都转发到index.php。Laravel下面这段配置是最常用的:
location / { try_files $uri $uri/ /index.php?$query_string; }ThinkPHP的伪静态略有不同,但思路一致。另外一定要确认PHP-FPM的upload_max_filesize、memory_limit这些参数,别等上传证件照片报错的时候才想起来调。
6.2 Laravel部署最容易翻车的三个细节
第一,storage目录权限。storage和bootstrap/cache必须让PHP-FPM进程有写权限,我吃过没配权限导致日志写不了、页面白屏的亏。第二,php artisan config:cache之后,如果改了.env文件没重新缓存,配置会不生效;而如果改完.env不清理缓存,反而可能让老配置继续续命,这个细节很容易迷惑人。第三,队列监听。通知和回调里用到的队列任务,必须保证php artisan queue:work常驻运行,否则消息通知会积压。我用的是supervisor托管队列进程,比直接用nohup挂后台靠谱得多。
6.3 ThinkPHP部署:runtime权限与日志切割
ThinkPHP端部署相对简单,但runtime目录权限是同样的坑,一定要确保可写,不然各种缓存、日志都会出问题。另外ThinkPHP的日志是单文件追加,时间久了文件会非常大,最好在运维层面做一下按天切割,或者定期清理,不然磁盘很容易被撑满。我在上线前就把日志级别调整成了record,只记录error级别,避免generate级别刷出海量调试日志。
6.4 性能调优:慢查询日志与Redis缓存热点数据
上线稳定后,我做了一轮性能排查。打开MySQL慢查询日志,发现大部分耗时SQL集中两类:一类是后台报表查询,通过预聚合表解决了;另一类是首页科室列表和医生列表,本身查询不慢,但每次请求都查数据库,白白浪费时间。
解决方案是加Redis缓存:科室列表缓存到Redis,key为departments:all,排班变更或医生信息修改后主动删除缓存。医生排班列表则按“科室+日期”组合成key,比如schedule:department:5:2025-01-10,缓存有效期设为五分钟或排班变更时失效。这个优化做完,接口平均响应时间从180毫秒降到了30毫秒左右,效果非常明显。
6.5 安全加固:这些坑基本每个医疗系统都会遇到
最后说安全,围绕的是几个真实发生过的攻击场景。接口层面,所有需要登录的接口都校验了用户身份,避免水平越权——患者A不能通过改URL里的ID去查看患者B的预约记录。提交预约时校验了CSRF Token,尤其是后台管理界面。前端登录加入了图形验证码,防止脚本批量注册。支付回调接口做了签名验证,并且对回调IP做了白名单校验,避免伪造回调。
还有一点容易被忽视:敏感信息脱敏。身份证号、手机号在列表接口中默认不返回,只有本人查看详情或后台管理员操作时才展示完整信息。这些虽然是常识,但在这个行业里,安全问题从来不是技术多高深的问题,而是有没有意识去做的问题。
整套系统从开始调研到上线,前后花了大概两个半月。回头看,最值得分享的经验不是某个具体函数怎么写,而是一个认知:预约系统的灵魂是状态机和并发控制,而不是页面漂不漂亮。把状态流转、号源扣减这些底层逻辑想透了,不管用ThinkPHP还是Laravel,写出来的代码都不会差到哪里去。希望这篇记录能帮你少踩几个坑。最后再提一句,如果真正要上线医疗业务系统,合规要求会比普通系统严格得多,这些环节建议在做需求调研阶段就主动向相关方确认清楚。