尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

医院预约挂号系统开发实战:ThinkPHP与Laravel并发控制与数据建模解析

医院预约挂号系统开发实战:ThinkPHP与Laravel并发控制与数据建模解析 我上一次完整落地这类项目是一个“课程设计级别”但业务细节并不含糊的医院预约挂号课题科室按门诊楼分楼层、医生排班分上午下午、部分专家号每天限量、患者要能看到剩余号源再锁定还要求能取消并立即释放号池。框架可选限制条件是实践层面横向比较 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(/, AppointmentControllerindex); Route::post(/, AppointmentControllerstore); Route::post(:id/cancel, AppointmentControllercancel); })-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 自带queue与task 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_date、doctor_id visit_date、status、 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; }); }这段代码的关键逻辑是lockForUpdate与remaining 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 适合想长期维护、丰富消息队列与任务调度的工程。在两个框架之间做选择时不要只是比谁更“潮”要看医院最在意的预约可靠性和后期排班处理效率被你放在了代码的哪一层。
返回列表