尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

ThinkPHP实战:社区诊所挂号排队系统从表结构到部署防坑全记录

发布时间:2026/9/17 20:44:37

资讯中心
01
ARTICLE

ThinkPHP实战:社区诊所挂号排队系统从表结构到部署防坑全记录

ThinkPHP实战:社区诊所挂号排队系统从表结构到部署防坑全记录
最近帮一个社区诊所做了一套在线挂号与排队叫号系统后台用的ThinkPHP从需求梳理到最终上线前后折腾了一个多月。这套系统乍看不算复杂——无非是患者登录、选医生、挂号、到号提醒前台播报叫号但真正做下来里面的坑和细节比预想的多很多。尤其是ThinkPHP在关联删除、事务并发、部署环境这些环节稍微不注意就埋雷。这篇文章我打算把这套系统的完整思路、表结构设计、核心代码逻辑、部署配置以及我在开发过程中踩过的坑全部整理出来。无论你是刚接触ThinkPHP的新手还是想快速搭建一套诊所挂号Demo的开发者这篇文章都能让你少走不少弯路。文章内容基于我在实际项目里的做法不是文档复读所有代码片段都可以直接参考。1. 项目拆解社区诊所到底需要一套什么样的挂号系统1.1 别小看诊所挂号业务闭环比表面复杂很多开发者接到“挂号系统”这个需求时第一反应是做个列表页患者选个医生、填个手机号、提交就完事。但诊所的实际业务流程根本不是这个逻辑。一个完整的社区诊所挂号闭环至少包含以下节点患者建档姓名、手机号、身份证、历史病历→ 排班读取医生哪天出诊、上午还是下午、每个时段放几个号→ 在线取号选择医生和时间段占用一个号源→ 排队叫号到号后诊所大屏显示、语音播报医生叫下一个→ 就诊记录医生开单、写病历与挂号单关联→ 复诊回访后续复诊要用到历史记录。如果只做“预约-确认”这一环系统上线后会发现诊室门口的秩序依然混乱。因为社区诊所的特色是“当天挂号当天看”现场和线上混合患者的到达时间不是预约时间而是“我来了就在队列里”。所以系统必须在挂号和排队之间建立一套状态流转机制。这也是我在设计时最核心的取舍挂号不是终点排队才是真正的业务核心。整个项目的模块划分可以这么看患者端小程序或H5页面注册登录、按科室找医生、查看剩余号源、在线挂号、查看当前排队位置。诊所端医生或护士操作查看今日号源、叫号/过号/完成、简单病历记录。管理后台科室管理、医生管理、排班管理、号源配置、挂号记录查询与统计。1.2 技术选型为什么最后锁定了ThinkPHP这个项目本来也可以用Java、Go或者Node.js做但最终选了ThinkPHP核心原因有几个第一开发效率。社区诊所的项目预算和周期都有限ThinkPHP的MVC结构、自带的ORM和验证器能让我在两周内把核心业务跑通。尤其是我个人习惯用它很多常规操作Auth权限、分页、API返回都有现成方案不用重复造轮子。第二生态成熟。ThinkPHP在国内的开发者基数很大遇到问题时搜索资料、找类库都非常方便。比如Excel导出、短信验证码、文件上传这些诊所系统里常见的功能都有现成的Composer包。第三部署轻量。诊所的服务器配置不会太高用ThinkPHP MySQL Nginx这套LAMP/LNMP组合就能稳定运行。相比动不动就要几个G内存的容器化方案这种传统PHP应用的部署成本低得多后续维护也简单。当然选ThinkPHP也意味着要接受它的历史包袱。网上搜索“ThinkPHP漏洞”能搜出一堆安全通告尤其是老版本被爆过远程代码执行漏洞。所以我在项目里直接使用了最新稳定版并且在安全层面做了一系列加固这个到后面第4章详细说。需要明确一点ThinkPHP没有传言的那么不安全“漏洞”大多集中在使用了无人维护的旧版本、开启Debug模式、暴露了后台路由这类操作问题上。框架本身只要用对版本、做对配置完全可以用在生产环境。1.3 数据库表结构与模型关系梳理数据库设计是整个系统的基础我最终拆了7张核心业务表外加2张辅助表。一张张过一下clinic_department科室表。字段有 id、name、sort_order、status。社区诊所一般科室不多内科、外科、儿科、中医科、全科等用排序值控制展示顺序。clinic_doctor医生表。字段有 id、department_id、name、title、avatar、intro、status。医生和科室是belongsTo关系一个科室有多个医生。clinic_schedule排班表。字段有 id、doctor_id、work_date、time_slot、total_num、surplus_num、status。time_slot一般分为AM上午、PM下午、EVENING晚间三个档位total_num是放号总数surplus_num是剩余号数。clinic_patient患者表。字段有 id、openid、name、phone、id_card、created_at。患者首次挂号时建档。clinic_register挂号单表。字段有 id、register_no、patient_id、doctor_id、schedule_id、visit_date、time_slot、queue_no、status、created_at。status有“待就诊、已叫号、就诊中、已完成、已过号、已取消”几种状态。clinic_queue排队叫号表。字段有 id、register_id、queue_no、status、called_count、called_at、finished_at。每张挂号单都会生成一条排队记录。clinic_medical_record就诊病历表。字段有 id、register_id、patient_id、doctor_id、diagnosis、prescription、created_at。辅助表是clinic_operation_log操作日志和clinic_sms_log短信发送记录。这两张表看着不起眼但线上出问题排查效率全靠它们。模型关系上用ThinkPHP关联定义很简单class Doctor extends Model { public function department() { return $this-belongsTo(Department::class, department_id, id); } public function schedules() { return $this-hasMany(Schedule::class, doctor_id, id); } }表中的外键关系保持简单清晰不要过度设计。诊所系统的数据量不会很大不需要分库分表正确使用索引就能满足查询需求。我给 schedule 表设置了(doctor_id, work_date)联合索引给 register 表设置了(schedule_id, queue_no)联合索引给 patient 表设置了openid唯一索引。2. 核心功能落地挂号、排队、关联删除的实现细节2.1 号源与排班先解决“有没有号”挂号系统的第一道关口是号源管理。号源由排班表衍生出来一个排班时间段对应一批号源。系统要做的是在医生排班时生成号源、在患者挂号时扣减号源、在号源不足时拒绝挂号。排班生成逻辑我放在了管理后台的排班控制器里。管理员选择医生、日期、时间段然后填一个放号总数系统就生成排班记录。这里有个容易被忽视的点同一医生的同一时间段不应该出现重复排班。我在数据库层面给(doctor_id, work_date, time_slot)加了唯一索引同时代码里也做了查重判断双保险。$exist Schedule::where(doctor_id, $doctorId) -where(work_date, $workDate) -where(time_slot, $timeSlot) -find(); if ($exist) { return json([code 1, msg 该医生在此时间段已有排班]); }设置surplus_num初始值等于total_num每次挂号成功后减1。查询医生列表时直接展示当天的剩余号数让患者心中有数。这里还有一个实用做法如果当天剩余号数少于5个前端标红提示“号源紧张”刺激患者尽快挂号。2.2 在线挂号事务与锁保证不超卖“不超卖”是挂号系统最核心的底线。社区诊所的患者往往集中在早上8点到10点之间涌进来同一时间可能有多人抢同一个医生的号。如果代码里只是“先查剩余号数再扣减”在高并发下一定会出现超卖——两个人同时查到剩余1个号都认为自己能挂上。ThinkPHP的ORM提供了lock(true)方法可以在查询时加排它锁SELECT ... FOR UPDATE。配合数据库事务就能保证号源扣减的原子性。我的挂号核心代码如下Db::startTrans(); try { $schedule Schedule::where(id, $scheduleId) -lock(true) -find(); if (!$schedule || $schedule-status ! 1) { throw new \Exception(该排班已停诊); } if ($schedule-surplus_num 0) { throw new \Exception(号源已满); } $schedule-surplus_num $schedule-surplus_num - 1; $schedule-save(); $queueNo $this-generateQueueNo($scheduleId); $register Register::create([ register_no date(YmdHis) . rand(1000, 9999), patient_id $patientId, doctor_id $schedule-doctor_id, schedule_id $schedule-id, visit_date $schedule-work_date, time_slot $schedule-time_slot, queue_no $queueNo, status 0, ]); Queue::create([ register_id $register-id, queue_no $queueNo, status 0, ]); Db::commit(); return json([code 0, msg 挂号成功, data [queue_no $queueNo]]); } catch (\Throwable $e) { Db::rollback(); return json([code 1, msg $e-getMessage()]); }注意几个关键点lock(true)必须是“当前事务内”的查询所以必须放在Db::startTrans()之后否则锁不生效。排它锁会锁住这一行其他事务要更新同一行时必须等当前事务提交。所以在高并发下大家串行处理同一个医生的号源自然就避免了超卖。生成排队号queue_no不是简单自增因为同一医生、同一天、同一时段的排队号才是连续的。我用的是“当日该时段最大排队号1”的逻辑。private function generateQueueNo($scheduleId) { $maxNo Register::where(schedule_id, $scheduleId) -max(queue_no); return $maxNo ? $maxNo 1 : 1; }诊所量级下这个方案够用。如果真要做秒杀级高并发可以换成Redis自增但社区诊所一天也就两三百个号没必要把架构搞复杂。2.3 排队叫号状态机驱动的前后端联动挂号完成后患者看到的是“您已成功挂号当前排队号是A023前面还有7位等候”。诊所端的屏幕上会显示当前叫到哪个号语音播报“请A023号张三到内科2诊室就诊”。这个排队机制本质上是一个状态机。我把挂号单和排队记录的状态统一成5个0待就诊挂号成功还没被叫到。1已叫号护士或医生点击叫号后患者需要到诊室门口等待。2就诊中患者进入诊室医生正在接诊。3已完成本次就诊结束病历已记录。4已过号叫号多遍未响应标记为过号。护士操作端页面很简单就是一个“下一个”按钮。点击后系统把当前最小的“待就诊”号改成“已叫号”同时通过WebSocket推送到患者端和大屏端。考虑到诊所的网络环境不一定支持WebSocket我退而求其次用了前端轮询每5秒请求一次接口拉取当前叫号状态。效果上虽然稍微有些延迟但稳定性和兼容性更好。过号处理是业务里的一个隐藏难点。很多人挂了号后去上厕所、停车、转账叫号没听到。如果专门等这个号后面的全被阻塞。我的处理策略是护士连点“过号”两次系统先把当前号标记为“已过号”再叫下一个号。过号的患者回来后在护士那登记系统会把他插入到当前正在就诊患者之后的“优先队列”。这个逻辑用一个is_priority字段标记而不是重新排一个靠前的固定序号避免出现“过号了还直接插到第2个”这种让其他患者不满的情况。2.4 关联删除的正确打开方式别把就诊记录删没了“ThinkPHP 关联删除”是网上搜这个框架时出现频率极高的词。很多人以为定义了模型关联后删除主表数据关联表数据会自动一起删掉。实际上ThinkPHP的关联删除分两种情况第一种是hasMany关联删除需要手动调用。比如你定义了一个科室有多个医生、一个医生有多个挂号单那删除科室时并不会自动把医生删掉除非你在模型里定义关联后删除时主动调用together。示例class Department extends Model { public function doctors() { return $this-hasMany(Doctor::class, department_id, id); } } // 删除科室时把关联医生也删掉 $department Department::with(doctors)-find($id); $department-together(doctors)-delete();第二种是真正的“数据库级联删除”在MySQL外键约束里设置ON DELETE CASCADE。这种方法最省事但隐患也最大——一旦误删科室所有关联的医生、排班、挂号记录会被瞬间清空根本无法恢复。我在这个项目里的建议是不要用物理删除用软删除。ThinkPHP的模型内置了软删除机制在模型里引入 SoftDelete 后调用delete()只是写入delete_time字段数据还在库里。查询时默认自动过滤掉已删除数据。use think\model\concern\SoftDelete; class Doctor extends Model { use SoftDelete; protected $deleteTime delete_time; }这样做的好处第一不会出现误删后找不回来的情况第二医生的历史挂号单、病历记录仍然完整保留这对医疗项目至关重要。你不能因为医生离职就把他看过的所有患者记录删掉——这既是业务要求也是合规底线。如果要删除一个科室正确做法是先检查该科室下是否有未完成排班如果有则提示“该科室仍有在排班医生不能删除”如果没有把科室和医生标记为停用状态status0而不是物理删除。上了软删除之后我甚至没有给业务表建任何物理外键约束完全在应用层做关联管理既灵活又安全。3. 从开发到部署小皮面板、运行目录与二级域名配置实操3.1 小皮面板一次性跑通ThinkPHP开发调试阶段我是用本地的集成环境跑的生产环境也是用的面板方式部署。目前使用小皮面板这类工具确实方便图形界面点几下就能把Nginx、PHP、MySQL全部装好省去手工编译的时间。但小皮面板跑ThinkPHP有个经典的坑运行目录必须指向 public 目录。ThinkPHP的入口文件在public/index.php如果你把站点根目录直接指向项目根路径你会看到整个目录结构被列出来甚至application下的源码直接暴露。正确配置方式在小皮面板左侧点击“网站”添加站点域名填你实际的域名。域名创建后在网站设置里找到“运行目录”选择public保存。确认伪静态设置。面板一般都自带ThinkPHP伪静态规则没有的话手动加上Nginx下的规则location / { if (!-e $request_filename) { rewrite ^(.*)$ /index.php?s$1 last; } }Apache下的规则写在.htaccess里IfModule mod_rewrite.c Options FollowSymlinks -Multiviews RewriteEngine On RewriteCond %{REQUEST_FILENAME} !-d RewriteCond %{REQUEST_FILENAME} !-f RewriteRule ^(.*)$ index.php?s$1 [QSA,PT,L] /IfModulePHP版本选择要根据ThinkPHP的版本来。ThinkPHP 6和8都要求PHP 7.2以上我实测PHP 8.1跑起来最顺畅选8.0或8.1都行别选PHP 5.x——只有老掉牙的ThinkPHP 3.x才跑5.x。面板部署还有一个容易被忽略的点刚用Composer安装完ThinkPHP后项目里的runtime目录必须是可写的。面板默认的权限是root但PHP-FPM执行用户是www会导致写入日志和缓存时报权限错误。我踩过一次之后养成了习惯每次部署完第一时间执行chown -R www:www /项目路径/runtime。3.2 前台与后台的二级域名拆分诊所系统分成患者端和管理后台两块。患者访问的是www.clinic.com管理员访问的是admin.clinic.com。这种情况下用二级域名拆分是很自然的做法。ThinkPHP从6.0开始支持多应用模式multi-app把前台和后台拆成两个应用目录每个应用有自己的控制器、模型、视图。目录结构大概这样app/ ├── common/ # 公共模块 ├── api/ # 患者端API应用 │ ├── controller/ │ ├── model/ │ └── validate/ ├── admin/ # 管理后台应用 │ ├── controller/ │ ├── model/ │ └── validate/启用多应用模式需要在config/app.php里打开auto_multi_appauto_multi_app true,然后处理二级域名。我推荐用ThinkPHP的域名绑定路由在route/app.php里配置Route::domain(admin.clinic.com, admin); Route::domain(www.clinic.com, api);这样访问admin.clinic.com自动加载admin应用访问www.clinic.com加载api应用不需要每台服务器单独配置入口。站点部署时在小皮面板里要把admin.clinic.com也作为站点添加运行目录依然指向public然后添加一个admin.php这样的入口文件不需要。多应用模式下同一个入口文件会根据二级域名自动切换到不同应用只要你确认Route::domain写对即可。需要留意一件小事跨域问题。患者端如果是H5页面很可能部署在另一个域名或端口上与后端API域名不同就会触发跨域。我在前端项目的nginx配置里加了跨域响应头或者直接用后端中间件处理public function handle($request, \Closure $next) { $response $next($request); $response-header([ Access-Control-Allow-Origin *, Access-Control-Allow-Methods GET, POST, PUT, DELETE, OPTIONS, Access-Control-Allow-Headers Content-Type, Authorization, ]); return $response; }3.3 部署上线前的检查清单项目定位是生产环境不是写个Demo跑通即可。部署上线前我按照以下清单逐项检查缺一不可确认APP_DEBUG为 false.env里配置好数据库连接。关闭目录列表Nginx配置里添加autoindex off;。确认日志目录runtime可写日志级别设成 error。确认MySQL备份任务已配置诊所数据不能丢。确认HTTPS证书已部署患者提交手机号、身份证信息必须加密传输。确认php think optimize:schema等优化命令已执行线上性能更稳。这套检查清单基本通用不管是不是ThinkPHP项目生产环境上线前照着过一遍都能避免低级事故。4. 安全加固被频繁曝出漏洞的ThinkPHP项目怎么防护4.1 ThinkPHP常见风险点自查网上每次搜ThinkPHP漏洞相关的内容都会看到各类远程代码执行通告。仔细研究后发现这些漏洞大多集中在一个非常老旧的版本上而且利用条件往往很苛刻——必须同时满足Debug模式开启、攻击者能控制路由参数等条件。但不管怎么说它确实给整个框架打上了“高危”标签所以要用ThinkPHP做生产项目安全配置必须格外用心。我从自己项目的角度整理了四个最容易出问题的点第一Debug模式没关。开发阶段为了看报错信息很多人习惯开着APP_DEBUGtrue。一旦上线忘了关访问一个不存在的路由时页面会直接抛出完整的堆栈信息包括数据库账号密码、项目绝对路径、PHP扩展列表。这是最基础的漏洞却也是最常见的。第二SQL注入。用户输入没经过参数绑定直接拼接进where条件。ThinkPHP的ORM其实默认做了参数绑定但有些人图省事用whereRaw拼SQL就容易出问题。我的建议是全程使用查询构造器或模型方法不用原生SQL。第三任意文件上传。诊所系统需要让医生上传头像、患者上传病历图片。如果上传接口没限制文件类型攻击者可能直接上传PHP一句话木马然后通过URL访问执行任意命令。解决思路是白名单校验、重命名文件名、禁止上传目录执行PHP脚本。第四后台弱口令。管理后台如果依然是admin/admin123这种组合等于给攻击者送分。必须强制使用强密码加上验证码和登录失败锁定策略。4.2 本项目实际落地使用的安全措施安全不能只靠框架的底层兜底应用层必须主动做防护。我在这套系统里落了这么几个措施验证器是ThinkPHP里非常好用的安全层。所有参数在进入业务逻辑前必须经过validate校验。比如挂号接口先校验schedule_id必须存在、必须是正整数再校验患者已经建档否则直接返回参数错误。class RegisterValidate extends Validate { protected $rule [ schedule_id require|number|gt:0, patient_id require|number|gt:0, ]; protected $message [ schedule_id.require 排班ID不能为空, schedule_id.number 排班ID不合法, patient_id.require 患者ID不能为空, ]; }CSRF防护。管理后台的表单操作全部加上令牌校验ThinkPHP的token验证器规则一行代码就能实现。我的做法是所有后台POST请求统一带_token参数提交时用验证器校验别有用心的人伪造跨站请求就进不来了。敏感信息加密存储。患者的手机号和身份证号不能明文入库。我的做法是在模型里加一个自定义修改器写入时自动加密读取时自动解密。用AES-128-CBC秘钥从项目环境变量里读取。class Patient extends Model { public function setPhoneAttr($value) { return openssl_encrypt($value, AES-128-CBC, env(DATA_KEY), 0, env(DATA_IV)); } public function getPhoneAttr($value) { return openssl_decrypt($value, AES-128-CBC, env(DATA_KEY), 0, env(DATA_IV)); } }这样即使数据库被拖走攻击者拿到的也只是密文而不是一长串真实手机号。防重复提交。患者可能在挂号页面连点两次提交导致生成两条挂号单。除了锁机制我还在前端做了按钮置灰后端加了表单Token校验。同一患者的同一排班如果已存在未取消的挂号单直接提示“您已挂过该时段号源”。这套组合拳落地之后项目上线至今没遇到安全问题。当然这不代表可以高枕无忧框架版本一旦发布安全更新就要及时跟进升级安全是持续过程不是一次配置就能一劳永逸的。5. 上线后我踩过的那些坑常见问题排查实录5.1 关联删除失效、外键约束冲突怎么办很多人会遇到这个问题在模型里定义好了hasMany关联然后删除主表数据报“Cannot delete or update a parent row: a foreign key constraint fails”的数据库错误。原因很直接——MySQL外键约束还在但你用的是物理删除。我刚才说过业务表最好不要建物理外键约束改成软删除后这个报错自然消失。但如果你在做成旧项目改造物理外键已经存在了有两个处理办法一是把外键改成ON DELETE SET NULL删除主表时子表外键字段置空二是干脆删掉外键约束应用层用代码保证数据一致性。我强烈建议第二种数据库外键在分布式和微服务场景下是灾难靠应用层事务管理更灵活。5.2 挂号并发与重复数据项目上线后某天早上护士打电话过来说有患者反映“我明明挂到了号到现场说没有我记录”。查日志发现同一患者同一时段确实生成了两条挂号单。原因是我给(schedule_id, queue_no)加了唯一索引但没给(schedule_id, patient_id)加限制。患者用两个设备同时提交两个请求都没检查到对方的存在直接都进了库。解决办法就是在数据库层面加联合唯一索引代码检查永远有遗漏的可能但数据库约束一定拦得住ALTER TABLE clinic_register ADD UNIQUE INDEX uniq_schedule_patient (schedule_id, patient_id);另外这个案例也提醒我像lock(true)行锁这种方案必须在被锁事务提交前完成状态检查别在事务里做外部接口调用比如发短信否则锁等待时间过长系统会变成“看起来卡死了”的状态。5.3 伪静态、404和跨域问题速查我把部署期间遇到的高频问题整理成了一个速查表方便后面复用现象原因解决方式打开域名直接展示项目目录文件列表运行目录没有指向public在小皮面板“网站设置-运行目录”里选public除首页外所有路由都404伪静态规则未配置确认nginx/apache已加ThinkPHP伪静态规则接口请求返回302跳转到登录页全局中间件拦截了未登录请求在中间件白名单里放行登录接口和患者端公开接口前端请求接口被浏览器拦截跨域前后端不同域名后端中间件添加CORS响应头删除医生后患者查看历史挂号单报错物理删除导致关联记录丢失改用软删除SoftDelete机制并发挂号时出现重复数据缺少唯一索引在表上添加联合唯一索引这套系统的开发经验里我最想强调的一个习惯是每张业务表都加上 created_at、update_time、delete_time 这三个时间字段用软删除代替物理删除。项目刚开始可能看不出区别等真到了线上需要恢复数据的那一天你就明白这个决定有多重要了。另外如果想在这个项目基础上继续扩展可以做诊间支付、电子病历共享、药品库存关联这些模块。数据表设计只要预留好关联字段后面的扩展基本不会伤筋动骨。总之社区诊所挂号排队系统不是难在技术而是难在业务流程的边界把握——想清楚号源、队列、状态机这三个核心概念整个系统的大盘就稳了。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。