☰
医院预约挂号系统设计与实现:从并发控制到毕业答辩全指南
2026/10/21 8:05:55 网站建设 项目流程

1. 这个"烂大街"的毕设题目,到底在考你什么

每年毕业季前后,我都能碰到几拨来问医院预约挂号系统的同学。有的是导师直接给了这个题,有的是在"Python毕设推荐"清单里看到它,还有的是从网上下载了一堆号称"全套源码+文档"的压缩包,结果解压完连环境都跑不起来。这个题目年年有人做,表面看就是一个普通的Web系统,但真把它从"能演示"做到"能答辩",需要的东西其实不少:用户认证、角色权限、数据建模、事务并发、状态机、部署调试,几乎把Web开发的地基全部过了一遍。

先把业务边界说清楚。一个能拿去答辩的医院预约挂号系统,至少要覆盖三类角色:

  • 患者:注册登录、浏览科室和医生、查看排班、预约挂号、取消预约、查看历史记录
  • 医生:查看自己的出诊排班、查看预约患者、标记就诊状态
  • 管理员:维护科室、维护医生、维护排班、查看预约统计

很多人拿到题目的第一反应是"这不就是增删改查嘛"。这话说对了一半。增删改查只是底座,真正拉开差距的是"预约"这个动作背后的规则:一个医生一个时段,号源就那么多,两个人同时发起预约,系统凭什么把号给其中一个人而不是另一个人?这就是标题里"设计与实现"中"设计"两个字的分量。

我拆解过不少网上下载的源码包,页面看着五脏俱全,但预约表十有八九没有唯一约束,后端不加锁,靠前端按钮禁用冒充防重复提交。这种项目演示没问题,被评委追问两句就露馅。所以这篇文章我不打算贴一份现成代码让你复制,而是把做这类系统的完整思考链路整理出来:表怎么建、预约怎么防超卖、状态怎么流转、部署踩了哪些坑、文档和答辩怎么准备。你照着这个思路自己写一遍,远比背一份陌生代码更稳。

1.1 需求拆解:把"挂号"翻译成技术动作

把业务需求翻译成技术动作,是这个项目的第一道考题。"患者看医生排班"对应的是"按科室查医生、按医生查排班、按排班查余量";"预约挂号"对应的是"校验身份、校验排班状态、校验余量、防重复、落库、更新余量";"取消预约"对应的是"校验状态、释放号源、记录取消时间"。这些一一对应的映射,在需求分析阶段就要列清楚,因为它们直接决定后面建几张表、写几个接口。

另一个容易被忽略的点是时间窗口。不是所有时刻都能预约:出诊前一天的排班能不能约?就诊时间已经过了还能不能取消?爽约怎么判定?这些规则最好前期用文字定死,落到代码里就是几个简单的状态判断;如果前期不定,写到后面就是到处都是补丁逻辑,自己都说不清为什么这里能约那里不能约。

1.2 别被"全套源码"带偏节奏

市面上那些"全套源码+文档+远程调试+讲解+定制"的套餐,卖的本质是解决问题的时间,不是代码本身。代码拿到手不消化,答辩照样过不了。我的建议是:源码可以看,但要带着问题看——它的表是怎么设计的?它在哪里加锁?它的状态机是怎么定义的?把它当成参考教材,而不是交付物。你真正要交付的,是你自己脑子里那套能讲清楚的设计,这也是我下面每个章节都把"为什么这么做"讲透的原因。

2. 技术选型:为什么我坚持用Django而不是Flask

2.1 三套框架的实际体感

Python做Web后端,主流就是三个选择:Django、Flask、FastAPI。三套我都实际写过,分别说下在"毕设场景"下的真实感受。

Django是全家桶路线:ORM、Admin后台、用户认证、表单、分页、消息框架全是自带的。学习曲线前两三天略陡,一旦跨过"路由-视图-模型"那道坎,后面开发速度会非常快。这正好匹配"一个人、一个学期、做一个完整系统"的毕设节奏。

Flask是微框架,核心就路由加WSGI,什么都要自己装。用Flask意味着你自己选ORM(通常是SQLAlchemy)、自己搭用户认证(Flask-Login之类)、自己拼后台管理页面。它能证明"我懂底层原理",代价是工作量直接翻倍,而且各种扩展的版本兼容问题能折磨你好几天。

FastAPI最近很火,异步高性能、自带接口文档,确实香。但它的异步ORM体系、Pydantic模型、依赖注入这些概念,对没毕业的同学来说上手门槛偏高。非要用也不是不行,但要有心理准备:大部分资料是英文的,踩到的坑也相对冷门,答辩时评委还不一定买账。

我的结论是:除非导师指定或者你有明确的炫技需求,否则选Django。毕设的评价核心是业务逻辑和系统设计,不是框架有多新。Django把认证、Admin、ORM这些最烧时间的地基打好了,省下来的时间正好花在预约并发、排班建模这些真正的难点上。

2.2 前后端分离:能不做就不做

这里有个常见分叉口:用Django模板渲染,还是Django写API、前端用Vue或React?

我明确建议选前者。理由有三个。第一,模板渲染开发速度快,Django模板加Bootstrap,一个页面从零到能看基本是小时级别;前后端分离的话,至少要多处理跨域、Token认证、前端路由、打包部署一堆事。第二,预约流程需要的动态交互其实不复杂,选科室后动态加载医生、查看某天余量,用Ajax就能优雅解决,没必要上完整的前端工程。第三,答辩风险低——前后端分离一旦做了,评委大概率追问"你的Token过期策略是什么""跨域怎么配的""前端打包后怎么部署",每一个都是深坑。

当然,如果你确实想给项目贴上"前后端分离"的标签,我建议用DRF提供API、Vue3做前端,然后把JWT认证和CORS配置写进文档,至少有自圆其说的余地。但对大多数同学来说,模板渲染 + 局部Ajax是投入产出比最高的方案。

2.3 数据库:从SQLite起步,到MySQL收尾

开发阶段我强烈建议直接用SQLite。零配置、单文件、拷来拷去方便,非常适合前期调试。但必须清醒一点:SQLite和MySQL在事务模型、锁行为上有明显差异,所以并发相关的验证不能只信SQLite的结果。

我的做法是:开发期SQLite跑功能,项目收尾前切换到MySQL做一轮完整的并发和压力测试。切换时重点确认三件事:字段类型是否兼容(尤其是DateTime的时区表现)、字符集是否统一为utf8mb4、事务隔离级别是否确认。这些在毕设文档里写一句"开发环境SQLite、生产环境MySQL"就能体现工程意识,但前提是你真的在两套环境里都跑通过。

提示:Django连MySQL需要mysqlclient,Windows下直接装这个库经常报错,建议下载预编译的whl文件安装,能省掉一晚上折腾编译器的痛苦。

3. 数据库建模:五张核心表怎么把业务串成闭环

3.1 表结构:从用户到预约的一整条链

我把核心模型收敛成五张表:用户、科室、医生、排班、预约。下面这张表是整套系统的地基,字段取舍都有讲究。

表名核心字段说明
用户表(扩展Django自带)username, password, role, phonerole区分患者/医生/管理员
departmentsname, location, description, status科室基础信息
doctorsuser_id, dept_id, title, specialty, introduction关联用户表和科室表
schedulesdoctor_id, work_date, time_slot, total_slots, booked_slots, status医生出诊排班与号源
appointmentspatient_id, schedule_id, doctor_id, appointment_date, time_slot, status, created_at, canceled_at预约记录

用户表这里我要重点说。很多新手会把"患者表"和"医生表"分开建,结果登录逻辑变得很别扭——患者走一套,医生又要另写一套认证。正确做法是统一用Django的auth_user存账号和密码,加一个role字段区分身份,医生独有的职称、擅长领域等信息放doctors表,通过OneToOne指向auth_user。这样Django自带的authenticate、login、logout、request.user全部复用,代码量小一个量级。

appointments表里我故意冗余了一个doctor_id。明明通过schedule就能找到医生,为什么还存一份?因为"我的预约列表"要频繁按医生展示,直接按doctor_id过滤,能少做两次join。这种适度冗余就是数据库里的"反规范化",写进文档里,能看出你懂设计取舍,而不是只会照本宣科建范式表。

3.2 排班与号源:两种建模方式的取舍

排班是整个系统里最需要想清楚的部分。我见过两种主流做法。

第一种是"号源预生成"。排班时直接生成N条号源记录,每条对应一个可预约名额,比如"张医生 周二上午 10:00-10:30"一条、"10:30-11:00"一条。预约时锁定某一条号源,逻辑简单直观。缺点是数据量大,改排班要级联处理一大堆子记录。

第二种是"排班+计数"。schedule表只存"张医生周二上午出诊",用total_slots表示总号源数,booked_slots表示已约数,余量就是两者之差。预约时判断booked_slots小于total_slots。优点是灵活,管理员临时加号直接把total_slots加1;缺点是并发控制难做。

我最终选的是第二种。因为医院的排班粒度通常是"上午门诊""下午门诊"这种粗粒度,而不是精确到每个人15分钟;而且毕设系统里"加号""停诊"操作很常见,计数模型的维护成本最低。代价就是预约模块必须把并发控制做扎实,这正是下一章要解决的问题。

3.3 用户角色与权限:一个role字段加装饰器就够了

Django自带完整的Group和Permission,但毕设场景我更推荐一个更轻的方案:用户表加role字段,取值是patient、doctor、admin,再写一个装饰器统一做权限校验。

from functools import wraps from django.shortcuts import redirect from django.http import HttpResponseForbidden def role_required(roles): def decorator(view_func): @wraps(view_func) def wrapper(request, *args, **kwargs): if not request.user.is_authenticated: return redirect('login') if request.user.role not in roles: return HttpResponseForbidden("无权访问") return view_func(request, *args, **kwargs) return wrapper return decorator

用法是在视图上标注 @role_required(['patient'])。整套逻辑不到20行,完全透明,答辩时从路由到装饰器一路讲下来没有任何黑盒。相比之下,用Django Group方案要解释组和权限的关联关系,反而把简单的事说复杂了。

权限这里还有个细节:管理员的登录入口我单独做了一个URL,模板也单独继承管理端基类,跟患者端的页面结构分开。三条业务线的界面互不干扰,后面做菜单级权限控制也方便。

4. 预约核心逻辑:事务、行锁和状态机的组合拳

4.1 一次预约请求的完整生命周期

预约是整个系统的心脏。我拆成六个步骤,一步都不能少:

  1. 校验登录状态和角色,必须是已登录的患者
  2. 校验排班存在且可预约,状态为open且在可预约窗口内
  3. 校验该用户在这个排班下没有未结束的预约,防止重复
  4. 校验号源余量,booked_slots小于total_slots
  5. 在事务内更新余量并插入预约记录
  6. 返回结果,前端跳转到"我的预约"

网上很多源码的问题就出在第3步和第4步。漏掉第3步,用户连续点提交就会生成一堆重复预约;第4步如果判断和更新之间没有原子性,并发请求同时通过判断,号源直接超卖。这两个问题,就是评判一个预约系统"有没有设计过"的分水岭。

4.2 用select_for_update锁住排班记录

解决并发超卖的标准答案,是数据库事务加行锁。Django的ORM提供了select_for_update(),底层执行SELECT ... FOR UPDATE,对选中的行加排他锁,事务提交前其他事务对同一行的更新都会阻塞等待。

from django.db import transaction from django.db.models import F @transaction.atomic def create_appointment(request, schedule_id): schedule = Schedule.objects.select_for_update().get(id=schedule_id) if schedule.status != 'open': return error_response("该排班不可预约") if schedule.booked_slots >= schedule.total_slots: return error_response("号源已满") if Appointment.objects.filter( patient=request.user, schedule=schedule, status__in=['booked', 'completed'] ).exists(): return error_response("您已预约过该时段,请勿重复预约") Appointment.objects.create( patient=request.user, schedule=schedule, doctor=schedule.doctor, appointment_date=schedule.work_date, time_slot=schedule.time_slot, status='booked' ) schedule.booked_slots = F('booked_slots') + 1 schedule.save(update_fields=['booked_slots']) return success_response()

这段代码有三个关键点。第一,select_for_update()必须在transaction.atomic块里,离开事务它不生效。第二,锁的是schedule这一行,两个并发请求同时进来,第二个会卡在查询语句上,直到第一个事务提交或回滚。第三,更新booked_slots用的是F表达式,让数据库执行原子的字段自增,避免"读出来、改完、写回去"这个过程中被其他事务覆盖。

我实际压测过:同样的代码去掉select_for_update,并发50个请求预约同一个医生同一个时段,生成了11个成功预约,号源余量变成负数;加上行锁后,成功数严格等于号源数。这个对比结果如果写进毕设文档,比任何架构图都有说服力。

4.3 状态机:让预约记录进入可管理状态

预约记录不能只有"有"和"无"两种状态。我设计的集合是:booked已预约待就诊、completed已完成就诊、canceled患者取消、missed爽约。流转规则集中在一个枚举类里管理:

class AppointmentStatus: BOOKED = 'booked' COMPLETED = 'completed' CANCELED = 'canceled' MISSED = 'missed' TRANSITIONS = { BOOKED: {COMPLETED, CANCELED, MISSED}, }

为什么需要状态机?因为"取消预约"和"标记就诊"都依赖当前状态:已取消的不能再取消,已完成的不能回到待就诊,爽约的不能再当成正常预约。把规则集中管理,视图层只调流转方法,不会出现"某处漏判状态"的脏逻辑。

还有一个细节:missed状态怎么产生?我没引入Celery定时任务,而是做惰性更新——患者查看预约列表时,如果发现booked记录的就诊日期已经过去,就自动置为missed并释放号源。对毕设系统来说,这种惰性处理比定时任务简单得多,也够用。

4.4 取消预约与号源回补的原子性

取消预约看着简单,其实和预约一样需要事务保护。"把状态改为取消"和"把booked_slots减1"是两个操作,必须放同一个事务里,否则会出现"状态取消了但号源没释放"或"号源释放了但状态还是booked"的中间态。

@transaction.atomic def cancel_appointment(request, appointment_id): appointment = Appointment.objects.select_for_update().get( id=appointment_id, patient=request.user ) if appointment.status != AppointmentStatus.BOOKED: return error_response("当前状态不可取消") appointment.status = AppointmentStatus.CANCELED appointment.canceled_at = timezone.now() appointment.save(update_fields=['status', 'canceled_at']) schedule = Schedule.objects.select_for_update().get(id=appointment.schedule_id) schedule.booked_slots = F('booked_slots') - 1 schedule.save(update_fields=['booked_slots']) return success_response()

这里有两个容易踩的坑。第一个是必须限制patient=request.user,否则用户可以拿别人的预约单号去取消。第二个是加锁顺序:这里先锁appointment再锁schedule,而创建预约时先锁schedule再插appointment,两个事务拿锁顺序不一致,并发场景下可能死锁。怎么排查这个死锁,我在调试章节专门讲。

5. 管理员、患者、医生三个端口的界面与流程设计

5.1 管理后台:Django Admin打底,再做三处自定义

管理端如果完全自己写页面,工作量会吃掉你大量时间。我的做法是先用Django Admin把科室、医生、排班、预约四块管理能力搭起来,然后针对三个高频场景做自定义。

第一处是排班管理。默认Admin表单只能一条条添加,我用自定义admin action实现了"批量生成排班":选择医生、填起始日期和结束日期、勾选上午下午时段,一键生成多条schedule记录。这个功能演示时非常抢眼,因为它解决的是真实痛点,不是凑功能。

第二处是预约列表的筛选。list_filter按状态、科室、日期过滤,search_fields支持按患者姓名和医生姓名搜索。预约查询是管理员最高频的操作,筛选搜索做不好,后台再好看也没用。

第三处是仪表盘。我加了一个统计页,展示今日预约量、近一周预约趋势、各科室预约占比,数据用ORM的aggregate和annotate算出来,图表用纯CSS进度条就能做得清爽。答辩时这一页能直观说明"系统产生了数据、数据能被利用",是很好的加分点。

5.2 患者端:一条路走到黑的预约流程

患者端核心路径是:首页进科室列表,科室详情看医生列表,医生详情看未来7天排班,选时段确认预约,最后在个人中心看记录。这条路径要做到点击次数最少、每一步有明确反馈。

排班页最值得打磨。我做成"医生信息 + 未来7天排班表"的布局,按日期分列,每列显示上午、下午两个时段的余量,余量为0的按钮直接置灰。前端拿到后端JSON数据后用JavaScript渲染,Django返回JsonResponse,前端用fetch或jQuery的ajax处理,这是标准的局部刷新方案。

个人中心要展示什么?我的预约列表里每条显示医生、科室、日期、时段、状态、操作按钮。状态用不同颜色标签区分,操作按钮按状态动态出现:booked显示"取消预约",completed显示"查看详情",canceled和missed不再显示操作。这块逻辑就是上一章状态机在前端的映射,前后端规则保持一致,才不会出现"页面按钮跟真实状态对不上"的尴尬。

5.3 医生端:一个页面搞定出诊日常

医生端的核心场景是"今天有哪些患者约了我"。我的设计是一个工作台页面:左侧是排班列表,右侧是选中排班下的预约患者名单,每位患者后面有"确认就诊"按钮。这个页面同时查schedule和appointment两个表,通过schedule_id关联,前端做一个简单的联动交互。

"确认就诊"这个操作需要做状态校验:仅当预约状态是booked且就诊日期是今天时才允许确认,防止医生把明天的患者提前标记为已就诊。这种业务校验在视图层写清楚,比在模板里用if判断来得可靠。

医生端我还做了一个低成本的隐藏功能:当某天号源预约进度超过八成时,排班列表显示进度条提醒。成本很低,但体现了"你考虑过用户真实使用场景",比堆砌大图表更能打动评委。

注意:医生端和患者端的URL路由一定要分开命名空间,例如/doctor/和/patient/。否则一旦出现"医生也能预约自己"这种荒谬漏洞,答辩时很难收场。

6. 调试与部署:那些远程调试经常要解决的坑

6.1 时区问题:预约日期为什么会"错一天"

Django默认USE_TZ为True,所有时间按UTC存储。如果代码里用了datetime.now()而没用timezone.now(),就会出现"存进去是本地时间、读出来是UTC时间"的错乱,表现就是预约日期显示成前一天。

我的统一约定是:所有DateTimeField一律用django.utils.timezone.now()写入;排班的work_date这种纯日期字段用DateField,只存日期不做时区转换;前端显示时间用模板过滤器或前端库按本地时区格式化。把"日期"和"时间"两类字段彻底分开,这个坑基本就绝了。

还有一个连带问题:MySQL连接配置里的时区和Django的时区如果不一致,同样会出现偏差。我一般在配置里统一设置为东八区,数据库连接参数也显式指定时区,双保险。

6.2 死锁:两个事务互相等待的完整排查链路

这是我实际遇到过一次的经典问题。现象是压测时偶尔报deadlock detected,错误日志里有两条SQL互相等待。起因是:创建预约的事务先锁schedule再插appointment,而取消预约的代码一开始写的是先锁appointment再锁schedule。当两个事务并发时,事务A锁了schedule等待appointment,事务B锁了appointment等待schedule,正好形成一个环。

完整排查过程是这样的。第一步,去数据库错误日志里grep到死锁的SQL,拿到两个事务各自执行到的语句。第二步,用命令查看最近一次死锁的详细信息,确认锁模式是排他锁。第三步,画出两个事务的持锁和等待关系,发现闭环。第四步,修改取消预约代码,让加锁顺序和创建预约保持一致——都是先锁schedule再操作appointment。第五步,重新压测,死锁消失。

这套"看日志-画环-统一顺序-复测"的方法,对一切锁顺序不一致导致的死锁都适用。我把这段排查过程原样写进了毕设文档,答辩时评委看到你连死锁都系统性处理过,印象分会明显不一样。

6.3 部署三件套:静态文件、字符集、域名白名单

开发环境一切正常、部署到服务器就变形的经典问题有三个。

第一个是静态文件全丢。原因多半是DEBUG关闭后Django不再托管静态文件,需要用collectstatic把static目录收集到STATIC_ROOT,再由Web服务器托管。STATIC_URL、STATIC_ROOT、STATICFILES_DIRS三个配置各司其职,缺一个都会出问题。

第二个是中文乱码。Django侧的LANGUAGE_CODE设为中文,MySQL侧的库表字符集也必须确认是utf8mb4。很多服务器默认是latin1,建库时不指定就埋了雷。

第三个是ALLOWED_HOSTS。DEBUG关闭后Django会拦截所有不在白名单里的Host,服务器IP或域名没加进去,整个站点直接403。这三个都属于配置性错误,但恰恰是远程调试里最常被问到的三类求助内容,部署文档里提前写清楚,能省下大量来回折腾的时间。

7. 文档与答辩:代码之外的分数怎么拿

7.1 两张图定乾坤

答辩PPT里最有力的不是页面截图,而是两张图:系统架构图和数据库ER图。架构图表达"浏览器-服务端应用-数据库"三层结构以及每层核心组件;ER图画清楚五张表之间的主外键关系、一对多关系。建议用绘图工具排版,导出矢量图,别拿截图软件随便画。

别小看这两张图。评委扫一眼就知道你有没有全局视角:架构图里是否画出Web服务器、数据库、缓存这些层次,说明你想没想过真实运行环境;ER图外键关系是否清晰,说明你建模是否认真。两张图占的版面不大,但信息密度极高,值得花半天时间好好画。

7.2 准备好一个"难点故事"

评委最常问的问题是:"你觉得这个项目里哪个地方最难?"如果回答"都挺简单的",等于主动放弃这轮加分。我的建议是准备一个完整的"难点故事",就用预约并发这件事:

  • 问题:并发预约时号源可能超卖
  • 排查:用压测脚本并发50个请求复现,发现成功预约数超过号源数
  • 方案:事务加select_for_update行锁,配合F表达式原子自增
  • 验证:加上锁之后,成功数严格等于号源数

这个故事有现象、有数据、有解法、有验证,是一条完整闭环。评委想听的就是这种"发现问题-分析问题-解决问题"的过程,而不是背概念、念手册。

7.3 文档结构的顺序与测试记录的含金量

毕设文档建议按这个顺序写:绪论(背景与意义)、需求分析(功能点加用例图)、系统设计(技术架构、数据库设计、接口设计)、系统实现(核心模块代码与截图)、系统测试(功能测试加并发测试)、总结与展望。

其中最有含金量的是系统测试这一章。不要只贴"打开页面显示正常"这种套话,要贴真实的边界测试记录:连续点击两次预约提交、两个账号同时抢最后一个号源、取消预约后立即再次预约、超过就诊日期未取消被标记为爽约。每一条都对应一个真实的功能点,测试结果和预期一致用表格列出。这些记录放在文档里,比任何"系统运行稳定"的空话都有说服力,也让整个项目从"能跑"变成"被验证过"。

按这套思路做完,你会发现最难的其实不是写代码,而是把每个决策背后的理由想清楚。这也是为什么我一直建议参考源码但不要抄源码——把技术方案吃透,变成自己能表达的东西,这个项目才真正属于你。

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

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

立即咨询