医院预约挂号系统开发实战:ThinkPHP与Laravel并发控制与数据建模解析
2026/9/8 14:05:13 网站建设 项目流程

我上一次完整落地这类项目,是一个“课程设计级别”但业务细节并不含糊的医院预约挂号课题:科室按门诊楼分楼层、医生排班分上午下午、部分专家号每天限量、患者要能看到剩余号源再锁定,还要求能取消并立即释放号池。框架可选,限制条件是实践层面横向比较 ThinkPHP 和 Laravel 其中一版能跑通。做完之后最大的感受是:预约挂号这类系统,真正难的不是写 CRUD,而是把‘并发下的号源不超卖’这件小事在设计阶段就想透。

这篇文章我会把完整的分析和实操过程复盘一遍,覆盖需求拆解、两个框架在关键点上的差异、数据表设计、号源锁复用、常见并发冲突的排查方法,以及一些普通业务流程文档里不会写出来的“坑”。如果你是准备做毕业设计、课程设计,或者刚进医疗信息化方向想找参照,应该能直接少走不少弯路。

1. 别急着敲路由:医院预约挂号系统的真实业务规则远比想象中多

许多网上案例写挂号系统,无非是“用户登录—选科室—选医生—选日期—提交订单”,看起来像一个简化版电商。实际上如果直接按这个思路建表,十有八九会在后期发现逻辑漏洞:同一时间医生只能看一个患者、医生临时停诊怎么办、患者如果一天内挂了好几个科要不要限制,这些业务规则没定清楚,后端的接口设计就是空中楼阁。

1.1 先把角色和核心流程单独梳理出来

我习惯把整个系统从两类使用视角拆分:患者端和管理端。

  • 患者端的核心动作是:科室浏览 → 医生列表 → 排班日期选择 → 时间段锁定 → 提交预约 → 查看预约记录 → 取消预约。
  • 管理端(含医院运营人员和医生角色)的核心动作是:维护科室信息 → 维护医生基本信息 → 配置每周出诊计划 → 生成某天某医生可预约号源 → 医生查看今日患者列表 → 标记到诊或爽约。

这个流程拆开看并不复杂,但它决定了后面数据表之间会有一个明显的父子层级:科室是一级节点,医生挂在科室下,排班挂在医生下,预约记录挂在排班和患者之间。前端要展示的所谓“今日可预约列表”,本质上是“排班 + 剩余号源”的聚合查询,而不是把过去所有预约记录捞出来。

另外要尽早决定一个关键规则:预约的最小时间单位。常见有两种方案,一种是按“上午/下午”这种大时间段约,另一种是按半小时或一小时细分号源。前一种实现简单,后台录入容易,但患者体验差;后一种体验好,前期设计和并发处理难度都上来了。我在项目里选中了“日期 + 上午/下午 + 小时级时间段”的方案,并且要求同科室内同一患者一天只允许约一次,避免医疗资源被无意义占住。

1.2 基于规格列表的开发排期比直接到处加字段更靠谱

医院信息系统有个特点:字段一旦上线,后续改动成本极高,因为牵扯到挂号记录、病历、叫号、医院对账等下游环节。因此不能边写代码边加字段,我习惯把功能需求列成一张基础清单,每条需求都标注业务含义和涉及的边界条件。

功能点说明易忽略的边界规则
科室管理支持启用/停用停用后不应出现在患者端列表
医生管理医生必须归属某个科室停诊需要对未就诊预约全部通知
排班管理指定医生在指定日期有某时段坐诊排班日期不能是过去时间
号源生成根据排班时段切分时间段并控制限号同一时段不能对同医生重复插入
预约提交患者锁定号源,生成正式记录前置查询与后置写入必须防并发冲突
取消预约释放号源,反向恢复可约数量已就诊、已过期的记录不可取消
预约查询患者查询历史,后台按日期/医生筛列表默认倒序或分页

这张表在动手开发前敲定,后续设计表结构和写事务代码时就不会出现“做完发现有歧义,要返工改表加状态”的地狱模式。

2. ThinkPHP 与 Laravel 的实际差异:以预约挂号为试金石

写这套系统之前,我先把 ThinkPHP 6 和 Laravel 10 都搭了最小原型。两个框架实现同样的患者登录、科室列表、提交预约功能,复杂度差别不大,但工程组织风格差异非常明显。如果项目里大量使用中间件、任务队列、RESTful 资源路由、门面服务,Laravel 会顺手很多;如果团队熟 TP、模板渲染要更快出界面、数据库操作喜欢链式但也别太“魔法”,ThinkPHP 更容易落地。

2.1 后端路由定义方式:RESTful 接口上的直观差异

预约挂号的接口大多是标准 REST 风格,例如取消预约对应DELETE /appointments/{id}。两个框架能力上都支持,写法不同。TP6 中惯用方式是手动把控制器和方法挂到路由上,Laravel 则提供资源控制器方法,让代码组织更规整。

这是 Laravel 的写法:

use App\Http\Controllers\Api\AppointmentController; Route::prefix('appointments')->middleware('auth:sanctum')->group(function () { Route::get('/', [AppointmentController::class, 'index']); Route::post('/', [AppointmentController::class, 'store']); Route::post('/{appointment}/cancel', [AppointmentController::class, 'cancel']); });

这是 ThinkPHP 6 的等价写法:

use think\facade\Route; Route::group('appointments', function () { Route::get('/', 'AppointmentController@index'); Route::post('/', 'AppointmentController@store'); Route::post(':id/cancel', 'AppointmentController@cancel'); })->middleware(AuthCheck::class);

看过代码结构可以发现,TP 的控制器负责对外暴露方法,认证依赖中间件;Laravel 同样依赖中间件,但更倾向于把每个业务动作切分成不同方法,API 风格约束更明显。如果是纯手写控制器、习惯把验证、事务、响应都放在一个方法里的小项目,TP 写起来更快;如果项目希望后续业务层能独立测试,Laravel 对方法细粒度拆分的要求会更自然。

2.2 ORM 与模型设计:拼接业务数据的繁琐程度是关键

预约挂号系统免不了大量关联查询。比如患者端医生列表,要查询医生归属科室名、职称以及当天是否排班。在 Laravel 中,我直接给 Doctor 模型定义关联关系:

// app/Models/Doctor.php public function department() { return $this->belongsTo(Department::class); } public function schedules() { return $this->hasMany(Schedule::class); }

联表结果也可以用with预加载:

$doctors = Doctor::with(['department' => function ($query) { $query->select(['id', 'name']); }]) ->where('status', 1) ->whereHas('schedules', function ($q) use ($date) { $q->where('work_date', $date) ->where('remaining_count', '>', 0); }) ->get();

这样得到的是一条完整医生对象链。ThinkPHP 6 的模型同样有关联预加载,写法上with(['department', 'schedules'])也很接近,差异主要在命名习惯、查询作用域和模型事件上。比如说你要在预约创建后自动扣减号池,TP 可以在模型事件afterInsert里挂逻辑,Laravel 则在推荐的服务层中统一处理,避免模型里藏了太多副作用,这两种选择在业务规范上各有利弊。

我的个人建议是:如果核心业务要长期迭代,不要在控制器里写大量裸的Db::table()调用,把每次预约扣号逻辑收敛到一个服务类。TP 或 Laravel 这点都一样,但 Laravel 的依赖注入容器会让服务类使用更舒服。

2.3 认证与权限控制对医疗系统意味着什么

预约挂号系统的用户角色不像普通电商那样只有注册用户。至少要区分患者、医生角色以及后台运营者,Laravel 中我选择sanctum同时管理 API 令牌和后台 session 登录功能。实现一张users表加role字段,配合中间件进行角色判断。

ThinkPHP 项目如果不多引包,认证方案通常得自己来一套基于 session 或 JWT 的逻辑。TP6 官方生态中 JWT 组件不是完整体验,需要额外装第三方包;Laravel 的官方包在文档、社区和升级上更稳。权限差异对最终功能并不形成完全决定性影响,但对于非多年经验团队,配置越省心,后期卡壳越少。

2.4 Laravel 的队列与调度提醒在挂号场景里的额外加成

医院预约挂号不能只完成“下单”。常见并且体验很好的功能是公众号或短信提醒“你预约的医生明天上午出诊”,以及医生临时停诊后的批量通知。Laravel 自带queuetask scheduler,把提醒任务做成后台任务自然。

ThinkPHP 也有 queue 组件和命令行支持,能实现轮询任务,但相对 Laravel 来说,任务失败重试、延迟队列、事件监控的集成度要引入更多手动配置。如果课题或项目只要求基本挂号功能,这两点差距不大;如果需求文档里写了“患者需提前一天收到短信/微信提醒”,Laravel 实现成本会更低。

顺手谈一个新鲜话题:不少同学看到laravel graphql这类热词,就想着给预约系统加 GraphQL 接口。我的观点是,除非管理端要做多条件组合筛选,涉及大量嵌套查询优化,否则这个系统的接口形态完全用 REST 就够了。GraphQL 的引入“需要”额外的 schema 设计和查询保护,在传统预约业务上属于过度设计,学习价值大于业务价值。真想玩可以单独把“科室医生排班搜索”做成一个 GraphQL 模块练手,别让全项目都走 GraphQL。

3. 数据建模是第一道“防超卖”屏障,不只是表结构的事

回到系统实现。预约挂号最怕的就是数据不一致:一个患者和另一个患者同时看到同一个 9:00 号源,并且同时预约成功。常见初级设计把逻辑写到代码里:先查if (剩余号 > 0),然后再插入一条预约记录。数据库没有唯一约束或行锁保护时,这一步几乎必然产生超卖。

我在实际建模时,用的是“排班主表 + 预约明细表”的方案,没有做太复杂的号源切片表以缩短开发量。关键在设计两张核心表。

3.1 排班表是医生某一天出诊的唯一事实来源

通常一个医生一天只能有一组排班,比如早上 08:00-12:00,下午 14:00-17:30。排班表作为当天的聚合根存在,负责维护总号和余号。设计如下:

字段名 类型 必填 说明 doctor_id int 是 关联 doctors 表 work_date date 是 出诊具体日期 shift_type tinyint 是 1 上午 / 2 下午 / 3 全天 start_time time 是 出诊开始时间 end_time time 是 出诊结束时间 total_count int 是 当日总号源数量 remain_count int 是 当日剩余可约数量 status tinyint 是 0 停用 / 1 正常

这表是“防超卖”的第一道防线。凡是对余号的扣减,都应该有类似条件更新语句:

$affected = Schedule::where('id', $scheduleId) ->where('status', 1) ->where('remain_count', '>', 0) ->decrement('remain_count'); if ($affected === 0) { throw new \RuntimeException('当前号源不足或已停诊'); }

这种条件更新的好处是:数据库只在remain_count > 0的前提下减一,并且在加锁的行范围内更新结果,返回受影响行数作为是否扣减成功的凭证。如果有两个请求同时进来,数据库行级锁保证后到者会在等待锁释放后再检查remain_count,从而打平,不会出现两个人同时把余号从 1 减到 0 的情况。

3.2 预约记录表要设唯一索引,从根上挡掉重复预约

患者预约记录需要的信息是患者、医生、日期、时间段、状态来源。我设计的核心字段大致如下:

字段名 类型 必填 说明 order_no varchar(32) 是 业务订单号,非主键 patient_id int 是 rel 患者表 doctor_id int 是 关联医生 schedule_id int 是 关联排班 id visit_date date 是 就诊日期 time_slot varchar(20) 是 如 09:00 status tinyint 是 0待就诊 1已就诊 2已取消 3爽约 created_at datetime 是 reason varchar(255) 否 取消原因

这里有两个很容易被忽略的细节。第一,visit_date不能从schedule_id反查后省略,因为后台经常会拿预约记录表做统计,单列日期字段能省去大量联表。第二,必须把业务级唯一索引落在数据库层。

需要防的是同一个医生在同一个日期同一个时间段不能被患者重复约中。此时可以向预约记录表加上:

ALTER TABLE appointments ADD UNIQUE KEY uniq_doctor_slot (doctor_id, visit_date, time_slot);

有了这个唯一约束,即使你的条件更新代码因为某些原因被绕过或并发穿透,数据库也会把第二条插入直接拒绝,相当于兜底。实践上我很推荐“代码条件扣号 + 数据库唯一约束”双保险。

另一个常见的要求是一个患者不能在同一半天里重复挂同一个科室,以防资源被刷。我的处理是在代码里检查appointments表是否已有未取消的同日期记录,但由于不同医院业务口径差异大,这个规则要谨慎做成配置而不是写成死代码。有的医院会允许患者上午挂一个科、下午挂另一个科,这时候如果用“同一天同患者只能约一次”的逻辑就会误伤。

3.3 支撑业务表设计时容易被忽视的索引和状态枚举

科室表结构虽然简单,但要注意删除策略。医院项目禁止物理删除,科室和医生都应只做软删除或禁用;因为历史预约记录要保留这些外键关系。凡是预约记录表里的查询条件,比如patient_id + visit_datedoctor_id + visit_datestatus、+ visit_date,都建议建组合索引,不然数据量大了以后慢查询会先爆发。

我强烈建议把“是否停诊字段”放到排班表而非医生表。因为同一个医生可能周一正常、周二临时停诊。医生表上的状态只代表账号层面是否启用;排班表里的status才是当天号源是否开放的核心依据。取消预约后恢复号率的逻辑也要同时判断排班状态,如果已经停诊,取消时号池不能继续加回。

4. 实操核心:从排班生成到预约提交的完整流程实现

这部分我给出这套系统最核心的流程实现思路。整体按 Laravel 风格写,在需要注意差异的地方单独说明 TP 怎么对应。

4.1 排班记录创建并由后台生成的流程

录入排班后,系统会针对当天的出诊开始时间和结束时间定期按 30 分钟或 60 分钟拆号源。但排班表里若已有记录,必须防止重复创建。这一步简单业务编写用事务加上唯一索引即可:

public function storeSchedule(Doctor $doctor, string $date, int $shiftType) { $exists = Schedule::query() ->where('doctor_id', $doctor->id) ->where('work_date', $date) ->exists(); if ($exists) { throw new \DomainException('该医生在此日期已有排班'); } DB::transaction(function () use ($doctor, $date, $shiftType) { $start = strtotime($date . ' 08:00:00'); $end = strtotime($date . ' 12:00:00'); $slotMinutes = 30; $schedule = Schedule::create([ 'doctor_id' => $doctor->id, 'work_date' => $date, 'shift_type' => $shiftType, 'start_time' => date('H:i:s', $start), 'end_time' => date('H:i:s', $end), 'total_count' => ($end - $start) / 60 / $slotMinutes, 'remain_count' => ($end - $start) / 60 / $slotMinutes, 'status' => 1, ]); }); return $schedule ?? null; }

由于“id 唯一主键”并不能真正防止业务数据不一致,必须给数据库增加doctor_id + work_date + shift_type组合唯一,或在插入前用exists判断。当前是单机单库场景,用事务包着判断,一般不会有大问题。

4.2 前端查询可约号源:核心是展示剩余时间点

患者查看医生排班时,后端应该返回已按时间段切好、并且仍可预约的时间。如果拆号源存在专门的号源表里,查询只返回status = free行即可。我为了省表直接使用排班时间区间在内存中生成展示,预约提交时并不保存精确时间点,而是直接对应 schedule_id。

需要注意这类查询不可把整个排班区间全量返回。如果患者进入页面到点击预约之间有延迟,号源可能已经没了,前端最好在提交预约时再把 time 传给后端做二次校验。更稳妥的方案是可以给预约记录表存time_slot,但后续是否冲突用组合唯一索引保证,而不是纯内存切分。

4.3 提交预约事务方法实现

Laravel 中核心方法大致如下:

public function createAppointment(CreateAppointmentRequest $request) { $validated = $request->validated(); $userId = auth()->id(); return DB::transaction(function () use ($validated, $userId) { $schedule = Schedule::query() ->lockForUpdate() ->where('id', $validated['schedule_id']) ->where('work_date', $validated['visit_date']) ->where('status', 1) ->firstOrFail(); if ($schedule->remain_count <= 0) { throw new \DomainException('该时间段已约满'); } $exists = Appointment::query() ->where('patient_id', $userId) ->where('visit_date', $validated['visit_date']) ->where('status', 0) ->exists(); if ($exists) { throw new \DomainException('当天已有待就诊预约,请勿重复挂号'); } $orderNo = 'GH' . date('YmdHis') . random_int(1000, 9999); $appointment = Appointment::create([ 'order_no' => $orderNo, 'patient_id' => $userId, 'doctor_id' => $schedule->doctor_id, 'schedule_id' => $schedule->id, 'visit_date' => $validated['visit_date'], 'time_slot' => $validated['time_slot'] ?? '', 'status' => 0, ]); $affected = Schedule::where('id', $schedule->id) ->where('remain_count', '>', 0) ->decrement('remain_count'); if ($affected === 0) { throw new \DomainException('号源已被抢完'); } return $appointment; }); }

这段代码的关键逻辑是lockForUpdateremaining > 0 plus decrement的结合。实际业务如果并发量很高,数据库单行锁可能阻塞大量查询,因此引入 Redis 锁控制同一医生同一天排班的并发更新,减少数据库锁等待。不过对于中小医院门诊量(一天几千号)来说,上面的数据库锁方案已足够稳定。TP6 要对应这个事务,可以把Transaction::换成Db::transaction()lockForUpdate改为lock(true),思路完全一样。

4.4 取消预约与号池恢复的边界条件

取消预约是超卖之外的另一个业务高发坑。如果患者用不到,号源必须回填调度表。但回填前必须有三次判断:该预约是否本人、预约状态是否为待就诊、就诊时间是否已过期。否则到了时间点还能改可以退,等于变相创造了过期号。

public function cancel(Appointment $appointment) { if ($appointment->patient_id !== auth()->id()) { throw new \DomainException('无权操作他人预约记录'); } if (!in_array($appointment->status, [0])) { throw new \DomainException('当前记录状态不可取消'); } if ($appointment->visit_date < now()->toDateString()) { throw new \DomainException('已过期,如需退号请线下处理'); } DB::transaction(function () use ($appointment) { $appointment->update(['status' => 2]); Schedule::where('id', $appointment->schedule_id) ->where('status', 1) ->increment('remain_count'); }); }

有些医院会要求“就诊当天不可线上取消,必须现场退号”或“取消扣信用”,这类需求可以在取消流程中加入前置参数配置,而不是修改预约逻辑。我给出的这段直接对应题目要求,严格限制到日期,适合大多数演示项目。

5. 常见问题与排查实录,直接当速查表用

这类系统跑起来后,问题往往出现在数据可见性、并发和日期处理三块。我发现大多数同学在两框架切换时犯的问题也不是语法层面的,而是“业务规则没落库”。

5.1 同一患者重复预约,代码挡住但并发绕过

最常见的是开发环境压测时,同一账号对同一医生同一时间连续发起两次预约请求。由于代码中exists是查询,紧接着插入,两请求之间有间隙,就会出现两条预约同时写入。光靠代码层判断不够。

最终解决需要落到唯一索引,我在设计预约记录表时就有意加了uniq_doctor_slot,加了一次索引后直接把异常改成数据库QueryException抛出,捕获后换成人性化提示即可。

5.2 超卖问题集中爆发于“先查后插”的结构

如果代码原来写成:

$count = Appointment::where(...)->count(); if ($count >= $schedule->total_count) { throw ...; } Appointment::create(...);

这种写法,即使前端看起来没有问题,并发一高就会超卖。因为count查询没有锁,更新是异步的。排查时可以先看 MySQL 日志有没有“duplicate entry”或“deadlock”报错;有的话基本不是代码跑得不正常,而是缺少条件更新。这也是我在前文反复强调decrement和“受影响行数”的原因。

5.3 取消后号源没有恢复,往往是恢复条件弄错

取消时我把状态更新与号池恢复放进一个事务,但有人会在恢复前重新查一下schedule是否存在,若排班被逻辑删除则干脆不加回,认为这样数据不会乱。可删掉的排班却保留着大量预约记录,一旦后面统计报表分析,字段不一致严重。对医院项目来说,逻辑停用比物理删除普通排班更稳妥。如果就诊记录对应的排班已被停用,则此日期不再允许预约该医生,但存量记录依然保留“未取消”状态。

5.4 跨天、跨周、跨月的时间边界问题

预约系统的日期判断非常烦琐。查询今天可预约时,若把当前日期过滤条件写成visit_date >= now(),会出现预约“今天的下午”在早上还能查到、可就诊时间已经开始的怪象。我通常的实现如下:

  • 查询预约列表:按日期倒序
  • 判断可取消:就诊日期大于今天才可取消
  • 展示今日号源:只能看当天及以后排班

不使用now()进行比较,因为now()包含时分秒,直接和日期比会比较一些意外。核心比较一律用Carbon::today()date('Y-m-d')转字符串匹配,TP 中我一般也直接用date('Y-m-d'),避免时区混淆。

最后:再分享一个我在搭建两个框架项目时的小习惯

我在两个框架实现同一套预约系统时,故意在 TP 版和 Laravel 版之间重用同一套前端交互页面和同一套字段命名规则。业务字段顺序越一致,后台或接口切换成本就越低。很多人换框架觉得痛苦,并不是框架学习成本高,而是项目里把业务字段写得到处乱放,要么叫 datetime 要么叫 time,要么同一逻辑一份叫 book、一份叫 order。真正进入医疗信息化方向,多一套稳定的命名和字段管理习惯,比纠结框架选型更有价值。

如果你也要做医院预约挂号方向的课设或实际项目,我的建议是先把最后的数据模型和第二业务周期锁定,再选框架。ThinkPHP 适合快速看见功能全貌、模板渲染和手写 API 同样方便;Laravel 适合想长期维护、丰富消息队列与任务调度的工程。在两个框架之间做选择时,不要只是比谁更“潮”,要看医院最在意的预约可靠性和后期排班处理效率被你放在了代码的哪一层。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询