简介:这是一套基于Python Django框架开发的医院挂号诊疗系统毕业设计源码案例,面向计算机相关专业的在校学生、教师以及初入行的开发者,适用于毕业设计、课程设计、作业、项目初期立项演示,也可作为Django全栈开发的学习进阶素材。压缩包内文件数量达到2000个,其中JavaScript脚本1633个、HTML页面249个、CSS样式53个,另有JSON数据文件、Python后端文件和说明文档,覆盖前端交互、页面布局与后端逻辑,结构清晰便于查阅和二次开发。资源包大小约5.57MB,附带详细设计文档,项目代码已经过测试并成功运行,获得导师认可,答辩评审分达95分,整体完成度较高。目前已有54人学习,对于需要快速搭建挂号诊疗场景、理解Django项目结构或完成课程设计与毕业论文的读者,具有很强的参考价值,可直接使用或在此基础上扩展功能。
1. 医院挂号系统不只是一个 CRUD:先看这个 Django 项目的分层设计
Django 写的医院挂号诊疗系统在毕业设计和课程设计里出现频率很高,但大多数人拿到源码后只把页面跑通就停了,真正去拆数据流的人不多。这份资源带完整文档和测试通过的源码,核心是把「科室—医生—号源—挂号记录—缴费记录」这条链路在 Django 里完整落地,前端基于 Bootstrap 全家桶,日期时间选择用的是 bootstrap-datetimepicker,整体是典型 MTV 结构,没有引入前后端分离,适合用来理解 Django 原生工作机制,也适合改造成小型诊所的预约系统。它解决的不仅是「挂个号」这个动作,还包括科室管理、医生排班、患者档案、挂号费统计这些配套场景。对刚接触 Python 和 Django 的人,把这份代码从头读一遍,比照着教程敲十遍有效得多。
2. 数据模型与挂号流程建模:从科室表到挂号记录的字段设计
2.1 五张核心表的关联关系与选型理由
在 Django 里建模医院挂号,最先要决定的是「号源」怎么表达。常见做法有两种:一种是单独建一张排班表,每天由后台生成当天可挂的号码段;另一种是不建号源表,把挂号记录当作事实表,医生上只存一个每日号源上限,通过统计当天已挂数量判断是否约满。这个资源用的是第二种,理由是它省掉了一套定时生成号源的逻辑,在毕设演示和中小流量场景下完全够用,代码量也少一截。
建议先理解这套关联关系:Department(科室)一对多 Doctor(医生),Patient(患者)一对多 Appointment(挂号记录),Appointment 外键关联 Doctor 和 Patient,再外键关联一个 User 记录操作人。挂号费、缴费记录等扩展信息挂在 Appointment 上而不是反过来,这样查询链路短,患者查历史记录、医生查当日号单都只需要一次 join。字段设计可以直接照下表对齐,这套命名在资源文档里也能一一对应上:
| 模型 | 关键字段 | 类型与约束 | 说明 |
|---|---|---|---|
| Department | name, floor | CharField, PositiveSmallIntegerField | 科室名称加 unique 约束 |
| Doctor | name, department(FK), title, daily_limit | ForeignKey, PositiveIntegerField | 职称可空,号源上限默认 30 |
| Patient | name, id_card, phone, birth_date | CharField, DateField | id_card 加 unique 约束 |
| Appointment | doctor(FK), patient(FK), date, time_slot, status, fee | DateField, CharField, DecimalField | status 表示已挂/已就诊/已取消 |
| Payment | appointment(OneToOne), amount, pay_time | OneToOneField, DecimalField | 缴费记录,一对一挂接 |
选 PositiveIntegerField 而不是 IntegerField,是为了在数据库层面挡住负数号源。fee 用 DecimalField 而不是 FloatField,避免金额精度问题,这是批量统计挂号费时最容易踩的坑,FloatField 累加会出现 0.1 + 0.2 不等于 0.3 的问题。
2.2 Appointment 模型代码与迁移要点
核心模型可以这样写,和资源里的结构基本一致:
from django.db import models from django.contrib.auth.models import User class Department(models.Model): name = models.CharField('科室名称', max_length=50, unique=True) floor = models.PositiveSmallIntegerField('所在楼层', default=1) def __str__(self): return self.name class Doctor(models.Model): name = models.CharField('医生姓名', max_length=30) department = models.ForeignKey(Department, on_delete=models.CASCADE, related_name='doctors') title = models.CharField('职称', max_length=20, blank=True) daily_limit = models.PositiveIntegerField('每日号源上限', default=30) def __str__(self): return f'{self.department.name}-{self.name}' class Appointment(models.Model): STATUS_CHOICES = ( ('booked', '已挂号'), ('done', '已就诊'), ('cancelled', '已取消'), ) doctor = models.ForeignKey(Doctor, on_delete=models.CASCADE, related_name='appointments') patient = models.ForeignKey(Patient, on_delete=models.CASCADE, related_name='appointments') operator = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) date = models.DateField('就诊日期') time_slot = models.CharField('时段', max_length=20, default='上午') status = models.CharField('状态', max_length=10, choices=STATUS_CHOICES, default='booked') fee = models.DecimalField('挂号费', max_digits=8, decimal_places=2, default=10.00) created_at = models.DateTimeField('创建时间', auto_now_add=True) class Meta: ordering = ['date', 'time_slot'] indexes = [models.Index(fields=['doctor', 'date'])]这里有两个细节值得注意。related_name 的作用是定义反向查询名,doctor.appointments.all() 可以直接拿到某位医生的全部挂号记录,不写的话 Django 默认生成 appointment_set,可读性差很多。operator 用 on_delete=models.SET_NULL 而不是 CASCADE,因为后台操作员工号被删除时,挂号记录本身是业务数据,不能跟着删,SET_NULL 要求字段可空,所以补了 null=True。
Meta 里给 (doctor, date) 建联合索引,是因为这个项目里查询最频繁的接口就是「某医生某天还剩几个号」。没有这个索引,数据量到几万条时页面会明显变慢。之后执行 python manage.py makemigrations 和 python manage.py migrate 生成并应用迁移,如果初次跑 migrate 报 MySQL 客户端依赖错误,常见做法是先装 mysqlclient,Windows 下下载对应的 whl 包再 pip install,Linux 下先确保安装了 libmysqlclient-dev。
2.3 号源校验逻辑放在哪一层
号源校验是这个系统的核心业务规则:每个医生每天最多挂 daily_limit 个号。我一般把这个校验放在 Form 层的 clean 方法里,而不是视图里,这样无论从哪个入口提交都会走同一套规则,不会出现视图 A 校验了、视图 B 漏掉的情况。
from django import forms from .models import Appointment, Doctor class AppointmentForm(forms.Form): doctor = forms.ModelChoiceField( queryset=Doctor.objects.select_related('department'), label='医生') date = forms.DateField(label='就诊日期', widget=forms.DateInput( attrs={'class': 'form-control', 'id': 'id_date'})) time_slot = forms.ChoiceField( choices=(('上午', '上午'), ('下午', '下午')), label='时段') def clean(self): cleaned = super().clean() doctor = cleaned.get('doctor') date = cleaned.get('date') if doctor and date: booked = Appointment.objects.filter( doctor=doctor, date=date, status__in=('booked', 'done') ).count() if booked >= doctor.daily_limit: raise forms.ValidationError( f'{doctor.name} 在 {date} 的号源已满') return cleaned这段代码的核心是用 count() 做数量判断,而不是把记录全部取到内存里。status__in=('booked', 'done') 表示已挂号和已就诊的都占用号源,只有 cancelled 的号自动释放。但这里有一个看不到的隐患:count 校验不做并发控制,两个请求同时提交时可能都通过校验导致超挂。这个问题到最后一章讲事务和行锁时一并解决,做并发压测的人一定会遇到。
3. 视图、路由与前端的协作:挂号表单是怎么提交的
3.1 URL 设计与视图函数拆分
项目是 MTV 架构,路由全部在 urls.py 里显式声明,没有用 DRF 那套。常见做法是挂号相关的路径单独放一个 app 管理,创建 app 用 python manage.py startapp registration,然后在主 urls.py 里用 include 挂进来。视图以函数视图为主,逻辑直观,调试时打断点也方便。
# registration/urls.py from django.urls import path from . import views urlpatterns = [ path('book/', views.book_appointment, name='book'), path('cancel/<int:pk>/', views.cancel_appointment, name='cancel'), path('my/', views.my_appointments, name='my_appointments'), ]path 里的 int:pk 是 Django 的路径转换器,它保证了 cancel/abc/ 这种非法请求直接返回 404,不会进视图再抛 ValueError。name 参数是 URL 的别名,模板里用 {% url 'book' %} 反查完整路径,后期调整路由结构时不用改模板,这一点在频繁改需求的毕设阶段非常省事。主 urls.py 里对应写 path('registration/', include('registration.urls')),前缀统一挂在 registration 下。
3.2 视图里的数据流与 messages 消息传递
挂号视图的逻辑是:GET 请求渲染空表单,POST 请求校验、建记录、重定向。注意重定向而不是直接渲染结果页,这对应了 PRG 模式(Post-Redirect-Get),防止用户刷新页面时重复提交挂号。
from decimal import Decimal from django.shortcuts import render, redirect from django.contrib import messages from .forms import AppointmentForm from .models import Appointment def book_appointment(request): if request.method == 'POST': form = AppointmentForm(request.POST) if form.is_valid(): doctor = form.cleaned_data['doctor'] Appointment.objects.create( doctor=doctor, patient=request.user.patient, operator=request.user, date=form.cleaned_data['date'], time_slot=form.cleaned_data['time_slot'], fee=Decimal('10.00'), ) messages.success(request, '挂号成功,请按时就诊') return redirect('my_appointments') else: form = AppointmentForm() return render(request, 'registration/book.html', {'form': form})这里注意三点:request.user.patient 依赖的是 User 与 Patient 之间的一对一关联,前提是在 User 创建时同步建了 Patient 档案,资源里是在注册视图里一起处理的。fee 先取 Decimal('10.00') 是硬编码,实际项目里应改成从科室或医生配置读取,资源文档里有对应的配置项。messages.success 写入一次性的会话消息,模板里消费完就清除,这正是「重定向传递数据」的标准做法,比在 URL 里拼参数干净。
3.3 Bootstrap 与 datetimepicker 的接入
前端这边,资源里已经内置了 bootstrap.css、animate.css、font-awesome.css 和 bootstrap-datetimepicker.css,整体是 Bootstrap 3 时代的组件栈。日期选择控件不直接依赖 Django 的 DateInput,而是用 bootstrap-datetimepicker 在前端格式化后再提交,接入方式如下:
<!-- templates/registration/book.html 关键片段 --> <link rel="stylesheet" href="{% static 'bootstrap-datetimepicker.min.css' %}"> <script src="{% static 'bootstrap-datetimepicker.min.js' %}"></script> <script> $(function () { $('#id_date').datetimepicker({ format: 'yyyy-mm-dd', minView: 'month', autoclose: true, startDate: new Date() }); }); </script>format 必须与 Django 的 DateField 默认解析格式一致,写成 yyyy-mm-dd 后提交的值就是 2025-01-15 这种字符串,Django 能直接转成 Python 的 date 对象。minView: 'month' 表示只选日期不选具体时间,避免用户把时间也带上导致日期字段校验失败。startDate: new Date() 禁止选过去的日期,这里有一个常见坑:如果前端控件禁选了过去日期,但后端 Form 没有对应校验,绕过前端直接 POST 仍然可以挂过去的号,建议后端也校验 date >= date.today()。
3.4 表单错误回显
clean 方法里 raise 的 ValidationError 不会挂在某个字段下,而是进入 non_field_errors,模板里如果只遍历 field.errors 就会出现「提示了号源已满但界面上看不到」的诡异现象。正确写法要同时渲染两类错误:
{% if form.non_field_errors %} <div class="alert alert-danger"> {% for e in form.non_field_errors %}{{ e }}{% endfor %} </div> {% endif %} {% for field in form %} <div class="form-group {% if field.errors %}has-error{% endif %}"> {{ field.label_tag }} {{ field }} {% for e in field.errors %}<span class="help-block">{{ e }}</span>{% endfor %} </div> {% endfor %}has-error 类来自 Bootstrap 3,可以让输入框边框变红,配合 help-block 的错误文案,交互上比较完整。这个模式在资源的前端模板里反复出现,抽出来作为公共 include 会更省事,直接改一份模板就能统一全部表单的错误样式。
4. 后台管理、统计查询与 ORM 性能细节
4.1 Admin 定制:list_display、过滤与搜索
django admin 界面美化是搜索引擎里的高频需求。这个资源没有引入第三方美化包,用原生 Admin 调好的效果也够用,关键是不要把注册写成裸的 admin.site.register(Appointment),那只能得到一个没有任何操作的列表页。定制度高的写法如下:
from django.contrib import admin from .models import Appointment, Department, Doctor @admin.register(Appointment) class AppointmentAdmin(admin.ModelAdmin): list_display = ('id', 'doctor', 'patient', 'date', 'time_slot', 'status', 'fee') list_filter = ('status', 'date') search_fields = ('patient__name', 'doctor__name', 'patient__phone') date_hierarchy = 'date' list_per_page = 20search_fields 里用 patient__name 这种双下划线写法做跨表搜索,这是 Django ORM 的约定,表示沿着外键关系查关联模型的字段。date_hierarchy 会在列表页顶部生成按日期逐级下钻的筛选条,对挂号这类强日期属性的数据非常实用。list_per_page 控制分页,后台数据量上来之后不设分页会导致整页卡死。
如果确实要美化成更现代的侧边栏风格,常见做法是引入 django-simpleui 或 django-jazzmin,只需要在 INSTALLED_APPS 里加一项,Admin 的面貌就变了,业务代码一行不用动。这个资源里没有默认带,可以自行按版本安装。
4.2 挂号量统计与聚合查询
挂号日报、科室统计这类需求,用 ORM 的 annotate 聚合就能完成,不需要写原始 SQL,也不会因为数据库切换而失效。按月统计各科室挂号人数和挂号费收入:
from django.db.models import Count, Sum from .models import Appointment report = (Appointment.objects .filter(date__month=6, status__in=('booked', 'done')) .values('doctor__department__name') .annotate(total=Count('id'), fee_sum=Sum('fee')) .order_by('-total'))values 指定分组字段,底层生成 GROUP BY;annotate 定义聚合列,Count 统计记录数,Sum 累加挂号费;order_by('-total') 按挂号量倒序。这里有一个精度陷阱:Sum('fee') 返回的是 Decimal 对象,不能在 Python 端直接和 float 做乘除运算后再比较,否则会报 TypeError 或丢失精度,统一用 Decimal 处理。另外 date__month=6 这种写法在某些数据库上不走索引,数据量大时建议改成 date__range=(start, end) 的范围查询。
4.3 执行查询与删除对象时的几个坑
热词里经常有人搜「django执行查询-删除对象」和「django reverse resolve」,这两个点在实际开发中都容易出事。queryset.delete() 与 instance.delete() 行为不同,批量删除不会触发模型里重写的 delete() 方法和信号,Appointment 级联删除时关联的 Payment 会被数据库一并删掉,这种删除不可恢复,线上操作前务必先在事务里 count 确认影响范围。URL 反查可以用 reverse 和 resolve 互相校验:
from django.urls import reverse, resolve assert reverse('book') == '/registration/book/' assert resolve('/registration/book/').view_name == 'book'resolve 是把请求路径解析回视图函数和 URL name,测试里常用它断言某个路径确实绑定了预期的视图,改路由时能第一时间发现拼写错误。资源里没有写自动化测试,这部分建议补上,两条断言成本很低但收益很高。
4.4 常见的查询性能问题对照
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
| 挂号列表页加载慢 | 每行都查一次关联表,N+1 查询 | 视图里加 select_related('doctor', 'patient') |
| 按日期筛选很慢 | date 字段无索引 | 建单列索引或在 Meta 里加入索引 |
| 后台保存报唯一约束错误 | id_card 重复录入 | 用 get_or_create 或 pre_save 校验 |
| 前端日期少一天 | USE_TZ=True 但数据库时区未对齐 | 连接串加时区参数,统一用 Asia/Shanghai |
N+1 是这里最常见的性能问题:列表页渲染 50 条挂号记录,模板里每访问一次 appointment.doctor.name 就发一条 SQL,总共 51 条查询。加 select_related 后变成一条 JOIN,50 条变 1 条。资源的小数据量下看不出来,但答辩时如果被问到「数据量大了怎么办」,能答出这一条很加分。
5. 并发挂号与事务边界:用 select_for_update 守住最后一号
第 2 章留了一个并发隐患:两个请求同时提交时,count() 都读到 8,都小于 daily_limit=10,结果挂出 11 个号。常规修法是在事务里加行锁,让后到的请求等前一个提交后再重新判断。
from django.db import transaction from .models import Doctor, Appointment @transaction.atomic def book_with_lock(doctor_id, date, patient): doctor = Doctor.objects.select_for_update().get(pk=doctor_id) booked = (Appointment.objects .filter(doctor=doctor, date=date, status__in=('booked', 'done')) .count()) if booked >= doctor.daily_limit: return False Appointment.objects.create(doctor=doctor, patient=patient, date=date, fee=doctor.daily_limit) return Trueselect_for_update() 会在数据库层面对命中的行加排他锁,MySQL InnoDB 下这段事务提交前,其他事务的相同查询会被阻塞。注意这个查询必须在 transaction.atomic 块内执行,脱离事务直接调用会抛 TransactionManagementError。锁的粒度是 Doctor 行而不是 Appointment 表,并发挂不同医生的号互不阻塞,吞吐量不受影响。
验证方式有两种。一种是在 MySQL 里开两个终端手动模拟事务,观察第二个事务是否等待;另一种是用 Django 测试客户端压两个线程:
python manage.py shellfrom django.test import Client import threading def hit(): c = Client() c.post('/registration/book/', {'doctor': 1, 'date': '2025-01-20', 'time_slot': '上午'}) t1 = threading.Thread(target=hit) t2 = threading.Thread(target=hit) t1.start(); t2.start(); t1.join(); t2.join()跑完后查挂号记录数,如果仍然大于号源上限,说明锁没生效,优先检查是否用了 MyISAM 表——InnoDB 的行锁在 MyISAM 下会退化成表锁或直接失效。生产部署到云服务器时,常见做法是用宝塔面板配 Python 项目管理器,把 gunicorn 和 nginx 串起来,但堡垒机前的最后一关一定是数据库引擎,建表时确认 ENGINE=InnoDB,这条不满足,其他优化都白做。把这段逻辑跑一遍再配合 explain 看执行计划,超挂问题基本就堵死了。
本文还有配套的精品资源,点击获取