
各大社区医院年前的挂号软件往往只解决“能挂上号”这一个动作但真正的痛点在于患者不知道自己该挂哪个科挂完号后不知道下一步去哪医生临时停诊也没人及时通知。我在本地从零跑通了一套智慧社区医院医疗挂号服务导诊平台后端同时用了ThinkPHP和Laravel两套框架把挂号、排班、导诊、订单、对账这些模块完整串了起来。这篇文章把整体架构、核心业务实现思路、以及调试过程中遇到的实际问题都写出来给要做类似系统的人一个参考。1. 为什么一套系统里会同时出现ThinkPHP和Laravel1.1 这不是炫技是历史包袱加现实分工看到“ThinkPHP和Laravel”同时出现很多人第一反应是干嘛不用一套框架甚至怀疑是代码混乱。但真实项目里这种混用状态其实很常见尤其是医疗、政务这类信息化起步早的行业。早期系统往往用ThinkPHP快速搭起来后台功能都是标准CRUD做管理的同事也熟悉这套写法改起来效率高。后来要面向患者提供小程序和H5服务情况变了需要统一的认证体系、中间件、队列、事件系统需要承受比后台高得多的并发量这时候Laravel的工程化能力更合适。所以这项目的边界很直接ThinkPHP负责管理后台做科室管理、医生信息维护、排班模板设置、订单统计查询Laravel负责所有面向C端的API做小程序登录、号源查询、挂号下单、导诊推荐、支付回调、订单状态流转。两个框架不搞微服务也不做RPC调用就是共用同一个MySQL数据库各自操作自己负责的那几张表。1.2 双框架的边界谁写哪张表谁动哪个字段双框架共库最大的风险是两边同时写同一行数据导致逻辑冲突。我一开始就定了三条强约束开发过程中谁也不能破科室、医生、排班模板这类基础配置数据只能由ThinkPHP后台写入和修改。挂号订单、支付流水、号源余量扣减只能由Laravel的API层操作。排班生成后医生坐诊日期的状态字段未开诊、停诊、替诊由后台TP接管但从“待支付”到“已支付”的订单状态变化Laravel说了算。这里有一个比较棘手的场景后台医生临时停诊需要批量把已挂号患者的订单取消并退款。如果TP直接去改订单表就破坏了唯一入口原则。我的做法是TP端调用Laravel暴露的一个内部Webhook接口带签名校验由Laravel在事务里完成订单取消、号源释放、退款批次生成这三个动作再通知小程序端。签名用两边共享的secret生成请求IP做白名单限制内部接口不对外开放这样既保住了双框架共库的主线又能跨框架触发业务动作。1.3 两个框架协调开发时最容易吵起来的约定混用框架代码怎么写不是最大的问题口径统一才是。我踩过的几个细节提前约定能省大量返工。第一是时间字段。ThinkPHP的Model默认输出datetime格式的字符串Laravel的Eloquent默认会转成Carbon对象如果两边直接用原始字段拼接查询条件很容易出现格式对不上。这个项目里所有业务表的时间字段统一存datetimeLaravel的模型里对应字段不加$dates就按字符串处理排班日期和订单时间全部用Y-m-d H:i:s格式传参。第二是状态字段的枚举值。挂号状态、支付状态、停诊标志在两套框架里各写一份常量类本身没问题但必须保持一致。比如挂号状态我用0待支付 1已支付待就诊 2已就诊 3已取消 4违约TP后台的筛选下拉和Laravel的接口返回都读同一个枚举说明文档谁要改枚举值必须同步改两边的常量文件并在代码里留注释。第三是字段输出风格。TP后台页面反正自己人用snake_case无所谓但Laravel接口是给小程序前端用的我统一在Laravel的API Resource里把字段转成snake_case返回避免前端拿到的字段一会儿是schedule_id一会儿是scheduleId。前端只认Laravel接口的返回结构TP后台不做接口输出这块没出过乱子。2. 挂号主链路的三个关键环节排班、扣号、释放2.1 排班模板与号源批量生成医院挂号系统的第一环不是挂号而是排班。社区医院的医生数量不会特别多但排班规则复杂同一位医生可能周一三五上午在普通门诊周二下午在慢病门诊节假日还要停诊。手工一条条建排班不现实我用模板批量生成。核心表就两张一张是排班模板表一张是号源表也就是具体的排班日期记录。建表的核心字段大概是这样CREATE TABLE schedule_template ( id int unsigned PRIMARY KEY AUTO_INCREMENT, doctor_id int unsigned NOT NULL COMMENT 医生ID, week_map varchar(32) NOT NULL COMMENT 周班次如1,3,5表示周一、三、五, time_type tinyint NOT NULL COMMENT 1上午 2下午 3晚上, start_time time NOT NULL COMMENT 出诊开始时间, end_time time NOT NULL COMMENT 出诊结束时间, total_slots int NOT NULL COMMENT 号源总数, status tinyint NOT NULL DEFAULT 1 COMMENT 1启用 0停用 );CREATE TABLE doctor_schedule ( id int unsigned PRIMARY KEY AUTO_INCREMENT, doctor_id int unsigned NOT NULL, schedule_date date NOT NULL, time_type tinyint NOT NULL, start_time time NOT NULL, end_time time NOT NULL, total_slots int NOT NULL, used_slots int NOT NULL DEFAULT 0, status tinyint NOT NULL DEFAULT 1 COMMENT 1正常 2停诊 3替诊, source_rule varchar(64) DEFAULT COMMENT 来源模板ID便于追溯 );生成逻辑不复杂后台设定好模板后用一个定时任务按模板生成未来14天的号源。具体到代码层面ThinkPHP端用console命令在每天凌晨跑一次先把未来第14天的排班补出来再处理一遍假期例外表。假期例外表存的是“某天某医生停诊”的覆盖规则优先级最高生成号源时直接跳过。// ThinkPHP 定时生成号源示例 $startDate date(Y-m-d, strtotime(1 day)); $endDate date(Y-m-d, strtotime(14 day)); $templates ScheduleTemplate::where(status, 1)-select(); foreach ($templates as $template) { // 根据week_map判断本周哪些天坐诊 $weekDays explode(,, $template-week_map); for ($i 0; $i 14; $i) { $date date(Y-m-d, strtotime($startDate {$i} day)); if (!in_array(date(N, strtotime($date)), $weekDays)) { continue; } // 检查假期例外表如果命中停诊则跳过 if (ScheduleHoliday::where(doctor_id, $template-doctor_id) -where(schedule_date, $date)-count()) { continue; } // 防止重复生成按医生日期时段唯一判定 $exists DoctorSchedule::where(doctor_id, $template-doctor_id) -where(schedule_date, $date) -where(time_type, $template-time_type) -count(); if ($exists) { continue; } DoctorSchedule::create([ doctor_id $template-doctor_id, schedule_date $date, time_type $template-time_type, start_time $template-start_time, end_time $template-end_time, total_slots $template-total_slots, used_slots 0, source_rule $template-id, ]); } }批量生成号源之后前端小程序查号源就是一条简单查询指定日期有没有排班used_slots total_slots就说明还有号剩余号数直接两个字段相减。2.2 号源扣减条件更新比查出来再减更稳号源扣减是整个系统最需要小心的地方。社区医院特点是号源总量不大但早高峰会有集中抢号不处理并发就会超卖。最常见的错误写法是“先查剩余号数判断大于0再更新扣减”这在并发请求下会出大问题两个请求同时查到used_slots5都认为还有号最终都执行了used_slots1实际可能就超卖了。我推荐直接用MySQL的条件更新把判断和扣减在一条SQL里完成靠受影响行数判断是否成功// Laravel API 挂号下单的核心代码 use Illuminate\Support\Facades\DB; DB::transaction(function () use ($scheduleId, $userId, $patientId, $uuid) { // 幂等判断同一个uuid只能创建一单 $exists RegistrationOrder::where(request_uuid, $uuid)-lockForUpdate()-first(); if ($exists) { throw new \Exception(duplicated_request); } // 条件扣减号源used_slots total_slots 才能成功 $affected DB::table(doctor_schedule) -where(id, $scheduleId) -where(status, 1) -whereColumn(used_slots, , total_slots) -update([ used_slots DB::raw(used_slots 1) ]); if ($affected ! 1) { throw new \Exception(no_slot_available); } RegistrationOrder::create([ order_no date(YmdHis) . mt_rand(1000, 9999), request_uuid $uuid, user_id $userId, patient_id $patientId, schedule_id $scheduleId, visit_date $visitDate, visit_time $visitTime, order_status 0, // 待支付 pay_status 0, ]); });这段逻辑里有三个关键点第一request_uuid是前端在用户点击“提交挂号”时生成的唯一串每次点击生成一次。用户手抖连点两次两个请求的uuid相同第二次就会被幂等判断挡住不会产生两笔订单。这是支付场景里常用的手法直接挪到挂号下单上一样适用。第二whereColumn(used_slots, , total_slots)把“还有号”的判断下推到数据库执行并发情况下MySQL的行锁会保证同一时刻只有一个事务能成功更新这一行第二个事务更新影响行数为0系统直接提示“号源不足”。这比先查后改稳得多也避免了自己写锁的麻烦。第三扣减号源和创建订单放在同一个事务里两者要么都成功要么都失败。如果创建订单失败前面扣掉的号源会因为事务回滚自动恢复不用写额外的补偿逻辑。2.3 超时释放和被取消恢复号源用户提交挂号后不一定马上支付如果号源一直被占着其他患者就挂不上了。社区医院医生本来就紧张号源资源浪费是大忌。我用Laravel的延迟队列做超时取消用户下单后15分钟内未支付队列任务自动把订单状态改为“已取消”同时把对应的used_slots减回。这里要注意释放号源也要放在事务里只恢复那种“已取消”的待支付订单。已支付订单如果要退款取消走了另一条链路涉及支付渠道退款不能简单把号加回去。// Laravel 延迟队列处理超时未支付的订单 public function handle() { $order RegistrationOrder::where(order_no, $this-orderNo) -where(order_status, 0) -where(pay_status, 0) -first(); if (!$order) { return; } DB::transaction(function () use ($order) { $order-order_status 3; // 已取消 $order-cancel_reason timeout; $order-save(); DB::table(doctor_schedule) -where(id, $order-schedule_id) -where(used_slots, , 0) -decrement(used_slots, 1); }); }用户主动取消的逻辑也类似只是入口换成了API接口。不管是超时还是主动取消只要订单没支付号源恢复的动作必须和订单取消动作保持事务一致。3. 导诊服务从关键词命中到科室推荐再到急危重拦截3.1 规则表用一张表搞定“症状到科室”映射导诊是这个平台区别于普通挂号系统的核心功能。患者知道自己不舒服但说不清挂哪个科导诊要解决的就是“让患者找对科室”的问题。最开始我想得很复杂考虑过引入分词工具、做自然语言处理模型。后来一评估社区医院常见病症状就那么一百多种用关键词规则表完全可以覆盖90%以上的场景而且规则可维护、可解释后台运营人员自己就能改比模型黑盒好使多了。规则表结构相当简单CREATE TABLE guide_rule ( id int unsigned PRIMARY KEY AUTO_INCREMENT, keyword varchar(32) NOT NULL COMMENT 症状关键词, dept_code varchar(32) NOT NULL COMMENT 推荐科室编码, priority int NOT NULL DEFAULT 0 COMMENT 优先级越大越靠前, status tinyint NOT NULL DEFAULT 1, INDEX(keyword) );匹配逻辑是把用户输入的症状文本做简单清洗去掉标点和无意义词然后按关键词表逐条命中命中的科室按优先级排序同一个科室命中的关键词越多优先级越高。比如用户输入“最近总是心慌胸闷还有点没力气”文本里拆出“心慌”“胸闷”两个词都命中心血管内科命中次数2优先级就比只命中1个词的高排到推荐第一位。常用映射我整理了一张表算是整个导诊模块的基础数据症状关键词推荐科室优先级发热、咳嗽、咽痛、流涕、咳痰呼吸内科10心慌、胸闷、气短、心悸心血管内科10腹痛、腹泻、反酸、嗳气、烧心消化内科10头晕、头痛、失眠、肢体麻木神经内科10皮肤瘙痒、皮疹、红斑、起痘皮肤科10尿频、尿急、尿痛、血尿肾内科/泌尿外科10关节疼痛、腰腿痛、晨僵风湿免疫科/骨科10外伤、扭伤、骨折、伤口出血外科10耳鸣、耳痛、听力下降耳鼻喉科10眼红、眼痛、视力模糊眼科10规则表里每一条都可以设置是否启用到后期可以根据发病率调整优先级比如流感季把呼吸内科的权重调高这让导诊模块在运营上有了很长的迭代空间。3.2 结构化问诊让推荐更接近实际分诊光靠关键词匹配导诊还是有点“碰运气”。我加了三个结构化问诊步骤不是让患者在输入框里自由打字而是先问几个选择题系统拿着答案结合关键词再做一轮过滤。一遍问诊流程是这样的选主要症状从常用症状列表里点选支持多选。回答两个附加问题体温是否超过38.5度症状持续几天。系统把上一轮命中的科室列成候选再按规则表做第二轮排序最后返回“推荐科室”和“可挂号时段”。为什么这一步很关键举个例子同样是“发热”如果体温超过38.5度且持续3天以上优先推荐呼吸内科同时给出发热门诊的指引如果体温不到38.5度、症状刚起一天推荐科室可能以普通内科为主。有了结构化信息导诊结果从“看似相关”变成了“有依据”这个差异对患者体感非常直接。实现上也不用多高级就是Laravel的一个POST接口接收症状ID数组、体温档位、持续天数在Service层走一遍规则匹配返回科室列表。3.3 急危重症拦截这条线不能省导诊模块里最不能少的一块不是推荐科室而是识别急危重症。医疗行业的导诊本质是分诊严重疾病的识别优先级永远高于常规分诊。我建了一个危险关键词词库胸痛伴大汗、呼吸困难、意识模糊、抽搐、大出血、剧烈腹痛、外伤大量出血。用户输入或者选择症状时只要命中这些高危组合系统不推荐任何普通科室直接全屏提醒请立即前往急诊科或拨打急救电话并展示就近急救路线提示。这个提示页面上不放任何挂号引导按钮。哪怕拦错的概率存在也宁可错拦不能漏拦。这个逻辑上线前还专门和小程序端对齐过危险词命中时不输出任何科室推荐不允许患者“换个方法继续挂号”。从产品和医疗安全角度看这一步都是硬性要求不能为了转化率绕过去。4. 功能多会不会乱数据表与状态字段的设计原则4.1 核心表清单与数据关系这平台功能确实多科室、医生、排班、挂号、导诊、支付、对账、患者档案全都挤在一个库里表设计要是没条理后面写接口简直灾难。我把核心表拆成两组一组是基础数据一组是业务流程两组之间的关联尽量少避免跨组操作。基础数据组包括科室表department、医生表doctor_info、排班模板表schedule_template、号源表doctor_schedule、患者档案表patient_profile、导诊规则表guide_rule。业务流程组包括挂号订单表registration_order、支付流水表payment_flow、支付回调记录表pay_callback_log、停诊记录表schedule_suspension、站内信通知表message_notice。表关系上挂号订单通过schedule_id关联号源表通过patient_id关联患者档案医生表和科室表通过dept_code关联。这里有个设计细节订单表里除了外键字段我还冗余存了一份doctor_name、dept_name、visit_date、visit_time这种展示字段。原因是社区医院门诊量不大但报表查询频繁如果每次展示都要JOIN五张表后台页面容易卡。冗余字段会让数据一致性维护多花点心思但从实际性能体感来说值。4.2 状态和金额字段的统一约定状态字段是整个系统最容易混乱的地方。我统一了两种状态枚举全平台都沿用这一套挂号订单状态order_status值含义说明0待支付已占用号源等待用户支付1已支付待就诊支付成功等待就诊2已就诊医生完成接诊订单闭环3已取消用户取消/超时取消/停诊取消4违约预约未支付且未取消记录违约支付状态pay_status值含义0未支付1支付成功2已退款3退款失败金额字段一律用整数分存储比如挂号费10元存1000。PHP浮点数做加减乘除容易出精度问题特别是退款这种涉及钱进出的场景用分存储就能保证金额不会飘。接口层需要展示元的时候除以100转成字符串就好。订单号生成也有讲究我用的规则是年月日时分秒4位随机数但这里有个细节高并发下随机数可能重复所以订单号最终在数据库里建立了唯一索引重复时重新生成。更严格的做法是引入独立发号器这个项目量级用不上。4.3 统计口径与对账两套框架不能有分歧管理后台经常要看日报今日挂号量、支付金额、取消订单数、就诊人数。如果TP后台直接查订单表Laravel接口统计也查订单表两边SQL写得不一样数字就会对不上。解决方式很土但很有效统一由Laravel在每天早上跑一个定时任务把前一天所有订单、支付流水汇总成一张daily_report表后台报表页面只读这张表。口径完全一致不会出现TP统计的是“下单数”、Laravel统计的是“支付数”这种分歧。日报表字段大概这样CREATE TABLE daily_report ( id int unsigned PRIMARY KEY AUTO_INCREMENT, report_date date NOT NULL, appointment_count int NOT NULL DEFAULT 0 COMMENT 下单数, paid_count int NOT NULL DEFAULT 0 COMMENT 支付数, completed_count int NOT NULL DEFAULT 0 COMMENT 就诊数, cancel_count int NOT NULL DEFAULT 0 COMMENT 取消数, total_amount int NOT NULL DEFAULT 0 COMMENT 支付总金额(分), refund_amount int NOT NULL DEFAULT 0 COMMENT 退款总金额(分), UNIQUE(report_date) );5. 上线前最值得盯的几个坑5.1 死锁与并发扣号压测一跑就现原形开发环境单机测试一切正常并发一上来第一个碰到的问题就是Lock wait timeout exceeded。原因是我在事务里先查了订单表做幂等判断再更新号源表两个表都有行锁多个请求如果按不同顺序获取锁就会形成死锁等待。解决办法是让事务里的加锁顺序保持固定先处理号源表行锁再做订单幂等判断。具体实现是把request_uuid唯一索引加上先插入订单如果插入时遇到唯一键冲突就抛“重复提交”这样幂等判断就不需要先用lockForUpdate查询顺序自然变成扣号源、插订单两个请求争用同一行号源时后到的请求排队等锁依次执行不再死锁。这个坑给平台的启示是开发阶段必须建一个并发测试用例模拟同一个号源被100个用户同时提交只看结果还不够要连带看数据库的错误日志死锁经常不影响最终结果但会让响应时间飙升到不可接受。5.2 停诊链路取消订单、退款、通知一个都不能漏医生临时停诊是在医院场景里无法避免的事件。停诊的完整链路是后台TP标记排班为停诊调用Laravel WebhookLaravel事务内把所有该时段已支付订单置为取消生成退款批次通知小程序端用户。这个链路里最容易出问题的是退款。支付渠道的退款接口可能失败失败后订单状态却是“已取消”了患者前端看到订单没了但钱没回来。我的处理是退款失败的记录进refund_fail_log表状态置为“退款失败”后台对账页面单独列出这一批运营人员可以人工点击重试退款。同时患者端在“我的订单”里仍然能看到原订单但标注“停诊取消退款处理中”。另一个细节是短信通知。停诊发生得越晚通知的紧迫性越强。如果医生早上上班前临时说不来系统要在几分钟内批量发送通知我用的Laravel队列的high优先级队列处理站内信短信双通道保证消息及时到位。5.3 双框架接口输出的字段风格不一致这个问题开发时不一定暴露联调小程序时会反复响。TP后台页面没有接口输出不涉及。但Laravel接口如果直接返回Eloquent模型查询列表时会带出一堆非预期字段比如deleted_at、created_at这种前端根本不需要的东西。而且字段名默认跟随表列名全是snake_case前端直接拿不到“剩余号数”这种计算字段。我做了一层API Resource封装每个核心实体都有一个对应的Resource在toArray里明确列出要返回的字段顺带把计算字段一并算好。例如号源列表的Resource里输出remaining_slots total_slots - used_slots前端不用再自己减。前端对接只看接口文档而接口文档就是一份Resource清单联调效率明显提升。5.4 预留医保对接与扩展位别等上线再改表社区医院没有医保接口联调整个系统就少了最重要的闭环。但医保平台对接往往要专门立项周期长容易卡住整体上线。我的做法是预留好扩展位而不是等接口联调完成再开发。在挂号订单表里预留medical_insurance_flag、insurance_type、insurance_no三个字段支付流水表里预留channel_type区分自费和医保。小程序端在支付方式选择页先提供“自费”和“医保”两个选项医保选项暂时置灰并提示“即将开通”。这样等到医保接口真接入时数据库层面不用改表只要把支付模块的渠道分支补上即可。这块经验说起来简单但做的时候确实容易想着“以后再说”真到上线时医保功能一催临时加字段加逻辑又要连带改订单状态机成本翻倍。这套平台的核心链路我从排班生成一直跑通到停诊退款积累了这次双框架共库的实操经验。整体体会是并不是功能越多的系统就越要上微服务社区医院医疗挂号导诊这种场景两套框架按边界分工反而清爽——ThinkPHP承接后台管理的高效迭代Laravel承担C端接口的工程化要求。如果看完也要动手搭一套建议从排班和号源起步先把挂号主链路跑通再补导诊和支付功能多不等于步子大稳着来才能把坑一个个排干净。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。